feat: Enhance TimeEntry audit logging with comprehensive tracking

Add comprehensive audit logging for TimeEntry operations including:
- Client/project context and creation timestamps
- Full entity state before/after changes
- User-provided reasons for deletions and modifications
- Enhanced UI for entering reasons in delete/edit dialogs

Database Changes:
- Add migration 114: reason, entity_metadata, full_old_state, full_new_state columns
- Use JSON column type for entity_metadata for better type handling

Model Updates:
- Extend AuditLog model with new fields and helper methods
- Update log_change() to accept reason, metadata, and full states
- Add get_entity_metadata(), get_full_old_state(), get_full_new_state() methods
- Use JSON column for entity_metadata (returns dict/list directly)

Service Layer:
- Update TimeTrackingService to capture full TimeEntry state and metadata
- Accept reason parameter in delete_entry() and update_entry()
- Create comprehensive audit logs with all context

API Routes:
- Update api.py, api_v1.py, and timer.py routes to accept reason parameter
- Refactor routes to use service layer for consistent audit logging
- Add reason support to bulk delete operations

UI Enhancements:
- Add reason textarea to bulk delete confirmation dialog
- Add reason textarea to time entry edit forms (admin and regular users)
- Update JavaScript to handle reason submission

Audit Log Display:
- Show client/project information and creation timestamp in list view
- Display full old/new states, reason, and metadata in detail view
- Format JSON states for better readability

