mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
feat: TimeTracker polish and production readiness (plan implementation)
Quick wins (Phase A): - A1: Quick timer actions — last timer context, Repeat last button, Quick start one-click form; pre-fill modal and tags from last entry - A2: Unified empty states using empty_state macro on custom_view, time_entry_templates, saved_filters, issues; add loading_placeholder macro - A3: Dashboard hierarchy — Activity and Support/Donate moved to secondary row below fold with reduced visual weight - A4: Error/feedback consistency (flash-to-toast already in place) Medium impact (Phase B): - B5: Split API v1 — api_v1_common.py (shared helpers), api_v1_time_entries.py sub-blueprint for time-entries and timer/*; register api_v1_time_entries_bp - B6: Start Timer UX — templates as prominent chips at top of modal; default last context and quick start from A1 - B7: Week in review — ReportingService.get_week_in_review(), route /reports/week-in-review, template and link from reports index - B8: Tags discoverability — GET /api/tags, recent_tags in dashboard, tags input with datalist in Start Timer modal; last context includes tags - B9: Frontend consolidation — document onboarding.js vs onboarding-enhanced.js in base.html - B10: API validation — Marshmallow TimeEntryCreateSchema/TimeEntryUpdateSchema and handle_validation_error in api_v1_time_entries create/update UX: Remove duplicate Timer actions — single Repeat last and Start Timer in header; body shows only Resume when recent entries exist (no duplicate Repeat last or Start new).
This commit is contained in:
@@ -14,6 +14,7 @@ def register_all_blueprints(app, logger=None):
|
||||
from app.routes.admin import admin_bp
|
||||
from app.routes.api import api_bp
|
||||
from app.routes.api_v1 import api_v1_bp
|
||||
from app.routes.api_v1_time_entries import api_v1_time_entries_bp
|
||||
from app.routes.api_docs import api_docs_bp, swaggerui_blueprint
|
||||
from app.routes.analytics import analytics_bp
|
||||
from app.routes.tasks import tasks_bp
|
||||
@@ -67,6 +68,7 @@ def register_all_blueprints(app, logger=None):
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(api_v1_bp)
|
||||
app.register_blueprint(api_v1_time_entries_bp)
|
||||
app.register_blueprint(api_docs_bp)
|
||||
app.register_blueprint(swaggerui_blueprint)
|
||||
app.register_blueprint(analytics_bp)
|
||||
|
||||
@@ -58,6 +58,35 @@ def timer_status():
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/api/tags")
|
||||
@login_required
|
||||
def get_recent_tags():
|
||||
"""Return distinct tags from current user's time entries for autocomplete (e.g. Start Timer modal)."""
|
||||
limit = min(request.args.get("limit", 30, type=int), 100)
|
||||
entries = (
|
||||
TimeEntry.query.filter(
|
||||
TimeEntry.user_id == current_user.id,
|
||||
TimeEntry.tags.isnot(None),
|
||||
TimeEntry.tags != "",
|
||||
)
|
||||
.order_by(TimeEntry.updated_at.desc())
|
||||
.limit(500)
|
||||
.all()
|
||||
)
|
||||
tags_set = set()
|
||||
for e in entries:
|
||||
if e.tags:
|
||||
for part in e.tags.split(","):
|
||||
t = part.strip()
|
||||
if t:
|
||||
tags_set.add(t)
|
||||
if len(tags_set) >= limit:
|
||||
break
|
||||
if len(tags_set) >= limit:
|
||||
break
|
||||
return jsonify({"tags": sorted(tags_set)})
|
||||
|
||||
|
||||
@api_bp.route("/api/search")
|
||||
@login_required
|
||||
def search():
|
||||
|
||||
+9
-590
@@ -64,97 +64,14 @@ from app.models.time_entry import local_now
|
||||
|
||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
# ==================== Helper Functions ====================
|
||||
|
||||
|
||||
def paginate_query(query, page=None, per_page=None):
|
||||
"""Paginate a SQLAlchemy query"""
|
||||
page = page or int(request.args.get("page", 1))
|
||||
per_page = per_page or int(request.args.get("per_page", 50))
|
||||
per_page = min(per_page, 100) # Max 100 items per page
|
||||
|
||||
paginated = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
return {
|
||||
"items": paginated.items,
|
||||
"pagination": {
|
||||
"page": paginated.page,
|
||||
"per_page": paginated.per_page,
|
||||
"total": paginated.total,
|
||||
"pages": paginated.pages,
|
||||
"has_next": paginated.has_next,
|
||||
"has_prev": paginated.has_prev,
|
||||
"next_page": paginated.page + 1 if paginated.has_next else None,
|
||||
"prev_page": paginated.page - 1 if paginated.has_prev else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_datetime(dt_str):
|
||||
"""Parse datetime string from API request"""
|
||||
if not dt_str:
|
||||
return None
|
||||
try:
|
||||
# Handle ISO format with timezone
|
||||
ts = dt_str.strip()
|
||||
if ts.endswith("Z"):
|
||||
ts = ts[:-1] + "+00:00"
|
||||
dt = datetime.fromisoformat(ts)
|
||||
# Convert to local naive for storage
|
||||
if dt.tzinfo is not None:
|
||||
dt = utc_to_local(dt).replace(tzinfo=None)
|
||||
return dt
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date(dstr):
|
||||
"""Parse a YYYY-MM-DD string to date."""
|
||||
if not dstr:
|
||||
return None
|
||||
try:
|
||||
from datetime import date as _date
|
||||
|
||||
return _date.fromisoformat(str(dstr))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date_range(start_date_str, end_date_str):
|
||||
"""Parse start/end date params. Date-only end_date becomes end-of-day."""
|
||||
start_dt = parse_datetime(start_date_str) if start_date_str else None
|
||||
end_dt = parse_datetime(end_date_str) if end_date_str else None
|
||||
|
||||
# If end_date is date-only (YYYY-MM-DD), treat as end of that day
|
||||
if end_date_str and end_dt and "T" not in end_date_str.strip() and " " not in end_date_str.strip():
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
return start_dt, end_dt
|
||||
|
||||
|
||||
def _require_module_enabled_for_api(module_id: str):
|
||||
"""Return a Flask response tuple if module is disabled for this API user; otherwise None."""
|
||||
try:
|
||||
from app.models import Settings
|
||||
from app.utils.module_registry import ModuleRegistry
|
||||
|
||||
settings = Settings.get_settings()
|
||||
user = getattr(g, "api_user", None)
|
||||
if not ModuleRegistry.is_enabled(module_id, settings, user):
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "module_disabled",
|
||||
"message": f"{module_id} module is disabled by the administrator.",
|
||||
}
|
||||
),
|
||||
403,
|
||||
)
|
||||
except Exception:
|
||||
# If we can't check, default to allow (avoid breaking API during migrations/startup)
|
||||
return None
|
||||
return None
|
||||
# Shared helpers for API v1 (used here and in api_v1_time_entries)
|
||||
from app.routes.api_v1_common import (
|
||||
paginate_query,
|
||||
parse_datetime,
|
||||
_parse_date,
|
||||
_parse_date_range,
|
||||
_require_module_enabled_for_api,
|
||||
)
|
||||
|
||||
|
||||
# ==================== API Info & Health ====================
|
||||
@@ -568,505 +485,7 @@ def delete_project(project_id):
|
||||
return jsonify({"message": "Project archived successfully"})
|
||||
|
||||
|
||||
# ==================== Time Entries ====================
|
||||
|
||||
|
||||
@api_v1_bp.route("/time-entries", methods=["GET"])
|
||||
@require_api_token("read:time_entries")
|
||||
def list_time_entries():
|
||||
"""List time entries
|
||||
---
|
||||
tags:
|
||||
- Time Entries
|
||||
parameters:
|
||||
- name: project_id
|
||||
in: query
|
||||
type: integer
|
||||
- name: user_id
|
||||
in: query
|
||||
type: integer
|
||||
- name: start_date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
- name: end_date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
- name: billable
|
||||
in: query
|
||||
type: boolean
|
||||
- name: page
|
||||
in: query
|
||||
type: integer
|
||||
- name: per_page
|
||||
in: query
|
||||
type: integer
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: List of time entries
|
||||
"""
|
||||
from app.services import TimeTrackingService
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
# Filter by project
|
||||
project_id = request.args.get("project_id", type=int)
|
||||
|
||||
# Filter by user (non-admin can only see their own)
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
if user_id:
|
||||
if not g.api_user.is_admin and user_id != g.api_user.id:
|
||||
return jsonify({"error": "Access denied"}), 403
|
||||
else:
|
||||
# Default to current user's entries if not admin
|
||||
if not g.api_user.is_admin:
|
||||
user_id = g.api_user.id
|
||||
|
||||
# Filter by date range
|
||||
start_date = request.args.get("start_date")
|
||||
end_date = request.args.get("end_date")
|
||||
start_dt, end_dt = _parse_date_range(start_date, end_date)
|
||||
|
||||
# Filter by billable
|
||||
billable = request.args.get("billable")
|
||||
billable_filter = None
|
||||
if billable is not None:
|
||||
billable_filter = billable.lower() == "true"
|
||||
|
||||
# Only completed entries by default
|
||||
include_active = request.args.get("include_active") == "true"
|
||||
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = request.args.get("per_page", 50, type=int)
|
||||
|
||||
# Use repository with eager loading to avoid N+1 queries
|
||||
from app.repositories import TimeEntryRepository
|
||||
|
||||
time_entry_repo = TimeEntryRepository()
|
||||
|
||||
# Build query with eager loading (use model.query for base query)
|
||||
from app.models import TimeEntry
|
||||
|
||||
query = TimeEntry.query.options(
|
||||
joinedload(TimeEntry.project), joinedload(TimeEntry.user), joinedload(TimeEntry.task)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if project_id:
|
||||
query = query.filter(TimeEntry.project_id == project_id)
|
||||
if user_id:
|
||||
query = query.filter(TimeEntry.user_id == user_id)
|
||||
if start_dt:
|
||||
query = query.filter(TimeEntry.start_time >= start_dt)
|
||||
if end_dt:
|
||||
query = query.filter(TimeEntry.start_time <= end_dt)
|
||||
if billable_filter is not None:
|
||||
query = query.filter(TimeEntry.billable == billable_filter)
|
||||
if not include_active:
|
||||
query = query.filter(TimeEntry.end_time.isnot(None))
|
||||
|
||||
# Order and paginate
|
||||
query = query.order_by(TimeEntry.start_time.desc())
|
||||
result = paginate_query(query, page, per_page)
|
||||
|
||||
return jsonify({"time_entries": [e.to_dict() for e in result["items"]], "pagination": result["pagination"]})
|
||||
|
||||
|
||||
@api_v1_bp.route("/time-entries/<int:entry_id>", methods=["GET"])
|
||||
@require_api_token("read:time_entries")
|
||||
def get_time_entry(entry_id):
|
||||
"""Get a specific time entry
|
||||
---
|
||||
tags:
|
||||
- Time Entries
|
||||
parameters:
|
||||
- name: entry_id
|
||||
in: path
|
||||
type: integer
|
||||
required: true
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Time entry details
|
||||
404:
|
||||
description: Time entry not found
|
||||
"""
|
||||
from sqlalchemy.orm import joinedload
|
||||
from app.models import TimeEntry
|
||||
|
||||
entry = (
|
||||
TimeEntry.query.options(joinedload(TimeEntry.project), joinedload(TimeEntry.user), joinedload(TimeEntry.task))
|
||||
.filter_by(id=entry_id)
|
||||
.first_or_404()
|
||||
)
|
||||
|
||||
# Check permissions
|
||||
if not g.api_user.is_admin and entry.user_id != g.api_user.id:
|
||||
return jsonify({"error": "Access denied"}), 403
|
||||
|
||||
return jsonify({"time_entry": entry.to_dict()})
|
||||
|
||||
|
||||
@api_v1_bp.route("/time-entries", methods=["POST"])
|
||||
@require_api_token("write:time_entries")
|
||||
def create_time_entry():
|
||||
"""Create a new time entry
|
||||
---
|
||||
tags:
|
||||
- Time Entries
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- project_id
|
||||
- start_time
|
||||
properties:
|
||||
project_id:
|
||||
type: integer
|
||||
task_id:
|
||||
type: integer
|
||||
start_time:
|
||||
type: string
|
||||
format: date-time
|
||||
end_time:
|
||||
type: string
|
||||
format: date-time
|
||||
notes:
|
||||
type: string
|
||||
tags:
|
||||
type: string
|
||||
billable:
|
||||
type: boolean
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
201:
|
||||
description: Time entry created
|
||||
400:
|
||||
description: Invalid input
|
||||
"""
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Validate required fields
|
||||
if not data.get("project_id") and not data.get("client_id"):
|
||||
return jsonify({"error": "Either project_id or client_id is required"}), 400
|
||||
if not data.get("start_time"):
|
||||
return jsonify({"error": "start_time is required"}), 400
|
||||
|
||||
# Parse times
|
||||
start_time = parse_datetime(data["start_time"])
|
||||
if not start_time:
|
||||
return jsonify({"error": "Invalid start_time format"}), 400
|
||||
|
||||
end_time = None
|
||||
if data.get("end_time"):
|
||||
end_time = parse_datetime(data["end_time"])
|
||||
if end_time and end_time <= start_time:
|
||||
return jsonify({"error": "end_time must be after start_time"}), 400
|
||||
|
||||
# Use service layer to create time entry
|
||||
time_tracking_service = TimeTrackingService()
|
||||
result = time_tracking_service.create_manual_entry(
|
||||
user_id=g.api_user.id,
|
||||
project_id=data.get("project_id"),
|
||||
client_id=data.get("client_id"),
|
||||
start_time=start_time,
|
||||
end_time=end_time or start_time, # Service requires end_time
|
||||
task_id=data.get("task_id"),
|
||||
notes=data.get("notes"),
|
||||
tags=data.get("tags"),
|
||||
billable=data.get("billable", True),
|
||||
paid=data.get("paid", False),
|
||||
invoice_number=data.get("invoice_number"),
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not create time entry")}), 400
|
||||
|
||||
entry = result.get("entry")
|
||||
|
||||
# Log activity
|
||||
if entry:
|
||||
from app.models import Activity
|
||||
from app.utils.audit import get_request_info
|
||||
|
||||
entity_name = entry.project.name if entry.project else (entry.client.name if entry.client else "Unknown")
|
||||
task_name = entry.task.name if entry.task else None
|
||||
duration_formatted = entry.duration_formatted if hasattr(entry, 'duration_formatted') else "0:00"
|
||||
ip_address, user_agent, _ = get_request_info()
|
||||
|
||||
Activity.log(
|
||||
user_id=g.api_user.id,
|
||||
action="created",
|
||||
entity_type="time_entry",
|
||||
entity_id=entry.id,
|
||||
entity_name=f"{entity_name}" + (f" - {task_name}" if task_name else ""),
|
||||
description=f'Created time entry for {entity_name}' + (f" - {task_name}" if task_name else "") + f' - {duration_formatted}',
|
||||
extra_data={
|
||||
"project_name": entry.project.name if entry.project else None,
|
||||
"client_name": entry.client.name if entry.client else None,
|
||||
"task_name": task_name,
|
||||
"duration_formatted": duration_formatted,
|
||||
"duration_hours": entry.duration_hours if hasattr(entry, 'duration_hours') else None,
|
||||
},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
return jsonify({"message": "Time entry created successfully", "time_entry": result["entry"].to_dict()}), 201
|
||||
|
||||
|
||||
@api_v1_bp.route("/time-entries/<int:entry_id>", methods=["PUT", "PATCH"])
|
||||
@require_api_token("write:time_entries")
|
||||
def update_time_entry(entry_id):
|
||||
"""Update a time entry
|
||||
---
|
||||
tags:
|
||||
- Time Entries
|
||||
parameters:
|
||||
- name: entry_id
|
||||
in: path
|
||||
type: integer
|
||||
required: true
|
||||
- name: body
|
||||
in: body
|
||||
schema:
|
||||
type: object
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Time entry updated
|
||||
404:
|
||||
description: Time entry not found
|
||||
"""
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Parse times
|
||||
start_time = None
|
||||
if "start_time" in data:
|
||||
start_time = parse_datetime(data["start_time"])
|
||||
|
||||
end_time = None
|
||||
if "end_time" in data:
|
||||
if data["end_time"] is None:
|
||||
end_time = None
|
||||
else:
|
||||
end_time = parse_datetime(data["end_time"])
|
||||
|
||||
# Use service layer to update time entry
|
||||
time_tracking_service = TimeTrackingService()
|
||||
result = time_tracking_service.update_entry(
|
||||
entry_id=entry_id,
|
||||
user_id=g.api_user.id,
|
||||
is_admin=g.api_user.is_admin,
|
||||
project_id=data.get("project_id"),
|
||||
client_id=data.get("client_id"),
|
||||
task_id=data.get("task_id"),
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
notes=data.get("notes"),
|
||||
tags=data.get("tags"),
|
||||
billable=data.get("billable"),
|
||||
paid=data.get("paid"),
|
||||
invoice_number=data.get("invoice_number"),
|
||||
reason=data.get("reason"),
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not update time entry")}), 400
|
||||
|
||||
entry = result.get("entry")
|
||||
|
||||
# Log activity
|
||||
if entry:
|
||||
from app.models import Activity
|
||||
from app.utils.audit import get_request_info
|
||||
|
||||
entity_name = entry.project.name if entry.project else (entry.client.name if entry.client else "Unknown")
|
||||
task_name = entry.task.name if entry.task else None
|
||||
ip_address, user_agent, _ = get_request_info()
|
||||
|
||||
Activity.log(
|
||||
user_id=g.api_user.id,
|
||||
action="updated",
|
||||
entity_type="time_entry",
|
||||
entity_id=entry.id,
|
||||
entity_name=f"{entity_name}" + (f" - {task_name}" if task_name else ""),
|
||||
description=f'Updated time entry for {entity_name}' + (f" - {task_name}" if task_name else ""),
|
||||
extra_data={
|
||||
"project_name": entry.project.name if entry.project else None,
|
||||
"client_name": entry.client.name if entry.client else None,
|
||||
"task_name": task_name,
|
||||
},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
return jsonify({"message": "Time entry updated successfully", "time_entry": result["entry"].to_dict()})
|
||||
|
||||
|
||||
@api_v1_bp.route("/time-entries/<int:entry_id>", methods=["DELETE"])
|
||||
@require_api_token("write:time_entries")
|
||||
def delete_time_entry(entry_id):
|
||||
"""Delete a time entry
|
||||
---
|
||||
tags:
|
||||
- Time Entries
|
||||
parameters:
|
||||
- name: entry_id
|
||||
in: path
|
||||
type: integer
|
||||
required: true
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Time entry deleted
|
||||
404:
|
||||
description: Time entry not found
|
||||
"""
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
data = request.get_json() or {}
|
||||
reason = data.get("reason") # Optional reason for deletion
|
||||
|
||||
time_tracking_service = TimeTrackingService()
|
||||
result = time_tracking_service.delete_entry(
|
||||
entry_id=entry_id,
|
||||
user_id=g.api_user.id,
|
||||
is_admin=g.api_user.is_admin,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not delete time entry")}), 400
|
||||
|
||||
return jsonify({"message": "Time entry deleted successfully"})
|
||||
|
||||
|
||||
# ==================== Timer Control ====================
|
||||
|
||||
|
||||
@api_v1_bp.route("/timer/status", methods=["GET"])
|
||||
@require_api_token("read:time_entries")
|
||||
def timer_status():
|
||||
"""Get current timer status for authenticated user
|
||||
---
|
||||
tags:
|
||||
- Timer
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Timer status
|
||||
"""
|
||||
active_timer = g.api_user.active_timer
|
||||
|
||||
if not active_timer:
|
||||
return jsonify({"active": False, "timer": None})
|
||||
|
||||
return jsonify({"active": True, "timer": active_timer.to_dict()})
|
||||
|
||||
|
||||
@api_v1_bp.route("/timer/start", methods=["POST"])
|
||||
@require_api_token("write:time_entries")
|
||||
def start_timer():
|
||||
"""Start a new timer
|
||||
---
|
||||
tags:
|
||||
- Timer
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- project_id
|
||||
properties:
|
||||
project_id:
|
||||
type: integer
|
||||
task_id:
|
||||
type: integer
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
201:
|
||||
description: Timer started
|
||||
400:
|
||||
description: Invalid input or timer already running
|
||||
"""
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Validate project_id
|
||||
project_id = data.get("project_id")
|
||||
if not project_id:
|
||||
return jsonify({"error": "project_id is required"}), 400
|
||||
|
||||
# Use service layer to start timer
|
||||
time_tracking_service = TimeTrackingService()
|
||||
result = time_tracking_service.start_timer(
|
||||
user_id=g.api_user.id,
|
||||
project_id=project_id,
|
||||
task_id=data.get("task_id"),
|
||||
notes=data.get("notes"),
|
||||
template_id=data.get("template_id"),
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not start timer")}), 400
|
||||
|
||||
return jsonify({"message": "Timer started successfully", "timer": result["timer"].to_dict()}), 201
|
||||
|
||||
|
||||
@api_v1_bp.route("/timer/stop", methods=["POST"])
|
||||
@require_api_token("write:time_entries")
|
||||
def stop_timer():
|
||||
"""Stop the active timer
|
||||
---
|
||||
tags:
|
||||
- Timer
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Timer stopped
|
||||
400:
|
||||
description: No active timer
|
||||
"""
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
# Use same active_timer lookup as timer/status so stop always sees the same state
|
||||
active_timer = g.api_user.active_timer
|
||||
if not active_timer:
|
||||
return jsonify({
|
||||
"error": "No active timer to stop",
|
||||
"error_code": "no_active_timer",
|
||||
}), 400
|
||||
|
||||
time_tracking_service = TimeTrackingService()
|
||||
result = time_tracking_service.stop_timer(user_id=g.api_user.id, entry_id=active_timer.id)
|
||||
|
||||
if not result.get("success"):
|
||||
return jsonify({
|
||||
"error": result.get("message", "Could not stop timer"),
|
||||
"error_code": result.get("error", "stop_failed"),
|
||||
}), 400
|
||||
|
||||
return jsonify({"message": "Timer stopped successfully", "time_entry": result["entry"].to_dict()})
|
||||
# Time entries and timer routes are in api_v1_time_entries.py (api_v1_time_entries_bp)
|
||||
|
||||
|
||||
# ==================== Tasks ====================
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Shared helpers for API v1 routes.
|
||||
Used by api_v1.py and by domain-specific sub-blueprints (e.g. api_v1_time_entries).
|
||||
"""
|
||||
|
||||
from flask import request, jsonify, g
|
||||
|
||||
|
||||
def paginate_query(query, page=None, per_page=None):
|
||||
"""Paginate a SQLAlchemy query."""
|
||||
page = page or int(request.args.get("page", 1))
|
||||
per_page = per_page or int(request.args.get("per_page", 50))
|
||||
per_page = min(per_page, 100)
|
||||
|
||||
paginated = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
return {
|
||||
"items": paginated.items,
|
||||
"pagination": {
|
||||
"page": paginated.page,
|
||||
"per_page": paginated.per_page,
|
||||
"total": paginated.total,
|
||||
"pages": paginated.pages,
|
||||
"has_next": paginated.has_next,
|
||||
"has_prev": paginated.has_prev,
|
||||
"next_page": paginated.page + 1 if paginated.has_next else None,
|
||||
"prev_page": paginated.page - 1 if paginated.has_prev else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_datetime(dt_str):
|
||||
"""Parse datetime string from API request."""
|
||||
if not dt_str:
|
||||
return None
|
||||
try:
|
||||
from app.utils.timezone import utc_to_local
|
||||
|
||||
ts = dt_str.strip()
|
||||
if ts.endswith("Z"):
|
||||
ts = ts[:-1] + "+00:00"
|
||||
from datetime import datetime
|
||||
|
||||
dt = datetime.fromisoformat(ts)
|
||||
if dt.tzinfo is not None:
|
||||
dt = utc_to_local(dt).replace(tzinfo=None)
|
||||
return dt
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date(dstr):
|
||||
"""Parse a YYYY-MM-DD string to date."""
|
||||
if not dstr:
|
||||
return None
|
||||
try:
|
||||
from datetime import date as _date
|
||||
|
||||
return _date.fromisoformat(str(dstr))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date_range(start_date_str, end_date_str):
|
||||
"""Parse start/end date params. Date-only end_date becomes end-of-day."""
|
||||
start_dt = parse_datetime(start_date_str) if start_date_str else None
|
||||
end_dt = parse_datetime(end_date_str) if end_date_str else None
|
||||
|
||||
if end_date_str and end_dt and "T" not in end_date_str.strip() and " " not in end_date_str.strip():
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
return start_dt, end_dt
|
||||
|
||||
|
||||
def _require_module_enabled_for_api(module_id: str):
|
||||
"""Return a Flask response tuple if module is disabled for this API user; otherwise None."""
|
||||
try:
|
||||
from app.models import Settings
|
||||
from app.utils.module_registry import ModuleRegistry
|
||||
|
||||
settings = Settings.get_settings()
|
||||
user = getattr(g, "api_user", None)
|
||||
if not ModuleRegistry.is_enabled(module_id, settings, user):
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "module_disabled",
|
||||
"message": f"{module_id} module is disabled by the administrator.",
|
||||
}
|
||||
),
|
||||
403,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
API v1 - Time Entries and Timer endpoints.
|
||||
Sub-blueprint for /api/v1/time-entries and /api/v1/timer/*.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
from marshmallow import ValidationError
|
||||
from app.utils.api_auth import require_api_token
|
||||
from app.utils.api_responses import validation_error_response, handle_validation_error
|
||||
from app.routes.api_v1_common import paginate_query, parse_datetime, _parse_date_range
|
||||
from app.schemas.time_entry_schema import TimeEntryCreateSchema, TimeEntryUpdateSchema
|
||||
|
||||
api_v1_time_entries_bp = Blueprint("api_v1_time_entries", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@api_v1_time_entries_bp.route("/time-entries", methods=["GET"])
|
||||
@require_api_token("read:time_entries")
|
||||
def list_time_entries():
|
||||
"""List time entries with filters."""
|
||||
from sqlalchemy.orm import joinedload
|
||||
from app.models import TimeEntry
|
||||
|
||||
project_id = request.args.get("project_id", type=int)
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
if user_id:
|
||||
if not g.api_user.is_admin and user_id != g.api_user.id:
|
||||
return jsonify({"error": "Access denied"}), 403
|
||||
else:
|
||||
if not g.api_user.is_admin:
|
||||
user_id = g.api_user.id
|
||||
|
||||
start_date = request.args.get("start_date")
|
||||
end_date = request.args.get("end_date")
|
||||
start_dt, end_dt = _parse_date_range(start_date, end_date)
|
||||
|
||||
billable = request.args.get("billable")
|
||||
billable_filter = None
|
||||
if billable is not None:
|
||||
billable_filter = billable.lower() == "true"
|
||||
|
||||
include_active = request.args.get("include_active") == "true"
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = request.args.get("per_page", 50, type=int)
|
||||
|
||||
query = TimeEntry.query.options(
|
||||
joinedload(TimeEntry.project), joinedload(TimeEntry.user), joinedload(TimeEntry.task)
|
||||
)
|
||||
if project_id:
|
||||
query = query.filter(TimeEntry.project_id == project_id)
|
||||
if user_id:
|
||||
query = query.filter(TimeEntry.user_id == user_id)
|
||||
if start_dt:
|
||||
query = query.filter(TimeEntry.start_time >= start_dt)
|
||||
if end_dt:
|
||||
query = query.filter(TimeEntry.start_time <= end_dt)
|
||||
if billable_filter is not None:
|
||||
query = query.filter(TimeEntry.billable == billable_filter)
|
||||
if not include_active:
|
||||
query = query.filter(TimeEntry.end_time.isnot(None))
|
||||
|
||||
query = query.order_by(TimeEntry.start_time.desc())
|
||||
result = paginate_query(query, page, per_page)
|
||||
return jsonify({"time_entries": [e.to_dict() for e in result["items"]], "pagination": result["pagination"]})
|
||||
|
||||
|
||||
@api_v1_time_entries_bp.route("/time-entries/<int:entry_id>", methods=["GET"])
|
||||
@require_api_token("read:time_entries")
|
||||
def get_time_entry(entry_id):
|
||||
"""Get a specific time entry."""
|
||||
from sqlalchemy.orm import joinedload
|
||||
from app.models import TimeEntry
|
||||
|
||||
entry = (
|
||||
TimeEntry.query.options(joinedload(TimeEntry.project), joinedload(TimeEntry.user), joinedload(TimeEntry.task))
|
||||
.filter_by(id=entry_id)
|
||||
.first_or_404()
|
||||
)
|
||||
if not g.api_user.is_admin and entry.user_id != g.api_user.id:
|
||||
return jsonify({"error": "Access denied"}), 403
|
||||
return jsonify({"time_entry": entry.to_dict()})
|
||||
|
||||
|
||||
@api_v1_time_entries_bp.route("/time-entries", methods=["POST"])
|
||||
@require_api_token("write:time_entries")
|
||||
def create_time_entry():
|
||||
"""Create a new time entry."""
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
data = request.get_json() or {}
|
||||
schema = TimeEntryCreateSchema()
|
||||
try:
|
||||
validated = schema.load(data)
|
||||
except ValidationError as err:
|
||||
return handle_validation_error(err)
|
||||
|
||||
start_time = validated["start_time"]
|
||||
end_time = validated.get("end_time") or start_time
|
||||
|
||||
time_tracking_service = TimeTrackingService()
|
||||
result = time_tracking_service.create_manual_entry(
|
||||
user_id=g.api_user.id,
|
||||
project_id=validated.get("project_id"),
|
||||
client_id=validated.get("client_id"),
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
task_id=validated.get("task_id"),
|
||||
notes=validated.get("notes"),
|
||||
tags=validated.get("tags"),
|
||||
billable=validated.get("billable", True),
|
||||
paid=validated.get("paid", False),
|
||||
invoice_number=validated.get("invoice_number"),
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not create time entry")}), 400
|
||||
|
||||
entry = result.get("entry")
|
||||
if entry:
|
||||
from app.models import Activity
|
||||
from app.utils.audit import get_request_info
|
||||
|
||||
entity_name = entry.project.name if entry.project else (entry.client.name if entry.client else "Unknown")
|
||||
task_name = entry.task.name if entry.task else None
|
||||
duration_formatted = entry.duration_formatted if hasattr(entry, "duration_formatted") else "0:00"
|
||||
ip_address, user_agent, _ = get_request_info()
|
||||
Activity.log(
|
||||
user_id=g.api_user.id,
|
||||
action="created",
|
||||
entity_type="time_entry",
|
||||
entity_id=entry.id,
|
||||
entity_name=f"{entity_name}" + (f" - {task_name}" if task_name else ""),
|
||||
description=f"Created time entry for {entity_name}"
|
||||
+ (f" - {task_name}" if task_name else "")
|
||||
+ f" - {duration_formatted}",
|
||||
extra_data={
|
||||
"project_name": entry.project.name if entry.project else None,
|
||||
"client_name": entry.client.name if entry.client else None,
|
||||
"task_name": task_name,
|
||||
"duration_formatted": duration_formatted,
|
||||
"duration_hours": entry.duration_hours if hasattr(entry, "duration_hours") else None,
|
||||
},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
return jsonify({"message": "Time entry created successfully", "time_entry": result["entry"].to_dict()}), 201
|
||||
|
||||
|
||||
@api_v1_time_entries_bp.route("/time-entries/<int:entry_id>", methods=["PUT", "PATCH"])
|
||||
@require_api_token("write:time_entries")
|
||||
def update_time_entry(entry_id):
|
||||
"""Update a time entry."""
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
data = request.get_json() or {}
|
||||
schema = TimeEntryUpdateSchema()
|
||||
try:
|
||||
validated = schema.load(data, partial=True)
|
||||
except ValidationError as err:
|
||||
return handle_validation_error(err)
|
||||
|
||||
time_tracking_service = TimeTrackingService()
|
||||
result = time_tracking_service.update_entry(
|
||||
entry_id=entry_id,
|
||||
user_id=g.api_user.id,
|
||||
is_admin=g.api_user.is_admin,
|
||||
project_id=validated.get("project_id"),
|
||||
client_id=validated.get("client_id"),
|
||||
task_id=validated.get("task_id"),
|
||||
start_time=validated.get("start_time"),
|
||||
end_time=validated.get("end_time"),
|
||||
notes=validated.get("notes"),
|
||||
tags=validated.get("tags"),
|
||||
billable=validated.get("billable"),
|
||||
paid=validated.get("paid"),
|
||||
invoice_number=validated.get("invoice_number"),
|
||||
reason=data.get("reason"),
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not update time entry")}), 400
|
||||
|
||||
entry = result.get("entry")
|
||||
if entry:
|
||||
from app.models import Activity
|
||||
from app.utils.audit import get_request_info
|
||||
|
||||
entity_name = entry.project.name if entry.project else (entry.client.name if entry.client else "Unknown")
|
||||
task_name = entry.task.name if entry.task else None
|
||||
ip_address, user_agent, _ = get_request_info()
|
||||
Activity.log(
|
||||
user_id=g.api_user.id,
|
||||
action="updated",
|
||||
entity_type="time_entry",
|
||||
entity_id=entry.id,
|
||||
entity_name=f"{entity_name}" + (f" - {task_name}" if task_name else ""),
|
||||
description=f"Updated time entry for {entity_name}" + (f" - {task_name}" if task_name else ""),
|
||||
extra_data={
|
||||
"project_name": entry.project.name if entry.project else None,
|
||||
"client_name": entry.client.name if entry.client else None,
|
||||
"task_name": task_name,
|
||||
},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
return jsonify({"message": "Time entry updated successfully", "time_entry": result["entry"].to_dict()})
|
||||
|
||||
|
||||
@api_v1_time_entries_bp.route("/time-entries/<int:entry_id>", methods=["DELETE"])
|
||||
@require_api_token("write:time_entries")
|
||||
def delete_time_entry(entry_id):
|
||||
"""Delete a time entry."""
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
data = request.get_json() or {}
|
||||
reason = data.get("reason")
|
||||
time_tracking_service = TimeTrackingService()
|
||||
result = time_tracking_service.delete_entry(
|
||||
entry_id=entry_id,
|
||||
user_id=g.api_user.id,
|
||||
is_admin=g.api_user.is_admin,
|
||||
reason=reason,
|
||||
)
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not delete time entry")}), 400
|
||||
return jsonify({"message": "Time entry deleted successfully"})
|
||||
|
||||
|
||||
@api_v1_time_entries_bp.route("/timer/status", methods=["GET"])
|
||||
@require_api_token("read:time_entries")
|
||||
def timer_status():
|
||||
"""Get current timer status."""
|
||||
active_timer = g.api_user.active_timer
|
||||
if not active_timer:
|
||||
return jsonify({"active": False, "timer": None})
|
||||
return jsonify({"active": True, "timer": active_timer.to_dict()})
|
||||
|
||||
|
||||
@api_v1_time_entries_bp.route("/timer/start", methods=["POST"])
|
||||
@require_api_token("write:time_entries")
|
||||
def start_timer():
|
||||
"""Start a new timer."""
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
data = request.get_json() or {}
|
||||
project_id = data.get("project_id")
|
||||
if not project_id:
|
||||
return jsonify({"error": "project_id is required"}), 400
|
||||
time_tracking_service = TimeTrackingService()
|
||||
result = time_tracking_service.start_timer(
|
||||
user_id=g.api_user.id,
|
||||
project_id=project_id,
|
||||
task_id=data.get("task_id"),
|
||||
notes=data.get("notes"),
|
||||
template_id=data.get("template_id"),
|
||||
)
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not start timer")}), 400
|
||||
return jsonify({"message": "Timer started successfully", "timer": result["timer"].to_dict()}), 201
|
||||
|
||||
|
||||
@api_v1_time_entries_bp.route("/timer/stop", methods=["POST"])
|
||||
@require_api_token("write:time_entries")
|
||||
def stop_timer():
|
||||
"""Stop the active timer."""
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
active_timer = g.api_user.active_timer
|
||||
if not active_timer:
|
||||
return jsonify({"error": "No active timer to stop", "error_code": "no_active_timer"}), 400
|
||||
time_tracking_service = TimeTrackingService()
|
||||
result = time_tracking_service.stop_timer(user_id=g.api_user.id, entry_id=active_timer.id)
|
||||
if not result.get("success"):
|
||||
return jsonify({
|
||||
"error": result.get("message", "Could not stop timer"),
|
||||
"error_code": result.get("error", "stop_failed"),
|
||||
}), 400
|
||||
return jsonify({"message": "Timer stopped successfully", "time_entry": result["entry"].to_dict()})
|
||||
@@ -108,6 +108,54 @@ def dashboard():
|
||||
# Get recent activities for activity feed widget
|
||||
recent_activities = Activity.get_recent(user_id=None if current_user.is_admin else current_user.id, limit=10)
|
||||
|
||||
# Recent tags for Start Timer modal autocomplete (distinct from user's time entries)
|
||||
recent_tags = []
|
||||
tag_rows = (
|
||||
db.session.query(TimeEntry.tags)
|
||||
.filter(
|
||||
TimeEntry.user_id == current_user.id,
|
||||
TimeEntry.tags.isnot(None),
|
||||
TimeEntry.tags != "",
|
||||
)
|
||||
.order_by(TimeEntry.updated_at.desc())
|
||||
.limit(200)
|
||||
.all()
|
||||
)
|
||||
tags_seen = set()
|
||||
for (tags_str,) in tag_rows:
|
||||
if tags_str:
|
||||
for part in tags_str.split(","):
|
||||
t = part.strip()
|
||||
if t and t not in tags_seen:
|
||||
tags_seen.add(t)
|
||||
recent_tags.append(t)
|
||||
if len(recent_tags) >= 30:
|
||||
break
|
||||
if len(recent_tags) >= 30:
|
||||
break
|
||||
|
||||
# Last timer context: most recent completed time entry for "Repeat last" / quick start
|
||||
last_entry = (
|
||||
TimeEntry.query.filter(
|
||||
TimeEntry.user_id == current_user.id,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
)
|
||||
.order_by(TimeEntry.end_time.desc())
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
last_timer_context = None
|
||||
if last_entry and (last_entry.project_id or last_entry.client_id):
|
||||
last_timer_context = {
|
||||
"project_id": last_entry.project_id,
|
||||
"task_id": last_entry.task_id,
|
||||
"client_id": last_entry.client_id,
|
||||
"notes": (last_entry.notes or "").strip(),
|
||||
"tags": (last_entry.tags or "").strip(),
|
||||
"project_name": last_entry.project.name if last_entry.project else None,
|
||||
"client_name": last_entry.client.name if last_entry.client else None,
|
||||
}
|
||||
|
||||
# Get user stats for smart banner and donation widget
|
||||
try:
|
||||
from app.models import DonationInteraction
|
||||
@@ -147,6 +195,8 @@ def dashboard():
|
||||
"current_week_goal": current_week_goal,
|
||||
"templates": templates,
|
||||
"recent_activities": recent_activities,
|
||||
"last_timer_context": last_timer_context,
|
||||
"recent_tags": recent_tags,
|
||||
"user_stats": user_stats, # For smart banner
|
||||
"time_entries_count": time_entries_count, # For donation widget
|
||||
"total_hours": total_hours, # For donation widget
|
||||
|
||||
@@ -61,6 +61,23 @@ def reports():
|
||||
)
|
||||
|
||||
|
||||
@reports_bp.route("/reports/week-in-review")
|
||||
@login_required
|
||||
@module_enabled("reports")
|
||||
def week_in_review():
|
||||
"""Week in review: this week's hours, top projects, billable vs non-billable."""
|
||||
from app.services import ReportingService
|
||||
|
||||
reporting_service = ReportingService()
|
||||
data = reporting_service.get_week_in_review(
|
||||
user_id=current_user.id, is_admin=current_user.is_admin
|
||||
)
|
||||
if data.get("error"):
|
||||
flash(data["error"], "error")
|
||||
return redirect(url_for("reports.reports"))
|
||||
return render_template("reports/week_in_review.html", **data)
|
||||
|
||||
|
||||
@reports_bp.route("/reports/comparison")
|
||||
@login_required
|
||||
@module_enabled("reports")
|
||||
|
||||
@@ -296,3 +296,56 @@ class ReportingService:
|
||||
"days": (end_date - start_date).days,
|
||||
},
|
||||
}
|
||||
|
||||
def get_week_in_review(
|
||||
self, user_id: Optional[int] = None, is_admin: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get a summary of the current week: total hours, billable vs non-billable, top projects.
|
||||
Uses the user's week_start_day for "this week" boundaries.
|
||||
"""
|
||||
from app.utils.overtime import get_week_start_for_date
|
||||
from app.models import User
|
||||
|
||||
user = User.query.get(user_id) if user_id else None
|
||||
if not user and user_id:
|
||||
return {"error": "User not found"}
|
||||
today = date.today() if hasattr(date, "today") else datetime.utcnow().date()
|
||||
week_start = get_week_start_for_date(today, user or object())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
start_dt = datetime.combine(week_start, datetime.min.time())
|
||||
end_dt = datetime.combine(week_end, datetime.max.time().replace(microsecond=0))
|
||||
|
||||
time_summary = self.get_time_summary(
|
||||
user_id=user_id, start_date=start_dt, end_date=end_dt, billable_only=False
|
||||
)
|
||||
entries = self.time_entry_repo.get_by_date_range(
|
||||
start_date=start_dt, end_date=end_dt, user_id=user_id, include_relations=True
|
||||
)
|
||||
|
||||
project_hours = {}
|
||||
for entry in entries:
|
||||
key = (entry.project_id, entry.project.name if entry.project else "No project")
|
||||
if entry.project_id is None and entry.client_id:
|
||||
key = (None, entry.client.name if entry.client else "Direct (client)")
|
||||
elif entry.project_id is None:
|
||||
key = (None, "No project")
|
||||
name = key[1]
|
||||
if name not in project_hours:
|
||||
project_hours[name] = {"name": name, "hours": 0.0, "billable_hours": 0.0}
|
||||
h = (entry.duration_seconds or 0) / 3600
|
||||
project_hours[name]["hours"] += h
|
||||
if entry.billable:
|
||||
project_hours[name]["billable_hours"] += h
|
||||
|
||||
top_projects = sorted(project_hours.values(), key=lambda x: x["hours"], reverse=True)[:10]
|
||||
|
||||
return {
|
||||
"total_hours": time_summary["total_hours"],
|
||||
"billable_hours": time_summary["billable_hours"],
|
||||
"non_billable_hours": time_summary.get("non_billable_hours", time_summary["total_hours"] - time_summary["billable_hours"]),
|
||||
"entry_count": time_summary.get("total_entries", len(entries)),
|
||||
"top_projects": top_projects,
|
||||
"week_start": week_start.isoformat(),
|
||||
"week_end": week_end.isoformat(),
|
||||
}
|
||||
|
||||
@@ -1982,6 +1982,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='charts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='enhanced-ui.js') }}"></script>
|
||||
<!-- Onboarding: onboarding.js = core tour runner + restartTour(); onboarding-enhanced.js = enhanced steps/tooltips. Use restartTour() or window.onboardingManager to restart tour. -->
|
||||
<script src="{{ url_for('static', filename='onboarding.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='onboarding-enhanced.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='error-handling-enhanced.js') }}"></script>
|
||||
|
||||
@@ -202,6 +202,17 @@
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Generic loading placeholder for lists/tables: use kind="table" (default) or "spinner" #}
|
||||
{% macro loading_placeholder(kind="table", rows=5, cols=4, text=None, show_checkbox=False) %}
|
||||
{% if kind == "table" %}
|
||||
{{ skeleton_table(rows=rows, cols=cols, show_checkbox=show_checkbox) }}
|
||||
{% else %}
|
||||
<div class="py-12">
|
||||
{{ loading_spinner(size="lg", text=text or _('Loading...')) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro skeleton_table(rows=5, cols=4, show_checkbox=False) %}
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm overflow-hidden animate-pulse">
|
||||
<!-- Table Header -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, stat_card %}
|
||||
{% from "components/ui.html" import page_header, stat_card, empty_state %}
|
||||
{% from "components/client_select.html" import client_select %}
|
||||
|
||||
{% block content %}
|
||||
@@ -163,7 +163,14 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-8">{{ _('No issues found.') }}</p>
|
||||
{% set create_issue_action %}{% if current_user.is_admin or has_permission('create_issues') %}<a href="{{ url_for('issues.new_issue') }}" class="btn btn-primary"><i class="fas fa-plus mr-2"></i>{{ _('Create Issue') }}</a>{% endif %}{% endset %}
|
||||
{{ empty_state(
|
||||
'fas fa-bug',
|
||||
_('No issues found'),
|
||||
_('No issues match your filters. Create an issue or adjust your filters.'),
|
||||
create_issue_action,
|
||||
type='no-results'
|
||||
) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
+154
-127
@@ -99,9 +99,27 @@
|
||||
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Timer') }}</h2>
|
||||
</div>
|
||||
{% if not active_timer %}
|
||||
<button type="button" class="btn btn-primary js-open-start-timer">
|
||||
<i class="fas fa-play"></i>{{ _('Start Timer') }}
|
||||
</button>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{% if last_timer_context %}
|
||||
<button type="button" class="btn btn-secondary js-repeat-last-timer" title="{{ _('Start timer with same project, task and notes as last entry') }}">
|
||||
<i class="fas fa-redo"></i>{{ _('Repeat last') }}
|
||||
</button>
|
||||
<form method="POST" action="{{ url_for('timer.start_timer') }}" class="inline js-quick-start-last-form" data-confirm-message="{{ _('Start timer with last project and notes?') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="project_id" value="{{ last_timer_context.project_id or '' }}">
|
||||
<input type="hidden" name="client_id" value="{{ last_timer_context.client_id or '' }}">
|
||||
<input type="hidden" name="task_id" value="{{ last_timer_context.task_id or '' }}">
|
||||
<input type="hidden" name="notes" value="{{ last_timer_context.notes or '' }}">
|
||||
<input type="hidden" name="tags" value="{{ last_timer_context.tags or '' }}">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-bolt"></i>{{ _('Quick start') }}{% if last_timer_context.project_name %} ({{ last_timer_context.project_name }}){% elif last_timer_context.client_name %} ({{ last_timer_context.client_name }}){% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-primary js-open-start-timer">
|
||||
<i class="fas fa-play"></i>{{ _('Start Timer') }}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if active_timer %}
|
||||
@@ -165,14 +183,11 @@
|
||||
<div class="text-center py-4">
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('No active timer.') }}</p>
|
||||
{% if recent_entries %}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Resume your last session or start a new timer.') }}</p>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Resume your last session or use the buttons above to repeat last / start a new timer.') }}</p>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<a href="{{ url_for('timer.resume_timer', timer_id=recent_entries[0].id) }}" class="inline-flex items-center bg-green-500 hover:bg-green-600 text-white px-5 py-2.5 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5">
|
||||
<i class="fas fa-play mr-2"></i>{{ _('Resume') }}{% if recent_entries[0].project %} ({{ recent_entries[0].project.name }}){% elif recent_entries[0].client %} ({{ recent_entries[0].client.name }}){% endif %}
|
||||
</a>
|
||||
<button type="button" class="js-open-start-timer bg-primary hover:bg-primary-dark text-white px-5 py-2.5 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Start new') }}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Click "Start Timer" above to begin tracking your time.') }}</p>
|
||||
@@ -410,113 +425,92 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Timeline Widget -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 dashboard-widget">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-indigo-500/10 dark:bg-indigo-400/10 p-3 rounded-lg">
|
||||
<i class="fas fa-stream text-indigo-600 dark:text-indigo-400 text-xl"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Recent Activity') }}</h2>
|
||||
<!-- Secondary row: Activity and Support (below main focus) -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
|
||||
<!-- Activity Timeline Widget - reduced visual weight -->
|
||||
<div class="lg:col-span-2 bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-border-dark shadow-sm dashboard-widget">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-indigo-500/10 dark:bg-indigo-400/10 p-2 rounded-lg">
|
||||
<i class="fas fa-stream text-indigo-600 dark:text-indigo-400"></i>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 animate-pulse">
|
||||
<span class="w-2 h-2 bg-green-600 dark:bg-green-400 rounded-full mr-2"></span>
|
||||
{{ _('Live') }}
|
||||
</span>
|
||||
<h2 class="text-base font-semibold text-text-light dark:text-text-dark">{{ _('Recent Activity') }}</h2>
|
||||
</div>
|
||||
<div id="activityTimeline" class="activity-timeline">
|
||||
{% if recent_activities %}
|
||||
{% for activity in recent_activities %}
|
||||
<div class="activity-timeline-item group">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 relative">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center shadow-md group-hover:shadow-lg transition-shadow">
|
||||
<i class="fas fa-circle text-xs text-white"></i>
|
||||
</div>
|
||||
{% if not loop.last %}
|
||||
<div class="absolute left-1/2 top-10 w-0.5 h-6 bg-gradient-to-b from-indigo-300 to-transparent dark:from-indigo-700 dark:to-transparent transform -translate-x-1/2"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-1 pb-6 group-last:pb-0">
|
||||
<p class="text-sm font-medium text-text-light dark:text-text-dark mb-1 group-hover:text-primary dark:group-hover:text-primary transition-colors">{{ activity.description or 'Activity' }}</p>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark flex items-center gap-1">
|
||||
<i class="fas fa-clock"></i>
|
||||
{{ activity.created_at|local_datetime_short }}
|
||||
</p>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
<span class="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full mr-1.5"></span>
|
||||
{{ _('Live') }}
|
||||
</span>
|
||||
</div>
|
||||
<div id="activityTimeline" class="activity-timeline">
|
||||
{% if recent_activities %}
|
||||
{% for activity in recent_activities %}
|
||||
<div class="activity-timeline-item group">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 relative">
|
||||
<div class="w-8 h-8 rounded-full bg-indigo-500/20 dark:bg-indigo-400/20 flex items-center justify-center">
|
||||
<i class="fas fa-circle text-xs text-indigo-500 dark:text-indigo-400"></i>
|
||||
</div>
|
||||
{% if not loop.last %}
|
||||
<div class="absolute left-1/2 top-8 w-0.5 h-4 bg-border-light dark:bg-border-dark transform -translate-x-1/2"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-1 pb-4 group-last:pb-0">
|
||||
<p class="text-sm font-medium text-text-light dark:text-text-dark mb-0.5">{{ activity.description or 'Activity' }}</p>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
<i class="fas fa-clock mr-1"></i>{{ activity.created_at|local_datetime_short }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-6 w-20 h-20 mx-auto mb-4 flex items-center justify-center">
|
||||
<i class="fas fa-inbox text-3xl text-gray-400"></i>
|
||||
</div>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark font-medium mb-1">{{ _('No recent activity') }}</p>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Activity will appear here as you use the system.') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center py-8">
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('No recent activity') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
|
||||
<!-- Support TimeTracker Widget -->
|
||||
<div class="bg-gradient-to-br from-amber-500 via-orange-500 to-amber-600 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 dashboard-widget animated-card text-white">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="bg-white/20 backdrop-blur-sm p-3 rounded-lg">
|
||||
<i class="fas fa-mug-saucer text-xl"></i>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold">{{ _('Support TimeTracker') }}</h2>
|
||||
</div>
|
||||
{% if (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
|
||||
<!-- Support TimeTracker Widget - compact, below fold -->
|
||||
<div class="bg-card-light dark:bg-card-dark border border-amber-200 dark:border-amber-800 p-5 rounded-xl shadow-sm dashboard-widget">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="bg-amber-500/10 dark:bg-amber-400/10 p-2 rounded-lg">
|
||||
<i class="fas fa-mug-saucer text-amber-600 dark:text-amber-400"></i>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<p class="text-sm opacity-90 mb-4">
|
||||
{{ _('Support updates and new features. One-time key removes prompts for this instance.') }}
|
||||
</p>
|
||||
{% if time_entries_count > 0 or total_hours > 0 %}
|
||||
<div class="bg-white/10 backdrop-blur-sm rounded-lg p-3 border border-white/10 mb-4">
|
||||
<div class="text-xs opacity-90 space-y-2">
|
||||
{% if time_entries_count > 0 %}
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<span>{{ _('You\'ve tracked %(count)s time entries', count=time_entries_count) }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if total_hours > 0 %}
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<span>{{ _('You\'ve logged %(hours)s hours', hours="%.1f"|format(total_hours)) }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('main.donate') }}"
|
||||
class="inline-flex items-center justify-center flex-1 bg-white text-amber-600 px-4 py-3 rounded-lg font-semibold hover:bg-amber-50 transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5">
|
||||
<i class="fas fa-heart mr-2"></i>
|
||||
{{ _('Support / Get key') }}
|
||||
</a>
|
||||
<a href="https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=dashboard&utm_campaign=support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick="trackDonationClick('dashboard_widget')"
|
||||
class="inline-flex items-center justify-center flex-1 bg-amber-700 hover:bg-amber-800 text-white px-4 py-3 rounded-lg font-semibold transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5">
|
||||
<i class="fas fa-mug-saucer mr-2"></i>
|
||||
{{ _('Donate') }}
|
||||
<i class="fas fa-external-link-alt ml-2 text-xs"></i>
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-3 text-xs opacity-90">
|
||||
{{ _('Remove prompts with a one-time key.') }}
|
||||
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" onclick="trackDonationClick('dashboard_widget_key')" class="underline hover:no-underline font-medium">{{ _('Get key') }}</a>
|
||||
</p>
|
||||
<h2 class="text-base font-semibold text-text-light dark:text-text-dark">{{ _('Support TimeTracker') }}</h2>
|
||||
</div>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">
|
||||
{{ _('Support updates and new features. One-time key removes prompts for this instance.') }}
|
||||
</p>
|
||||
{% if time_entries_count > 0 or total_hours > 0 %}
|
||||
<div class="bg-background-light dark:bg-background-dark rounded-lg p-2 mb-3 text-xs text-text-muted-light dark:text-text-muted-dark space-y-1">
|
||||
{% if time_entries_count > 0 %}
|
||||
<div><i class="fas fa-check-circle text-green-500 mr-1"></i>{{ _('You\'ve tracked %(count)s time entries', count=time_entries_count) }}</div>
|
||||
{% endif %}
|
||||
{% if total_hours > 0 %}
|
||||
<div><i class="fas fa-check-circle text-green-500 mr-1"></i>{{ _('You\'ve logged %(hours)s hours', hours="%.1f"|format(total_hours)) }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<a href="{{ url_for('main.donate') }}" class="inline-flex items-center justify-center flex-1 bg-amber-500 hover:bg-amber-600 text-white px-3 py-2 rounded-lg font-medium text-sm transition-colors">
|
||||
<i class="fas fa-heart mr-1.5"></i>{{ _('Support / Get key') }}
|
||||
</a>
|
||||
<a href="https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=dashboard&utm_campaign=support" target="_blank" rel="noopener noreferrer" onclick="trackDonationClick('dashboard_widget')" class="inline-flex items-center justify-center flex-1 border border-amber-500 text-amber-600 dark:text-amber-400 px-3 py-2 rounded-lg font-medium text-sm hover:bg-amber-500/10 transition-colors">
|
||||
<i class="fas fa-mug-saucer mr-1.5"></i>{{ _('Donate') }} <i class="fas fa-external-link-alt ml-1 text-xs"></i>
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ _('Remove prompts with a one-time key.') }}
|
||||
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" onclick="trackDonationClick('dashboard_widget_key')" class="underline hover:no-underline font-medium text-amber-600 dark:text-amber-400">{{ _('Get key') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Delete Entry Confirmation Dialogs -->
|
||||
{% for entry in recent_entries %}
|
||||
{% if current_user.is_admin or entry.user_id == current_user.id %}
|
||||
@@ -535,6 +529,7 @@
|
||||
<div id="startTimerModal"
|
||||
class="fixed inset-0 z-50 hidden overflow-y-auto"
|
||||
aria-hidden="true"
|
||||
data-last-timer-context="{{ (last_timer_context|tojson)|e if last_timer_context else '{}' }}"
|
||||
data-tasks-api-url="{{ url_for('api.get_project_tasks', project_id=0) }}"
|
||||
data-create-task-url="{{ url_for('api.create_task_inline') }}"
|
||||
data-no-task="{{ _('No task')|e }}"
|
||||
@@ -566,6 +561,24 @@
|
||||
<form method="POST" action="{{ url_for('timer.start_timer') }}" id="startTimerForm" class="p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="space-y-4">
|
||||
{% if templates %}
|
||||
<div class="pb-3 border-b border-border-light dark:border-border-dark">
|
||||
<label class="form-label">{{ _('Quick start with a template') }}</label>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{% for template in templates %}
|
||||
<button type="button"
|
||||
onclick="applyTemplate({{ template.id }})"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-primary/30 bg-primary/5 dark:bg-primary/10 hover:bg-primary/15 dark:hover:bg-primary/20 text-primary font-medium text-sm transition">
|
||||
<i class="fas fa-bolt text-xs"></i>
|
||||
{{ template.name }}{% if template.project %} · {{ template.project.name }}{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
<a href="{{ url_for('time_entry_templates.list_templates') }}" class="inline-flex items-center gap-1 px-3 py-2 text-sm text-text-muted-light dark:text-text-muted-dark hover:text-primary transition">
|
||||
{{ _('All templates') }} <i class="fas fa-external-link-alt text-xs"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="startTimerProject" class="form-label">{{ _('Project') }}</label>
|
||||
@@ -601,35 +614,17 @@
|
||||
<div id="startTimerNotes_editor"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% if templates %}
|
||||
<div>
|
||||
<label class="form-label">{{ _('Or use a template') }}</label>
|
||||
<div class="space-y-2">
|
||||
{% for template in templates %}
|
||||
<button type="button"
|
||||
onclick="applyTemplate({{ template.id }})"
|
||||
class="w-full text-left p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{{ template.name }}</div>
|
||||
{% if template.project %}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<i class="fas fa-folder"></i> {{ template.project.name }}
|
||||
{% if template.task %} → {{ template.task.name }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||
</div>
|
||||
</button>
|
||||
<label for="startTimerTags" class="form-label">{{ _('Tags (optional)') }}</label>
|
||||
<input type="text" id="startTimerTags" name="tags" class="form-input w-full" placeholder="{{ _('e.g. meeting, dev, admin') }}"
|
||||
list="startTimerTagsList" autocomplete="off">
|
||||
<datalist id="startTimerTagsList">
|
||||
{% for tag in recent_tags %}
|
||||
<option value="{{ tag|e }}"></option>
|
||||
{% endfor %}
|
||||
<a href="{{ url_for('time_entry_templates.list_templates') }}"
|
||||
class="block text-center text-sm text-blue-600 dark:text-blue-400 hover:underline pt-2">
|
||||
{{ _('View all templates') }} →
|
||||
</a>
|
||||
</div>
|
||||
</datalist>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Comma-separated; recent tags shown as suggestions.') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -952,6 +947,38 @@
|
||||
const onlyOneClient = (modal?.dataset?.onlyOneClient || 'false') === 'true';
|
||||
const singleClientId = (modal?.dataset?.singleClientId || '') || null;
|
||||
|
||||
// Repeat last: pre-fill modal from last_timer_context and open
|
||||
document.querySelectorAll('.js-repeat-last-timer').forEach(function(btn) {
|
||||
btn.addEventListener('click', async function(e) {
|
||||
e.preventDefault();
|
||||
if (!modal) return;
|
||||
var ctxRaw = modal.getAttribute('data-last-timer-context');
|
||||
var ctx = {};
|
||||
try { ctx = ctxRaw ? JSON.parse(ctxRaw) : {}; } catch (err) { ctx = {}; }
|
||||
if (!ctx.project_id && !ctx.client_id) {
|
||||
openStartTimerModal(e);
|
||||
return;
|
||||
}
|
||||
if (projectSelect) projectSelect.value = ctx.project_id || '';
|
||||
if (clientSelect) clientSelect.value = ctx.client_id || '';
|
||||
if (ctx.project_id && typeof loadTasksForProject === 'function') {
|
||||
await loadTasksForProject(ctx.project_id, ctx.task_id || null);
|
||||
} else if (taskSelect) {
|
||||
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
|
||||
taskSelect.disabled = false;
|
||||
}
|
||||
var notesEl = document.getElementById('startTimerNotes');
|
||||
if (notesEl) notesEl.value = ctx.notes || '';
|
||||
if (window.dashboardNotesEditor && typeof window.dashboardNotesEditor.setMarkdown === 'function') {
|
||||
try { window.dashboardNotesEditor.setMarkdown(ctx.notes || ''); } catch (err) {}
|
||||
}
|
||||
var tagsEl = document.getElementById('startTimerTags');
|
||||
if (tagsEl && ctx.tags) tagsEl.value = ctx.tags;
|
||||
modal.classList.remove('hidden');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
// Task loading: attach when project and task elements exist (independent of client)
|
||||
if (projectSelect && taskSelect) {
|
||||
projectSelect.addEventListener('change', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
{% from "components/ui.html" import page_header, empty_state %}
|
||||
|
||||
{% block title %}{{ saved_view.name }} - {{ _('Custom Report') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
@@ -46,18 +46,14 @@
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 mb-4">
|
||||
<i class="fas fa-inbox text-3xl text-gray-400"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">{{ _('No data found') }}</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
{{ _('This report has no data matching the current filters. Try adjusting your date range or filters.') }}
|
||||
</p>
|
||||
<a href="{{ url_for('custom_reports.report_builder') }}" class="btn btn-primary">
|
||||
<i class="fas fa-edit mr-2"></i>{{ _('Edit Report') }}
|
||||
</a>
|
||||
</div>
|
||||
{% set edit_report_action %}<a href="{{ url_for('custom_reports.report_builder') }}" class="btn btn-primary"><i class="fas fa-edit mr-2"></i>{{ _('Edit Report') }}</a>{% endset %}
|
||||
{{ empty_state(
|
||||
'fas fa-inbox',
|
||||
_('No data found'),
|
||||
_('This report has no data matching the current filters. Try adjusting your date range or filters.'),
|
||||
edit_report_action,
|
||||
type='no-results'
|
||||
) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif component == 'summary' %}
|
||||
|
||||
@@ -94,6 +94,15 @@
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Comparison View') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<a href="{{ url_for('reports.week_in_review') }}" class="block w-full px-4 py-3 rounded-lg text-left bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors no-underline">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium text-text-light dark:text-text-dark">{{ _('Week in Review') }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('This week\'s hours, top projects, billable vs non-billable') }}</div>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-text-muted-light dark:text-text-muted-dark"></i>
|
||||
</div>
|
||||
</a>
|
||||
<button type="button" onclick="showComparisonView('month')" class="w-full px-4 py-3 rounded-lg text-left bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': _('Reports'), 'url': url_for('reports.reports')},
|
||||
{'text': _('Week in Review')}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-calendar-week',
|
||||
title_text=_('Week in Review'),
|
||||
subtitle_text=_('This week\'s time summary and top projects'),
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=None
|
||||
) }}
|
||||
<p class="mb-4"><a href="{{ url_for('reports.reports') }}" class="btn btn-secondary">{{ _('Back to Reports') }}</a></p>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl border border-border-light dark:border-border-dark shadow-sm mb-6">
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
|
||||
{{ _('Week') }} {{ week_start }} – {{ week_end }}
|
||||
</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
<div class="p-4 rounded-lg bg-primary/10 dark:bg-primary/20">
|
||||
<div class="text-2xl font-bold text-primary">{{ "%.1f"|format(total_hours) }}h</div>
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total hours') }}</div>
|
||||
</div>
|
||||
<div class="p-4 rounded-lg bg-green-500/10 dark:bg-green-500/20">
|
||||
<div class="text-2xl font-bold text-green-600 dark:text-green-400">{{ "%.1f"|format(billable_hours) }}h</div>
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Billable') }}</div>
|
||||
</div>
|
||||
<div class="p-4 rounded-lg bg-gray-500/10 dark:bg-gray-500/20">
|
||||
<div class="text-2xl font-bold text-gray-600 dark:text-gray-400">{{ "%.1f"|format(non_billable_hours) }}h</div>
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Non-billable') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ _('%(count)s time entries logged this week.', count=entry_count) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
|
||||
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark mb-4">{{ _('Top projects this week') }}</h2>
|
||||
{% if top_projects %}
|
||||
<ul class="space-y-3">
|
||||
{% for item in top_projects %}
|
||||
<li class="flex items-center justify-between py-2 border-b border-border-light dark:border-border-dark last:border-0">
|
||||
<span class="font-medium text-text-light dark:text-text-dark">{{ item.name }}</span>
|
||||
<span class="text-primary font-semibold">{{ "%.1f"|format(item.hours) }}h</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No time logged this week yet.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge, empty_state %}
|
||||
|
||||
{% block title %}Saved Filters - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
@@ -90,20 +90,14 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 mb-4">
|
||||
<i class="fas fa-filter text-3xl text-gray-400"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No saved filters yet</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Save filters from Reports or Tasks pages for quick access
|
||||
</p>
|
||||
<a href="{{ url_for('reports.reports') }}"
|
||||
class="btn btn-primary">
|
||||
<i class="fas fa-chart-bar mr-2"></i> Go to Reports
|
||||
</a>
|
||||
</div>
|
||||
{% set go_reports_action %}<a href="{{ url_for('reports.reports') }}" class="btn btn-primary"><i class="fas fa-chart-bar mr-2"></i>{{ _('Go to Reports') }}</a>{% endset %}
|
||||
{{ empty_state(
|
||||
'fas fa-filter',
|
||||
_('No saved filters yet'),
|
||||
_('Save filters from Reports or Tasks pages for quick access.'),
|
||||
go_reports_action,
|
||||
type='no-data'
|
||||
) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge, empty_state %}
|
||||
|
||||
{% block title %}Time Entry Templates - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
@@ -108,20 +108,14 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 mb-4">
|
||||
<i class="fas fa-file-alt text-3xl text-gray-400"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No templates yet</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Create your first time entry template to speed up your workflow
|
||||
</p>
|
||||
<a href="{{ url_for('time_entry_templates.create_template') }}"
|
||||
class="btn btn-primary">
|
||||
<i class="fas fa-plus mr-2"></i> Create Your First Template
|
||||
</a>
|
||||
</div>
|
||||
{% set create_action %}<a href="{{ url_for('time_entry_templates.create_template') }}" class="btn btn-primary"><i class="fas fa-plus mr-2"></i>{{ _('Create Your First Template') }}</a>{% endset %}
|
||||
{{ empty_state(
|
||||
'fas fa-file-alt',
|
||||
_('No templates yet'),
|
||||
_('Create your first time entry template to speed up your workflow.'),
|
||||
create_action,
|
||||
type='no-data'
|
||||
) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user