Bug Fixes:
- Fix duration_seconds reference in timer stop route
- Improve error handling in timer operations with proper exception handling
- Add dashboard cache invalidation after manual entry creation
This commit is contained in:
Dries Peeters
2026-01-22 13:35:08 +01:00
parent 71350adf7a
commit 7dcd58608a
12 changed files with 670 additions and 193 deletions
+49
View File
@@ -45,6 +45,18 @@ class AuditLog(db.Model):
# Human-readable change description
change_description = db.Column(db.Text, nullable=True)
# User-provided reason for change/deletion
reason = db.Column(db.Text, nullable=True)
# Entity metadata (JSON-encoded for client_id, project_id, created_at, etc.)
entity_metadata = db.Column(db.JSON, nullable=True) # JSON metadata (dict/list)
# Full entity state before change (JSON-encoded)
full_old_state = db.Column(db.Text, nullable=True)
# Full entity state after change (JSON-encoded)
full_new_state = db.Column(db.Text, nullable=True)
# Additional context
ip_address = db.Column(db.String(45), nullable=True)
user_agent = db.Column(db.Text, nullable=True)
@@ -79,6 +91,10 @@ class AuditLog(db.Model):
new_value=None,
entity_name=None,
change_description=None,
reason=None,
entity_metadata=None,
full_old_state=None,
full_new_state=None,
ip_address=None,
user_agent=None,
request_path=None,
@@ -95,6 +111,10 @@ class AuditLog(db.Model):
new_value: New value (will be JSON-encoded)
entity_name: Cached name of the entity for display
change_description: Human-readable description of the change
reason: User-provided reason for the change/deletion
entity_metadata: Metadata dict/list (client_id, project_id, created_at, etc.) - will be stored as JSON
full_old_state: JSON-encoded full entity state before change
full_new_state: JSON-encoded full entity state after change
ip_address: IP address of the request
user_agent: User agent string
request_path: Path of the request that triggered the change
@@ -102,6 +122,11 @@ class AuditLog(db.Model):
# Encode values as JSON if they're not already strings
old_val_str = cls._encode_value(old_value)
new_val_str = cls._encode_value(new_value)
# entity_metadata is stored as JSON type, so pass dict/list directly (not encoded)
# full_old_state and full_new_state are Text columns, so encode as JSON strings
full_old_str = cls._encode_value(full_old_state) if full_old_state else None
full_new_str = cls._encode_value(full_new_state) if full_new_state else None
audit_log = cls(
user_id=user_id,
@@ -113,6 +138,10 @@ class AuditLog(db.Model):
new_value=new_val_str,
entity_name=entity_name,
change_description=change_description,
reason=reason,
entity_metadata=entity_metadata, # Pass dict/list directly for JSON column
full_old_state=full_old_str,
full_new_state=full_new_str,
ip_address=ip_address,
user_agent=user_agent,
request_path=request_path,
@@ -178,6 +207,22 @@ class AuditLog(db.Model):
"""Get the decoded new value"""
return self._decode_value(self.new_value)
def get_entity_metadata(self):
"""Get the entity metadata (already a dict/list for JSON column)"""
# For JSON columns, SQLAlchemy returns the dict/list directly
# For backward compatibility with Text columns, try to decode if it's a string
if isinstance(self.entity_metadata, str):
return self._decode_value(self.entity_metadata)
return self.entity_metadata
def get_full_old_state(self):
"""Get the decoded full old state"""
return self._decode_value(self.full_old_state)
def get_full_new_state(self):
"""Get the decoded full new state"""
return self._decode_value(self.full_new_state)
@classmethod
def get_for_entity(cls, entity_type, entity_id, limit=100):
"""Get audit logs for a specific entity"""
@@ -224,6 +269,10 @@ class AuditLog(db.Model):
"old_value": self.get_old_value(),
"new_value": self.get_new_value(),
"change_description": self.change_description,
"reason": self.reason,
"entity_metadata": self.get_entity_metadata(),
"full_old_state": self.get_full_old_state(),
"full_new_state": self.get_full_new_state(),
"ip_address": self.ip_address,
"user_agent": self.user_agent,
"request_path": self.request_path,
+6 -2
View File
@@ -136,11 +136,15 @@ class TimeEntry(db.Model):
@property
def duration_formatted(self):
"""Get duration formatted as HH:MM:SS"""
if not self.duration_seconds:
# For active timers (end_time is None), use current_duration_seconds
if not self.end_time:
total_seconds = self.current_duration_seconds
elif not self.duration_seconds:
return "00:00:00"
else:
total_seconds = int(self.duration_seconds)
# Convert to int to ensure integer values for formatting
total_seconds = int(self.duration_seconds)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
+47 -108
View File
@@ -1418,15 +1418,7 @@ def update_entry(entry_id):
return jsonify({"error": "Access denied"}), 403
data = request.get_json() or {}
# Optional: project change (admin only)
new_project_id = data.get("project_id")
if new_project_id is not None and current_user.is_admin:
if new_project_id != entry.project_id:
project = Project.query.filter_by(id=new_project_id, status="active").first()
if not project:
return jsonify({"error": "Invalid project"}), 400
entry.project_id = new_project_id
reason = data.get("reason") # Optional reason for the change
# Optional: start/end time updates (admin only for safety)
# Accept HTML datetime-local format: YYYY-MM-DDTHH:MM
@@ -1445,79 +1437,43 @@ def update_entry(entry_id):
except Exception:
return None
if current_user.is_admin:
start_time_str = data.get("start_time")
end_time_str = data.get("end_time")
if start_time_str:
parsed_start = parse_dt_local(start_time_str)
if not parsed_start:
return jsonify({"error": "Invalid start time format"}), 400
entry.start_time = parsed_start
if end_time_str is not None:
if end_time_str == "" or end_time_str is False:
entry.end_time = None
entry.duration_seconds = None
else:
parsed_end = parse_dt_local(end_time_str)
if not parsed_end:
return jsonify({"error": "Invalid end time format"}), 400
if parsed_end <= (entry.start_time or parsed_end):
return jsonify({"error": "End time must be after start time"}), 400
entry.end_time = parsed_end
# Recalculate duration
entry.calculate_duration()
# Prevent multiple active timers for the same user when editing
if entry.end_time is None:
conflict = (
TimeEntry.query.filter(TimeEntry.user_id == entry.user_id)
.filter(TimeEntry.end_time.is_(None))
.filter(TimeEntry.id != entry.id)
.first()
)
if conflict:
return jsonify({"error": "User already has an active timer"}), 400
# Notes, tags, billable (both admin and owner can change)
if "notes" in data:
entry.notes = data["notes"].strip() if data["notes"] else None
if "tags" in data:
entry.tags = data["tags"].strip() if data["tags"] else None
if "billable" in data:
entry.billable = bool(data["billable"])
if "paid" in data:
entry.paid = bool(data["paid"])
# Clear invoice number if marking as unpaid
if not entry.paid:
entry.invoice_number = None
if "invoice_number" in data:
invoice_number = data["invoice_number"]
entry.invoice_number = invoice_number.strip() if invoice_number else None
# Prefer local time for updated_at per project preference
entry.updated_at = local_now()
if not safe_commit("api_update_entry", {"entry_id": entry_id}):
return jsonify({"error": "Database error while updating entry"}), 500
# Use service layer for update to get enhanced audit logging
from app.services import TimeTrackingService
service = TimeTrackingService()
# Convert data to service parameters
result = service.update_entry(
entry_id=entry_id,
user_id=current_user.id,
is_admin=current_user.is_admin,
project_id=data.get("project_id") if current_user.is_admin else None,
client_id=data.get("client_id") if current_user.is_admin else None,
task_id=data.get("task_id"),
start_time=parse_dt_local(data.get("start_time")) if current_user.is_admin and data.get("start_time") else None,
end_time=parse_dt_local(data.get("end_time")) if current_user.is_admin and data.get("end_time") else None,
notes=data.get("notes"),
tags=data.get("tags"),
billable=data.get("billable"),
paid=data.get("paid"),
invoice_number=data.get("invoice_number"),
reason=reason,
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not update entry")}), 400
# Invalidate dashboard cache for the entry owner so changes appear immediately
try:
from app.utils.cache import get_cache
cache = get_cache()
cache_key = f"dashboard:{entry.user_id}"
cache_key = f"dashboard:{result['entry'].user_id}"
cache.delete(cache_key)
current_app.logger.debug("Invalidated dashboard cache for user %s after entry update", entry.user_id)
current_app.logger.debug("Invalidated dashboard cache for user %s after entry update", result['entry'].user_id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
payload = entry.to_dict()
payload["project_name"] = entry.project.name if entry.project else None
payload = result["entry"].to_dict()
payload["project_name"] = result["entry"].project.name if result["entry"].project else None
return jsonify({"success": True, "entry": payload})
@@ -1525,50 +1481,33 @@ def update_entry(entry_id):
@login_required
def delete_entry(entry_id):
"""Delete a time entry"""
entry = TimeEntry.query.get_or_404(entry_id)
# Check permissions
if entry.user_id != current_user.id and not current_user.is_admin:
return jsonify({"error": "Access denied"}), 403
# Don't allow deletion of active timers
if entry.is_active:
return jsonify({"error": "Cannot delete active timer"}), 400
# Capture entry info for logging before deletion
project_name = entry.project.name if entry.project else None
client_name = entry.client.name if entry.client else None
entity_name = project_name or client_name or "Unknown"
duration_formatted = entry.duration_formatted
entry_user_id = entry.user_id # Capture user_id before deletion
db.session.delete(entry)
db.session.commit()
data = request.get_json() or {}
reason = data.get("reason") # Optional reason for deletion
# Use service layer for deletion to get enhanced audit logging
from app.services import TimeTrackingService
service = TimeTrackingService()
result = service.delete_entry(
user_id=current_user.id,
entry_id=entry_id,
is_admin=current_user.is_admin,
reason=reason,
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not delete entry")}), 400
# Invalidate dashboard cache for the entry owner so changes appear immediately
try:
from app.utils.cache import get_cache
cache = get_cache()
cache_key = f"dashboard:{entry_user_id}"
cache_key = f"dashboard:{current_user.id}"
cache.delete(cache_key)
current_app.logger.debug("Invalidated dashboard cache for user %s after entry deletion", entry_user_id)
current_app.logger.debug("Invalidated dashboard cache for user %s after entry deletion", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
# Log activity
from app.models import Activity
Activity.log(
user_id=current_user.id,
action="deleted",
entity_type="time_entry",
entity_id=entry_id,
entity_name=entity_name,
description=f'Deleted time entry for {entity_name} - {duration_formatted}',
extra_data={"project_name": project_name, "client_name": client_name, "duration_formatted": duration_formatted},
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
return jsonify({"success": True})
+11 -1
View File
@@ -716,6 +716,7 @@ def update_time_entry(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,
@@ -724,6 +725,7 @@ def update_time_entry(entry_id):
billable=data.get("billable"),
paid=data.get("paid"),
invoice_number=data.get("invoice_number"),
reason=data.get("reason"),
)
if not result.get("success"):
@@ -754,8 +756,16 @@ def delete_time_entry(entry_id):
"""
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)
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
+103 -67
View File
@@ -418,7 +418,7 @@ def stop_timer():
current_app.logger.info("Stopped timer id=%s for user=%s", active_timer.id, current_user.username)
# Track timer stopped event
duration_seconds = active_timer.duration if hasattr(active_timer, "duration") else 0
duration_seconds = active_timer.duration_seconds if active_timer.duration_seconds else 0
log_event(
"timer.stopped",
user_id=current_user.id,
@@ -467,30 +467,37 @@ def stop_timer():
current_user.id,
{"source": "timer", "duration_seconds": duration_seconds, "has_task": bool(active_timer.task_id)},
)
# Emit WebSocket event for real-time updates
try:
socketio.emit(
"timer_stopped",
{"user_id": current_user.id, "timer_id": active_timer.id, "duration": active_timer.duration_formatted},
)
except Exception as e:
current_app.logger.warning("Socket emit failed for timer_stopped: %s", e)
# Invalidate dashboard cache so timer disappears immediately
try:
from app.utils.cache import get_cache
cache = get_cache()
cache_key = f"dashboard:{current_user.id}"
cache.delete(cache_key)
current_app.logger.debug("Invalidated dashboard cache for user %s", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
flash(f"Timer stopped. Duration: {active_timer.duration_formatted}", "success")
return redirect(url_for("main.dashboard"))
except ValueError as e:
# Timer already stopped or invalid state
current_app.logger.warning("Cannot stop timer: %s", e)
flash(_("Cannot stop timer: %(error)s", error=str(e)), "error")
return redirect(url_for("main.dashboard"))
except Exception as e:
current_app.logger.exception("Error stopping timer: %s", e)
# Emit WebSocket event for real-time updates
try:
socketio.emit(
"timer_stopped",
{"user_id": current_user.id, "timer_id": active_timer.id, "duration": active_timer.duration_formatted},
)
except Exception as e:
current_app.logger.warning("Socket emit failed for timer_stopped: %s", e)
# Invalidate dashboard cache so timer disappears immediately
try:
from app.utils.cache import get_cache
cache = get_cache()
cache_key = f"dashboard:{current_user.id}"
cache.delete(cache_key)
current_app.logger.debug("Invalidated dashboard cache for user %s", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
flash(f"Timer stopped. Duration: {active_timer.duration_formatted}", "success")
return redirect(url_for("main.dashboard"))
flash(_("Could not stop timer due to an error. Please try again or contact support if the problem persists."), "error")
return redirect(url_for("main.dashboard"))
@timer_bp.route("/timer/status")
@@ -529,18 +536,31 @@ def edit_timer(timer_id):
return redirect(url_for("main.dashboard"))
if request.method == "POST":
# Update timer details
timer.notes = request.form.get("notes", "").strip()
timer.tags = request.form.get("tags", "").strip()
timer.billable = request.form.get("billable") == "on"
timer.paid = request.form.get("paid") == "on"
# Get reason for change
reason = request.form.get("reason", "").strip() or None
# Use service layer for update to get enhanced audit logging
from app.services import TimeTrackingService
service = TimeTrackingService()
# Prepare update parameters
update_params = {
"entry_id": timer_id,
"user_id": current_user.id,
"is_admin": current_user.is_admin,
"notes": request.form.get("notes", "").strip() or None,
"tags": request.form.get("tags", "").strip() or None,
"billable": request.form.get("billable") == "on",
"paid": request.form.get("paid") == "on",
"reason": reason,
}
# Update invoice number
invoice_number = request.form.get("invoice_number", "").strip()
timer.invoice_number = invoice_number if invoice_number else None
update_params["invoice_number"] = invoice_number if invoice_number else None
# Clear invoice number if marking as unpaid
if not timer.paid:
timer.invoice_number = None
if update_params["paid"] is False:
update_params["invoice_number"] = None
# Admin users can edit additional fields
if current_user.is_admin:
@@ -549,7 +569,7 @@ def edit_timer(timer_id):
if new_project_id and new_project_id != timer.project_id:
new_project = Project.query.filter_by(id=new_project_id, status="active").first()
if new_project:
timer.project_id = new_project_id
update_params["project_id"] = new_project_id
else:
flash(_("Invalid project selected"), "error")
return render_template(
@@ -562,14 +582,16 @@ def edit_timer(timer_id):
else Task.query.filter_by(project_id=new_project_id).order_by(Task.name).all()
),
)
else:
update_params["project_id"] = None # Don't change if not provided
# Update task if changed
new_task_id = request.form.get("task_id", type=int)
if new_task_id != timer.task_id:
if new_task_id:
new_task = Task.query.filter_by(id=new_task_id, project_id=timer.project_id).first()
new_task = Task.query.filter_by(id=new_task_id, project_id=update_params.get("project_id") or timer.project_id).first()
if new_task:
timer.task_id = new_task_id
update_params["task_id"] = new_task_id
else:
flash(_("Invalid task selected for the chosen project"), "error")
return render_template(
@@ -579,7 +601,9 @@ def edit_timer(timer_id):
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all(),
)
else:
timer.task_id = None
update_params["task_id"] = None
else:
update_params["task_id"] = None # Don't change if not provided
# Update start and end times if provided
start_date = request.form.get("start_date")
@@ -606,7 +630,7 @@ def edit_timer(timer_id):
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all(),
)
timer.start_time = new_start_time
update_params["start_time"] = new_start_time
except ValueError:
flash(_("Invalid start date/time format"), "error")
return render_template(
@@ -615,6 +639,8 @@ def edit_timer(timer_id):
projects=Project.query.filter_by(status="active").order_by(Project.name).all(),
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all(),
)
else:
update_params["start_time"] = None
if end_date and end_time:
try:
@@ -623,7 +649,8 @@ def edit_timer(timer_id):
new_end_time = utc_to_local(parsed_end_utc).replace(tzinfo=None)
# Validate that end time is after start time
if new_end_time <= timer.start_time:
start_time_for_validation = update_params.get("start_time") or timer.start_time
if new_end_time <= start_time_for_validation:
flash(_("End time must be after start time"), "error")
return render_template(
"timer/edit_timer.html",
@@ -632,9 +659,7 @@ def edit_timer(timer_id):
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all(),
)
timer.end_time = new_end_time
# Recalculate duration
timer.calculate_duration()
update_params["end_time"] = new_end_time
except ValueError:
flash(_("Invalid end date/time format"), "error")
return render_template(
@@ -643,15 +668,20 @@ def edit_timer(timer_id):
projects=Project.query.filter_by(status="active").order_by(Project.name).all(),
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all(),
)
else:
update_params["end_time"] = None
# Update source if provided
new_source = request.form.get("source")
if new_source in ["manual", "auto"]:
timer.source = new_source
if not safe_commit("edit_timer", {"timer_id": timer.id}):
flash(_("Could not update timer due to a database error. Please check server logs."), "error")
return redirect(url_for("main.dashboard"))
# Call service layer to update
result = service.update_entry(**update_params)
if not result.get("success"):
flash(_(result.get("message", "Could not update timer")), "error")
return render_template(
"timer/edit_timer.html",
timer=timer,
projects=Project.query.filter_by(status="active").order_by(Project.name).all() if current_user.is_admin else [],
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all() if current_user.is_admin and timer.project_id else [],
)
# Invalidate dashboard cache for the timer owner so changes appear immediately
try:
@@ -830,9 +860,10 @@ def delete_timer(timer_id):
@login_required
def bulk_delete_time_entries():
"""Bulk delete time entries"""
from app.utils.db import safe_commit
from app.services import TimeTrackingService
entry_ids = request.form.getlist("entry_ids[]")
reason = request.form.get("reason", "").strip() or None # Optional reason for bulk deletion
if not entry_ids:
flash(_("No time entries selected"), "warning")
@@ -855,6 +886,9 @@ def bulk_delete_time_entries():
deleted_count = 0
skipped_count = 0
# Use service layer for proper audit logging
service = TimeTrackingService()
for entry in entries:
# Check permissions
if not can_view_all and entry.user_id != current_user.id:
@@ -866,28 +900,20 @@ def bulk_delete_time_entries():
skipped_count += 1
continue
# Delete the entry
db.session.delete(entry)
deleted_count += 1
# Log activity
Activity.log(
# Delete using service layer to get enhanced audit logging
result = service.delete_entry(
user_id=current_user.id,
action="deleted",
entity_type="time_entry",
entity_id=entry.id,
entity_name=f"Time entry #{entry.id}",
description=f"Deleted time entry",
extra_data={"project_id": entry.project_id, "client_id": entry.client_id, "duration": entry.duration_formatted},
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
entry_id=entry.id,
is_admin=current_user.is_admin,
reason=reason, # Use same reason for all entries in bulk delete
)
if result.get("success"):
deleted_count += 1
else:
skipped_count += 1
if deleted_count > 0:
if not safe_commit("bulk_delete_time_entries", {"count": deleted_count}):
flash(_("Could not delete time entries due to a database error. Please check server logs."), "error")
return redirect(url_for("timer.time_entries_overview"))
flash(
_("Successfully deleted %(count)d time entry/entries", count=deleted_count),
"success"
@@ -1105,6 +1131,16 @@ def manual_entry():
else:
flash(_("Manual entry created for %(target)s", target=target_name), "success")
# Invalidate dashboard cache so new entry appears immediately
try:
from app.utils.cache import get_cache
cache = get_cache()
cache_key = f"dashboard:{current_user.id}"
cache.delete(cache_key)
current_app.logger.debug("Invalidated dashboard cache for user %s after manual entry creation", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
return redirect(url_for("main.dashboard"))
return render_template(
+97 -5
View File
@@ -301,10 +301,27 @@ class TimeTrackingService:
billable: Optional[bool] = None,
paid: Optional[bool] = None,
invoice_number: Optional[str] = None,
reason: Optional[str] = None,
) -> Dict[str, Any]:
"""
Update a time entry.
Args:
entry_id: ID of the time entry to update
user_id: ID of the user performing the update
is_admin: Whether the user is an admin
project_id: Optional new project ID
client_id: Optional new client ID
task_id: Optional new task ID
start_time: Optional new start time
end_time: Optional new end time
notes: Optional new notes
tags: Optional new tags
billable: Optional new billable status
paid: Optional new paid status
invoice_number: Optional new invoice number
reason: Optional reason for the change
Returns:
dict with 'success', 'message', and 'entry' keys
"""
@@ -325,6 +342,11 @@ class TimeTrackingService:
"error": "timer_active",
}
# Capture old state before changes
from app.utils.audit import capture_timeentry_state, capture_timeentry_metadata
full_old_state = capture_timeentry_state(entry)
entity_metadata = capture_timeentry_metadata(entry)
# Update fields
if project_id is not None:
# Validate project
@@ -384,12 +406,52 @@ class TimeTrackingService:
"error": "database_error",
}
# Capture new state after changes and create comprehensive audit log
try:
# Refresh entry to get updated values
db.session.refresh(entry)
full_new_state = capture_timeentry_state(entry)
updated_metadata = capture_timeentry_metadata(entry)
from app.models.audit_log import AuditLog
from app.utils.audit import get_request_info
ip_address, user_agent, request_path = get_request_info()
entity_name = entry.project.name if entry.project else (entry.client.name if entry.client else "Unknown")
AuditLog.log_change(
user_id=user_id,
action="updated",
entity_type="TimeEntry",
entity_id=entry_id,
entity_name=entity_name,
change_description=f"Updated time entry for {entity_name}",
reason=reason,
entity_metadata=updated_metadata,
full_old_state=full_old_state,
full_new_state=full_new_state,
ip_address=ip_address,
user_agent=user_agent,
request_path=request_path,
)
db.session.commit()
except Exception as e:
# Don't fail update if audit logging fails
import logging
logging.getLogger(__name__).warning(f"Failed to create audit log for TimeEntry update: {e}")
return {"success": True, "message": "Time entry updated successfully", "entry": entry}
def delete_entry(self, user_id: int, entry_id: int, is_admin: bool = False) -> Dict[str, Any]:
def delete_entry(self, user_id: int, entry_id: int, is_admin: bool = False, reason: Optional[str] = None) -> Dict[str, Any]:
"""
Delete a time entry.
Args:
user_id: ID of the user performing the deletion
entry_id: ID of the time entry to delete
is_admin: Whether the user is an admin
reason: Optional reason for deletion
Returns:
dict with 'success' and 'message' keys
"""
@@ -416,11 +478,41 @@ class TimeTrackingService:
entity_name = project_name or client_name or "Unknown"
duration_formatted = entry.duration_formatted
# Capture full state and metadata for audit logging
from app.utils.audit import capture_timeentry_state, capture_timeentry_metadata, get_request_info
from app.models.audit_log import AuditLog
full_old_state = capture_timeentry_state(entry)
entity_metadata = capture_timeentry_metadata(entry)
ip_address, user_agent, request_path = get_request_info()
if self.time_entry_repo.delete(entry):
if safe_commit("delete_entry", {"user_id": user_id, "entry_id": entry_id}):
# Create comprehensive audit log entry
try:
AuditLog.log_change(
user_id=user_id,
action="deleted",
entity_type="TimeEntry",
entity_id=entry_id,
entity_name=entity_name,
change_description=f"Deleted time entry for {entity_name} - {duration_formatted}",
reason=reason,
entity_metadata=entity_metadata,
full_old_state=full_old_state,
ip_address=ip_address,
user_agent=user_agent,
request_path=request_path,
)
db.session.commit()
except Exception as e:
# Don't fail deletion if audit logging fails
import logging
logging.getLogger(__name__).warning(f"Failed to create audit log for TimeEntry deletion: {e}")
# Log activity
from app.models import Activity
from flask import request, has_request_context
from flask import request
Activity.log(
user_id=user_id,
action="deleted",
@@ -428,9 +520,9 @@ class TimeTrackingService:
entity_id=entry_id,
entity_name=entity_name,
description=f'Deleted time entry for {entity_name} - {duration_formatted}',
extra_data={"project_name": project_name, "client_name": client_name, "duration_formatted": duration_formatted},
ip_address=request.remote_addr if has_request_context() else None,
user_agent=request.headers.get("User-Agent") if has_request_context() else None,
extra_data={"project_name": project_name, "client_name": client_name, "duration_formatted": duration_formatted, "reason": reason},
ip_address=ip_address,
user_agent=user_agent,
)
return {"success": True, "message": "Time entry deleted successfully"}
+25 -1
View File
@@ -97,7 +97,7 @@
<td class="px-6 py-4 whitespace-nowrap">
{{ badge(log.action, log.get_color()) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<td class="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
<a href="{{ url_for('audit_logs.entity_history', entity_type=log.entity_type, entity_id=log.entity_id) }}"
class="text-primary hover:underline">
{{ log.entity_type }}#{{ log.entity_id }}
@@ -105,6 +105,30 @@
{% if log.entity_name %}
<br><span class="text-xs text-gray-500">{{ log.entity_name }}</span>
{% endif %}
{% if log.entity_type == 'TimeEntry' and log.get_entity_metadata() %}
{% set metadata = log.get_entity_metadata() %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 space-y-0.5">
{% if metadata.project_name %}
<div><i class="fas fa-project-diagram mr-1"></i>{{ _('Project') }}: {{ metadata.project_name }}{% if metadata.project_id %} (ID: {{ metadata.project_id }}){% endif %}</div>
{% elif metadata.client_name %}
<div><i class="fas fa-building mr-1"></i>{{ _('Client') }}: {{ metadata.client_name }}{% if metadata.client_id %} (ID: {{ metadata.client_id }}){% endif %}</div>
{% endif %}
{% if metadata.created_at %}
<div><i class="fas fa-calendar-plus mr-1"></i>{{ _('Created') }}:
{% if metadata.created_at is string %}
{{ metadata.created_at[:16]|replace('T', ' ') }}
{% else %}
{{ metadata.created_at|user_datetime('%Y-%m-%d %H:%M') }}
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{% if log.reason %}
<div class="text-xs text-amber-600 dark:text-amber-400 mt-1">
<i class="fas fa-comment-alt mr-1"></i><strong>{{ _('Reason') }}:</strong> {{ log.reason }}
</div>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{% if log.field_name %}
+75 -1
View File
@@ -71,13 +71,52 @@
</dd>
</div>
{% endif %}
{% if audit_log.reason %}
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Reason') }}</dt>
<dd class="mt-1 text-sm text-amber-600 dark:text-amber-400">
<i class="fas fa-comment-alt mr-1"></i>{{ audit_log.reason }}
</dd>
</div>
{% endif %}
{% if audit_log.entity_type == 'TimeEntry' and audit_log.get_entity_metadata() %}
{% set metadata = audit_log.get_entity_metadata() %}
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Entity Context') }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<div class="space-y-1">
{% if metadata.project_name %}
<div><i class="fas fa-project-diagram mr-1"></i><strong>{{ _('Project') }}:</strong> {{ metadata.project_name }}{% if metadata.project_id %} (ID: {{ metadata.project_id }}){% endif %}</div>
{% endif %}
{% if metadata.client_name %}
<div><i class="fas fa-building mr-1"></i><strong>{{ _('Client') }}:</strong> {{ metadata.client_name }}{% if metadata.client_id %} (ID: {{ metadata.client_id }}){% endif %}</div>
{% endif %}
{% if metadata.task_name %}
<div><i class="fas fa-tasks mr-1"></i><strong>{{ _('Task') }}:</strong> {{ metadata.task_name }}{% if metadata.task_id %} (ID: {{ metadata.task_id }}){% endif %}</div>
{% endif %}
{% if metadata.created_at %}
<div><i class="fas fa-calendar-plus mr-1"></i><strong>{{ _('TimeEntry Created') }}:</strong>
{% if metadata.created_at is string %}
{{ metadata.created_at[:19]|replace('T', ' ') }}
{% else %}
{{ metadata.created_at|user_datetime('%Y-%m-%d %H:%M:%S') }}
{% endif %}
</div>
{% endif %}
{% if metadata.user_name %}
<div><i class="fas fa-user mr-1"></i><strong>{{ _('Entry Owner') }}:</strong> {{ metadata.user_name }}{% if metadata.user_id %} (ID: {{ metadata.user_id }}){% endif %}</div>
{% endif %}
</div>
</dd>
</div>
{% endif %}
</dl>
</div>
<!-- Change Details -->
{% if audit_log.field_name %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4">{{ _('Change Details') }}</h2>
<h2 class="text-xl font-semibold mb-4">{{ _('Field Change Details') }}</h2>
<div class="space-y-4">
{% if audit_log.old_value %}
<div>
@@ -99,6 +138,41 @@
</div>
{% endif %}
<!-- Full Entity State -->
{% if audit_log.get_full_old_state() or audit_log.get_full_new_state() %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow lg:col-span-2">
<h2 class="text-xl font-semibold mb-4">{{ _('Full Entity State') }}</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{% if audit_log.get_full_old_state() %}
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">{{ _('Before Change') }}</h3>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 max-h-96 overflow-y-auto">
{% set old_state = audit_log.get_full_old_state() %}
{% if old_state is mapping or old_state is iterable and old_state is not string %}
<pre class="text-xs text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words">{{ old_state|tojson(indent=2) }}</pre>
{% else %}
<pre class="text-xs text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words">{{ old_state }}</pre>
{% endif %}
</div>
</div>
{% endif %}
{% if audit_log.get_full_new_state() %}
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">{{ _('After Change') }}</h3>
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 max-h-96 overflow-y-auto">
{% set new_state = audit_log.get_full_new_state() %}
{% if new_state is mapping or new_state is iterable and new_state is not string %}
<pre class="text-xs text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words">{{ new_state|tojson(indent=2) }}</pre>
{% else %}
<pre class="text-xs text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words">{{ new_state }}</pre>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Request Information -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow lg:col-span-2">
<h2 class="text-xl font-semibold mb-4">{{ _('Request Information') }}</h2>
+42 -8
View File
@@ -119,6 +119,7 @@
<form id="bulkDeleteForm" method="POST" action="{{ url_for('timer.bulk_delete_time_entries') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="reason" id="bulkDeleteReason">
{% for key, value in filters.items() %}
{% if value %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
@@ -131,6 +132,30 @@
{% include 'timer/_time_entries_list.html' %}
</div>
<!-- Bulk Delete Dialog -->
<div id="bulkDeleteDialog" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4 text-rose-600">{{ _('Delete Selected Time Entries') }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4" id="bulkDeleteMessage"></p>
<div class="mb-4">
<label for="bulkDeleteReasonInput" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Reason for Deletion') }} <span class="text-text-muted-light dark:text-text-muted-dark text-xs">({{ _('Optional but recommended') }})</span>
</label>
<textarea id="bulkDeleteReasonInput" class="form-input w-full" rows="3" placeholder="{{ _('e.g., Duplicate entry, incorrect time, etc.') }}"></textarea>
</div>
<div class="flex justify-end gap-3 mt-6">
<button type="button" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" onclick="closeBulkDeleteDialog()">
{{ _('Cancel') }}
</button>
<button type="button" class="px-4 py-2 bg-rose-600 text-white rounded-lg hover:bg-rose-700 transition-colors" onclick="confirmBulkDelete()">
<i class="fas fa-trash mr-2"></i>{{ _('Delete') }}
</button>
</div>
</div>
</div>
</div>
<!-- Bulk Mark Paid Dialog -->
<div id="bulkPaidDialog" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full mx-4">
@@ -295,21 +320,28 @@ function showBulkDeleteConfirm() {
}
const count = checkboxes.length;
const msg = `{{ _("Are you sure you want to delete") }} ${count} {{ _("time entry/entries") }}? {{ _("This action cannot be undone.") }}`;
if (window.showConfirm) {
window.showConfirm(msg, { title: '{{ _("Delete Time Entries") }}', confirmText: '{{ _("Delete") }}', variant: 'danger' }).then(function(ok) {
if (ok) submitBulkDelete();
});
} else if (confirm(msg)) {
submitBulkDelete();
}
// Show custom dialog with reason field
document.getElementById('bulkDeleteMessage').textContent = msg;
document.getElementById('bulkDeleteReasonInput').value = '';
document.getElementById('bulkDeleteDialog').classList.remove('hidden');
// Close the menu
document.getElementById('bulkActionsMenu').classList.add('hidden');
return false;
}
function submitBulkDelete() {
function closeBulkDeleteDialog() {
document.getElementById('bulkDeleteDialog').classList.add('hidden');
}
function confirmBulkDelete() {
const checkboxes = document.querySelectorAll('.entry-checkbox:checked');
const form = document.getElementById('bulkDeleteForm');
const reasonInput = document.getElementById('bulkDeleteReasonInput');
// Set reason in form
document.getElementById('bulkDeleteReason').value = reasonInput.value.trim();
// Clear existing entry IDs
form.querySelectorAll('input[name="entry_ids[]"]').forEach(input => input.remove());
@@ -323,6 +355,8 @@ function submitBulkDelete() {
form.appendChild(input);
});
// Close dialog and submit
closeBulkDeleteDialog();
form.submit();
}
+97
View File
@@ -152,6 +152,64 @@ def serialize_value(value):
return str(value)
def capture_timeentry_metadata(entry):
"""Capture TimeEntry metadata for audit logging
Args:
entry: TimeEntry instance
Returns:
dict with client_id, project_id, created_at, and related entity names
"""
metadata = {
"client_id": entry.client_id,
"client_name": entry.client.name if entry.client else None,
"project_id": entry.project_id,
"project_name": entry.project.name if entry.project else None,
"task_id": entry.task_id,
"task_name": entry.task.name if entry.task else None,
"created_at": entry.created_at.isoformat() if hasattr(entry, "created_at") and entry.created_at else None,
"user_id": entry.user_id,
"user_name": entry.user.username if entry.user else None,
}
return metadata
def capture_timeentry_state(entry):
"""Capture full TimeEntry state for audit logging
Args:
entry: TimeEntry instance
Returns:
dict with all TimeEntry fields and related entity information
"""
state = {
"id": entry.id if hasattr(entry, "id") else None,
"user_id": entry.user_id,
"project_id": entry.project_id,
"client_id": entry.client_id,
"task_id": entry.task_id,
"start_time": entry.start_time.isoformat() if entry.start_time else None,
"end_time": entry.end_time.isoformat() if entry.end_time else None,
"duration_seconds": entry.duration_seconds,
"notes": entry.notes,
"tags": entry.tags,
"source": entry.source,
"billable": entry.billable,
"paid": entry.paid,
"invoice_number": entry.invoice_number,
"created_at": entry.created_at.isoformat() if hasattr(entry, "created_at") and entry.created_at else None,
"updated_at": entry.updated_at.isoformat() if hasattr(entry, "updated_at") and entry.updated_at else None,
# Related entity names for context
"project_name": entry.project.name if entry.project else None,
"client_name": entry.client.name if entry.client else None,
"task_name": entry.task.name if entry.task else None,
"user_name": entry.user.username if entry.user else None,
}
return state
# Call count for table-exists check (force recheck every 100) and warning/debug logs
_audit_call_count = 0
@@ -190,6 +248,16 @@ def receive_before_flush(session, flush_context, instances=None):
entity_name = get_entity_name(instance)
try:
# For TimeEntry, capture full old state before changes
full_old_state = None
entity_metadata = None
if entity_type == "TimeEntry":
try:
full_old_state = capture_timeentry_state(instance)
entity_metadata = capture_timeentry_metadata(instance)
except Exception as e:
logger.warning(f"Could not capture TimeEntry state for {entity_id}: {e}")
instance_state = inspect(instance)
changed_fields = []
for attr_name in instance_state.mapper.column_attrs.keys():
@@ -214,6 +282,8 @@ def receive_before_flush(session, flush_context, instances=None):
new_value=serialize_value(change["new"]),
entity_name=entity_name,
change_description=f"Updated {entity_type.lower()} '{entity_name}': {change['field']}",
entity_metadata=entity_metadata,
full_old_state=full_old_state,
ip_address=ip_address,
user_agent=user_agent,
request_path=request_path,
@@ -226,6 +296,8 @@ def receive_before_flush(session, flush_context, instances=None):
entity_id=entity_id,
entity_name=entity_name,
change_description=f"Updated {entity_type.lower()} '{entity_name}'",
entity_metadata=entity_metadata,
full_old_state=full_old_state,
ip_address=ip_address,
user_agent=user_agent,
request_path=request_path,
@@ -252,6 +324,16 @@ def receive_before_flush(session, flush_context, instances=None):
entity_id = instance.id if hasattr(instance, "id") else None
entity_name = get_entity_name(instance)
try:
# For TimeEntry, capture full state and metadata before deletion
full_old_state = None
entity_metadata = None
if entity_type == "TimeEntry":
try:
full_old_state = capture_timeentry_state(instance)
entity_metadata = capture_timeentry_metadata(instance)
except Exception as e:
logger.warning(f"Could not capture TimeEntry state for deletion of {entity_id}: {e}")
AuditLog = get_audit_log_model()
AuditLog.log_change(
user_id=user_id,
@@ -260,6 +342,8 @@ def receive_before_flush(session, flush_context, instances=None):
entity_id=entity_id,
entity_name=entity_name,
change_description=f"Deleted {entity_type.lower()} '{entity_name}'",
entity_metadata=entity_metadata,
full_old_state=full_old_state,
ip_address=ip_address,
user_agent=user_agent,
request_path=request_path,
@@ -298,6 +382,17 @@ def receive_after_flush(session, flush_context):
if entity_id is None:
continue
entity_name = get_entity_name(instance)
# For TimeEntry, capture full state and metadata
full_new_state = None
entity_metadata = None
if entity_type == "TimeEntry":
try:
full_new_state = capture_timeentry_state(instance)
entity_metadata = capture_timeentry_metadata(instance)
except Exception as e:
logger.warning(f"Could not capture TimeEntry state for creation of {entity_id}: {e}")
AuditLog = get_audit_log_model()
AuditLog.log_change(
user_id=user_id,
@@ -306,6 +401,8 @@ def receive_after_flush(session, flush_context):
entity_id=entity_id,
entity_name=entity_name,
change_description=f"Created {entity_type.lower()} '{entity_name}'",
entity_metadata=entity_metadata,
full_new_state=full_new_state,
ip_address=ip_address,
user_agent=user_agent,
request_path=request_path,
@@ -0,0 +1,100 @@
"""Enhance audit_logs table for TimeEntry comprehensive tracking
Revision ID: 114_enhance_audit_logs_timeentry
Revises: 113_add_invoice_buyer_reference
Create Date: 2025-01-30
Adds columns for reason, entity_metadata, full_old_state, and full_new_state
to support comprehensive TimeEntry audit logging.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '114_enhance_audit_logs_timeentry'
down_revision = '113_add_invoice_buyer_reference'
branch_labels = None
depends_on = None
def upgrade():
"""Add new columns to audit_logs table for enhanced TimeEntry tracking"""
from sqlalchemy import inspect
bind = op.get_bind()
inspector = inspect(bind)
existing_tables = inspector.get_table_names()
if 'audit_logs' not in existing_tables:
print("⊘ Table audit_logs does not exist, skipping enhancement")
return
# Check which columns already exist
existing_columns = [col['name'] for col in inspector.get_columns('audit_logs')]
# Add reason column
if 'reason' not in existing_columns:
try:
op.add_column('audit_logs', sa.Column('reason', sa.Text(), nullable=True))
print("✓ Added reason column to audit_logs")
except Exception as e:
print(f"⚠ Could not add reason column: {e}")
# Add entity_metadata column (JSON for flexibility)
if 'entity_metadata' not in existing_columns:
try:
# Use JSON for PostgreSQL, Text for SQLite
conn = op.get_bind()
is_postgres = conn.dialect.name == 'postgresql'
if is_postgres:
op.add_column('audit_logs', sa.Column('entity_metadata', postgresql.JSON(astext_type=sa.Text()), nullable=True))
else:
op.add_column('audit_logs', sa.Column('entity_metadata', sa.Text(), nullable=True))
print("✓ Added entity_metadata column to audit_logs")
except Exception as e:
print(f"⚠ Could not add entity_metadata column: {e}")
# Add full_old_state column
if 'full_old_state' not in existing_columns:
try:
op.add_column('audit_logs', sa.Column('full_old_state', sa.Text(), nullable=True))
print("✓ Added full_old_state column to audit_logs")
except Exception as e:
print(f"⚠ Could not add full_old_state column: {e}")
# Add full_new_state column
if 'full_new_state' not in existing_columns:
try:
op.add_column('audit_logs', sa.Column('full_new_state', sa.Text(), nullable=True))
print("✓ Added full_new_state column to audit_logs")
except Exception as e:
print(f"⚠ Could not add full_new_state column: {e}")
def downgrade():
"""Remove enhanced columns from audit_logs table"""
from sqlalchemy import inspect
bind = op.get_bind()
inspector = inspect(bind)
existing_tables = inspector.get_table_names()
if 'audit_logs' not in existing_tables:
print("⊘ Table audit_logs does not exist, skipping")
return
existing_columns = [col['name'] for col in inspector.get_columns('audit_logs')]
# Remove columns in reverse order
columns_to_remove = ['full_new_state', 'full_old_state', 'entity_metadata', 'reason']
for col_name in columns_to_remove:
if col_name in existing_columns:
try:
op.drop_column('audit_logs', col_name)
print(f"✓ Removed {col_name} column from audit_logs")
except Exception as e:
print(f"⚠ Could not remove {col_name} column: {e}")
+18
View File
@@ -332,6 +332,15 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
<!-- Reason for Change -->
<div class="mb-6">
<label for="reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-comment-alt mr-1"></i>{{ _('Reason for Change') }} <span class="text-text-muted-light dark:text-text-muted-dark text-xs">({{ _('Optional but recommended') }})</span>
</label>
<textarea class="form-input" id="reason" name="reason" rows="3" placeholder="{{ _('e.g., Corrected duration, updated project assignment, etc.') }}"></textarea>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Explain why you are making these changes') }}</p>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row justify-between gap-3 mt-8 pt-6 border-t border-border-light dark:border-border-dark">
<div class="flex gap-2">
@@ -441,6 +450,15 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
<!-- Reason for Change -->
<div class="mb-6">
<label for="reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-comment-alt mr-1"></i>{{ _('Reason for Change') }} <span class="text-text-muted-light dark:text-text-muted-dark text-xs">({{ _('Optional but recommended') }})</span>
</label>
<textarea class="form-input" id="reason" name="reason" rows="3" placeholder="{{ _('e.g., Corrected duration, updated project assignment, etc.') }}"></textarea>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Explain why you are making these changes') }}</p>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row justify-between gap-3 mt-8 pt-6 border-t border-border-light dark:border-border-dark">
<div class="flex gap-2">