diff --git a/CHANGELOG.md b/CHANGELOG.md index 439e658c..e1f59a8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Mileage and Per Diem export and filter (Issue #564)** — Mileage and Per Diem now support CSV and PDF export using the same filter set as the list view, matching Time Entries behavior. **Mileage**: Export CSV and Export PDF buttons in the filter card; exports use current filters (search, status, project, client, date range). Routes: `GET /mileage/export/csv`, `GET /mileage/export/pdf`. PDF report via [app/utils/mileage_pdf.py](app/utils/mileage_pdf.py) (ReportLab, landscape A4, totals row). **Per diem**: Client filter added to the list form (with client-lock/single-client handling); Export CSV and Export PDF buttons; routes `GET /per-diem/export/csv`, `GET /per-diem/export/pdf`. PDF via [app/utils/per_diem_pdf.py](app/utils/per_diem_pdf.py). Export links are built from the current filter form (JS), so applied filters apply to both the list and the downloaded file. +- **Break time for timers and manual time entries (Issue #561)** — Pause/resume running timers so time while paused counts as break; on stop, stored duration = (end − start) − break (with rounding). Manual time entries and edit form have an optional **Break** field (HH:MM); effective duration is (end − start) − break. Optional default break rules in Settings (e.g. >6 h → 30 min, >9 h → 45 min) power a **Suggest** button on the manual entry form; users can override. New columns: `time_entries.break_seconds`, `time_entries.paused_at`; Settings: `break_after_hours_1`, `break_minutes_1`, `break_after_hours_2`, `break_minutes_2`. API: `POST /api/v1/timer/pause`, `POST /api/v1/timer/resume`; timer status and time entry create/update accept and return `break_seconds`. See [docs/BREAK_TIME_FEATURE.md](docs/BREAK_TIME_FEATURE.md). - **Architecture refactor** — API v1 split into per-resource sub-blueprints (projects, tasks, clients, invoices, expenses, payments, mileage, deals, leads, contacts) under `app/routes/api_v1_*.py`; bootstrap slimmed by moving `setup_logging` to `app/utils/setup_logging.py` and legacy migrations to `app/utils/legacy_migrations.py`. Dashboard aggregations (top projects, time-by-project chart) moved into `AnalyticsService` (`get_dashboard_top_projects`, `get_time_by_project_chart`); dashboard route simplified to call services only. ARCHITECTURE.md updated with module table, API structure, and data flow; DEVELOPMENT.md with development workflow and build steps. ### Fixed +- **Time Entries date filter and export (Issue #555)** — Start/End date filters were hard to discover and exports ignored them. The Time Entries overview now has a visible **Apply filters** button in the filter header (next to Clear Filters and Export) so users can apply date and other filters without scrolling. CSV and PDF export links always use the current filter parameters: export href is set from the page URL on load and updated whenever filter form values change, so left-click export, right-click "Open in new tab", and "Save link as" all produce filtered exports. The in-form Apply filters button and the header button both trigger the same filter logic; clicking the header button expands the filter panel if it is collapsed. +- **Log Time / Edit Time Entry on mobile (Issue #557)** — Opening the manual time entry ("Log Time") or edit time entry page on mobile could freeze or crash the browser. The Toast UI Editor (WYSIWYG markdown editor) for the notes field is heavy and causes freezes on mobile Safari/Chrome. On viewports ≤767px we now skip loading the editor and show a plain textarea for notes instead; desktop behavior is unchanged. Manual entry and edit timer templates load Toast UI only when not in mobile view. +- **Stop & Save error (Issue #563)** — Fixed error after clicking "Stop & Save" on the dashboard. The post-timer toast was building the "View time entries" URL with the wrong route name (`timer.time_entries`); the correct endpoint is `timer.time_entries_overview`. Time entries were already saved; the error occurred when rendering the dashboard redirect. - **Dashboard cache (Issue #549)** — Removed dashboard caching that caused "Instance not bound to a Session" and "Database Error" on second visit. Cached template data contained ORM objects (active_timer, recent_entries, top_projects, templates, etc.) that become detached when served in a different request. - **Task description field (Issue #535)** — When creating or editing a task, the description field could appear missing or broken if the Toast UI Editor (loaded from CDN) failed to load (e.g. reverse proxy, CSP, Firefox, or offline). A fallback now shows a plain textarea so users can always enter a description; Markdown is still supported when the rich editor loads. - **ZUGFeRD / PDF/A-3 and PEPPOL (Discussion #433)** — ZUGFeRD embedding no longer silently succeeds without XML when the embed step fails; export is aborted with an actionable error. XMP metadata is created when missing so validators recognize the document. Optional PDF/A-3 normalization (XMP identification and output intent) and optional veraPDF validation gate added. Native PEPPOL transport (SML/SMP + AS4) and strict sender/recipient identifier validation added. diff --git a/app/models/settings.py b/app/models/settings.py index c4a95cdd..8126aba1 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -118,6 +118,12 @@ class Settings(db.Model): # Overtime / time tracking: default daily working hours for new users (e.g. 8.0) default_daily_working_hours = db.Column(db.Float, default=8.0, nullable=False) + # Default break rules for time entries (e.g. Germany: >6h = 30 min, >9h = 45 min). User can override per entry. + break_after_hours_1 = db.Column(db.Float, nullable=True) # e.g. 6 + break_minutes_1 = db.Column(db.Integer, nullable=True) # e.g. 30 + break_after_hours_2 = db.Column(db.Float, nullable=True) # e.g. 9 + break_minutes_2 = db.Column(db.Integer, nullable=True) # e.g. 45 + # Email configuration settings (stored in database, takes precedence over environment variables) mail_enabled = db.Column(db.Boolean, default=False, nullable=False) # Enable database-backed email config mail_server = db.Column(db.String(255), default="", nullable=True) diff --git a/app/models/time_entry.py b/app/models/time_entry.py index 93c8f778..1fce9d6d 100644 --- a/app/models/time_entry.py +++ b/app/models/time_entry.py @@ -26,6 +26,8 @@ class TimeEntry(db.Model): start_time = db.Column(db.DateTime, nullable=False, index=True) end_time = db.Column(db.DateTime, nullable=True, index=True) duration_seconds = db.Column(db.Integer, nullable=True) + break_seconds = db.Column(db.Integer, nullable=True, default=0) + paused_at = db.Column(db.DateTime, nullable=True) notes = db.Column(db.Text, nullable=True) tags = db.Column(db.String(500), nullable=True) # Comma-separated tags source = db.Column(db.String(20), default="manual", nullable=False) # 'manual' or 'auto' @@ -55,6 +57,7 @@ def __init__( paid=False, invoice_number=None, duration_seconds=None, + break_seconds=None, **kwargs, ): """Initialize a TimeEntry instance. @@ -73,8 +76,11 @@ def __init__( paid: Whether this entry has been paid invoice_number: Optional internal invoice number reference duration_seconds: Optional duration override (usually calculated automatically) + break_seconds: Optional break time in seconds to subtract from duration **kwargs: Additional keyword arguments (for SQLAlchemy compatibility) """ + if break_seconds is not None: + self.break_seconds = int(break_seconds) if user_id is not None: self.user_id = user_id if project_id is not None: @@ -126,6 +132,20 @@ def is_active(self): """Check if this is an active timer (no end time)""" return self.end_time is None + @property + def is_paused(self): + """Check if this active timer is currently paused""" + return self.paused_at is not None + + @property + def break_formatted(self): + """Format break_seconds as HH:MM:SS for display""" + sec = self.break_seconds or 0 + hours = sec // 3600 + minutes = (sec % 3600) // 60 + seconds = sec % 60 + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + @property def duration_hours(self): """Get duration in hours""" @@ -160,20 +180,19 @@ def tag_list(self): @property def current_duration_seconds(self): - """Calculate current duration for active timers""" + """Calculate current duration for active timers (worked time, excluding break).""" if self.end_time: return self.duration_seconds or 0 - # For active timers, calculate from start time to now - # Since we store everything in local timezone, we can work with naive datetimes - # as long as we treat them as local time - - # Get current time in local timezone (naive, matching database storage) + # For active timers: elapsed time minus break; if paused, time stops at paused_at + break_sec = self.break_seconds or 0 now_local = local_now() - - # Calculate duration (both times are treated as local time) - duration = now_local - self.start_time - return int(duration.total_seconds()) + end_ref = self._naive_dt(self.paused_at) if self.paused_at else now_local + start_naive = self._naive_dt(self.start_time) + if start_naive is None or end_ref is None: + return 0 + raw_seconds = int((end_ref - start_naive).total_seconds()) + return max(0, raw_seconds - break_sec) def _naive_dt(self, dt): """Return datetime as naive local for duration math (handles DB returning aware in some backends).""" @@ -197,6 +216,8 @@ def calculate_duration(self): return duration = end - start raw_seconds = int(duration.total_seconds()) + break_sec = self.break_seconds or 0 + raw_seconds = max(0, raw_seconds - break_sec) # Apply per-user rounding if user preferences are set if self.user and hasattr(self.user, "time_rounding_enabled"): @@ -230,6 +251,31 @@ def stop_timer(self, end_time=None): db.session.commit() + def pause_timer(self): + """Pause an active timer (clock stops; break accumulates on resume).""" + if self.end_time: + raise ValueError("Timer is already stopped") + if self.paused_at: + raise ValueError("Timer is already paused") + self.paused_at = local_now() + self.updated_at = local_now() + db.session.commit() + + def resume_timer(self): + """Resume a paused timer (accumulate time since paused_at into break_seconds).""" + if self.end_time: + raise ValueError("Timer is already stopped") + if not self.paused_at: + raise ValueError("Timer is not paused") + now = local_now() + paused_naive = self._naive_dt(self.paused_at) + if paused_naive: + added_break = int((now - paused_naive).total_seconds()) + self.break_seconds = (self.break_seconds or 0) + added_break + self.paused_at = None + self.updated_at = local_now() + db.session.commit() + def update_notes(self, notes): """Update notes for this entry""" self.notes = notes.strip() if notes else None @@ -270,6 +316,8 @@ def to_dict(self): "start_time": self.start_time.isoformat() if self.start_time else None, "end_time": self.end_time.isoformat() if self.end_time else None, "duration_seconds": self.duration_seconds, + "break_seconds": self.break_seconds, + "paused_at": self.paused_at.isoformat() if self.paused_at else None, "duration_hours": self.duration_hours, "duration_formatted": self.duration_formatted, "notes": self.notes, diff --git a/app/repositories/time_entry_repository.py b/app/repositories/time_entry_repository.py index 5236a68e..d72daad3 100644 --- a/app/repositories/time_entry_repository.py +++ b/app/repositories/time_entry_repository.py @@ -164,6 +164,7 @@ def create_manual_entry( start_time: datetime = None, end_time: datetime = None, duration_seconds: Optional[int] = None, + break_seconds: Optional[int] = None, task_id: Optional[int] = None, notes: Optional[str] = None, tags: Optional[str] = None, @@ -171,7 +172,7 @@ def create_manual_entry( paid: bool = False, invoice_number: Optional[str] = None, ) -> TimeEntry: - """Create a manual time entry""" + """Create a manual time entry. duration_seconds is net (worked time); break_seconds is subtracted when computing from start/end.""" entry = self.model( user_id=user_id, project_id=project_id, @@ -180,6 +181,7 @@ def create_manual_entry( start_time=start_time, end_time=end_time, duration_seconds=duration_seconds, + break_seconds=break_seconds or 0, notes=notes, tags=tags, billable=billable, @@ -187,8 +189,6 @@ def create_manual_entry( invoice_number=invoice_number, source=TimeEntrySource.MANUAL.value, ) - # If duration_seconds is explicitly provided, `TimeEntry.__init__` will keep it. - # Still run calculate_duration() to apply rounding behavior when duration was not provided. if duration_seconds is None: entry.calculate_duration() db.session.add(entry) diff --git a/app/routes/analytics.py b/app/routes/analytics.py index 91252d71..78e3a482 100644 --- a/app/routes/analytics.py +++ b/app/routes/analytics.py @@ -451,16 +451,29 @@ def weekly_trends(): @login_required @module_enabled("analytics") def overtime_analytics(): - """Get overtime statistics for the current user or all users (if admin)""" - try: - days = int(request.args.get("days", 30)) - except (ValueError, TypeError): - return jsonify({"error": "Invalid days parameter"}), 400 - + """Get overtime statistics for the current user or all users (if admin). + Supports period=ytd (year-to-date), or days=N (last N days), or start_date/end_date.""" from app.utils.overtime import calculate_period_overtime, get_daily_breakdown end_date = datetime.now().date() - start_date = end_date - timedelta(days=days) + period = request.args.get("period", "").lower() + if period == "ytd": + start_date = end_date.replace(month=1, day=1) + else: + start_date_arg = request.args.get("start_date") + end_date_arg = request.args.get("end_date") + if start_date_arg and end_date_arg: + try: + start_date = datetime.strptime(start_date_arg, "%Y-%m-%d").date() + end_date = datetime.strptime(end_date_arg, "%Y-%m-%d").date() + except ValueError: + return jsonify({"error": "Invalid start_date or end_date"}), 400 + else: + try: + days = int(request.args.get("days", 30)) + except (ValueError, TypeError): + return jsonify({"error": "Invalid days parameter"}), 400 + start_date = end_date - timedelta(days=days) # If admin, show all users; otherwise show current user only if current_user.is_admin: @@ -503,6 +516,9 @@ def overtime_analytics(): return jsonify( { + "period": "ytd" if period == "ytd" else "range", + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), "users": user_overtime_data, "summary": { "total_regular_hours": round(total_regular, 2), diff --git a/app/routes/api.py b/app/routes/api.py index 0f0240d0..1e789077 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -331,6 +331,11 @@ def api_start_timer(): if not project: return jsonify({"error": "Invalid project"}), 400 + from app.utils.scope_filter import user_can_access_project + + if not user_can_access_project(current_user, project_id): + return jsonify({"error": "You do not have access to this project"}), 403 + # Validate task if provided task = None if task_id: @@ -1508,9 +1513,12 @@ def get_stats(): start_date=start_date.date(), user_id=user_id ) - # Overtime for today and week + # Overtime for today, week, and YTD + from app.utils.overtime import get_overtime_ytd + today_overtime = calculate_period_overtime(current_user, today, today) week_overtime = calculate_period_overtime(current_user, week_start, today) + overtime_ytd = get_overtime_ytd(current_user) standard_hours = float(getattr(current_user, "standard_hours_per_day", 8.0) or 8.0) return jsonify( @@ -1524,6 +1532,7 @@ def get_stats(): "today_overtime_hours": today_overtime["overtime_hours"], "week_regular_hours": week_overtime["regular_hours"], "week_overtime_hours": week_overtime["overtime_hours"], + "overtime_ytd_hours": overtime_ytd["overtime_hours"], } ) @@ -1825,7 +1834,7 @@ def dashboard_stats(): """Get dashboard statistics for real-time updates""" from app.models import TimeEntry from datetime import datetime, timedelta - from app.utils.overtime import calculate_period_overtime, get_week_start_for_date + from app.utils.overtime import calculate_period_overtime, get_week_start_for_date, get_overtime_ytd today = datetime.utcnow().date() week_start = get_week_start_for_date(today, current_user) @@ -1837,9 +1846,10 @@ def dashboard_stats(): month_hours = TimeEntry.get_total_hours_for_period(start_date=month_start, user_id=current_user.id) - # Overtime for today and week (for dashboard cards) + # Overtime for today, week, and YTD (for dashboard cards) today_overtime = calculate_period_overtime(current_user, today, today) week_overtime = calculate_period_overtime(current_user, week_start, today) + overtime_ytd = get_overtime_ytd(current_user) standard_hours = float(getattr(current_user, "standard_hours_per_day", 8.0) or 8.0) return jsonify( @@ -1853,6 +1863,7 @@ def dashboard_stats(): "today_overtime_hours": today_overtime["overtime_hours"], "week_regular_hours": week_overtime["regular_hours"], "week_overtime_hours": week_overtime["overtime_hours"], + "overtime_ytd_hours": overtime_ytd["overtime_hours"], } ) diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py index 72f5c287..67c986c2 100644 --- a/app/routes/api_v1.py +++ b/app/routes/api_v1.py @@ -4266,6 +4266,17 @@ def close_timesheet_period(period_id): return jsonify({"message": "Timesheet period closed", "timesheet_period": result["period"].to_dict()}) +@api_v1_bp.route("/timesheet-periods/", methods=["DELETE"]) +@require_api_token("write:time_entries") +def delete_timesheet_period_api(period_id): + from app.services.workforce_governance_service import WorkforceGovernanceService + + result = WorkforceGovernanceService().delete_period(period_id=period_id, actor_id=g.api_user.id) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not delete period")}), 400 + return jsonify({"message": "Timesheet period deleted"}) + + @api_v1_bp.route("/timesheet-policy", methods=["GET"]) @require_api_token("read:time_entries") def get_timesheet_policy(): @@ -4348,6 +4359,19 @@ def create_leave_type_api(): return jsonify({"message": "Leave type created", "leave_type": leave_type.to_dict()}), 201 +@api_v1_bp.route("/time-off/leave-types/", methods=["DELETE"]) +@require_api_token("write:reports") +def delete_leave_type_api(leave_type_id): + from app.services.workforce_governance_service import WorkforceGovernanceService + + if not g.api_user.is_admin: + return jsonify({"error": "Access denied"}), 403 + result = WorkforceGovernanceService().delete_leave_type(leave_type_id) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not delete leave type")}), 400 + return jsonify({"message": "Leave type deleted"}) + + @api_v1_bp.route("/time-off/requests", methods=["GET"]) @require_api_token("read:time_entries") def list_time_off_requests_api(): @@ -4445,6 +4469,21 @@ def reject_time_off_request_api(request_id): return jsonify({"message": "Time-off request rejected", "time_off_request": result["request"].to_dict()}) +@api_v1_bp.route("/time-off/requests/", methods=["DELETE"]) +@require_api_token("write:time_entries") +def delete_time_off_request_api(request_id): + from app.services.workforce_governance_service import WorkforceGovernanceService + + result = WorkforceGovernanceService().delete_leave_request( + request_id=request_id, + actor_id=g.api_user.id, + actor_can_approve=_is_api_approver(g.api_user), + ) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not delete request")}), 400 + return jsonify({"message": "Time-off request deleted"}) + + @api_v1_bp.route("/time-off/balances", methods=["GET"]) @require_api_token("read:time_entries") def time_off_balances_api(): @@ -4494,6 +4533,19 @@ def create_holiday_api(): return jsonify({"message": "Holiday created", "holiday": holiday.to_dict()}), 201 +@api_v1_bp.route("/time-off/holidays/", methods=["DELETE"]) +@require_api_token("write:reports") +def delete_holiday_api(holiday_id): + from app.services.workforce_governance_service import WorkforceGovernanceService + + if not g.api_user.is_admin: + return jsonify({"error": "Access denied"}), 403 + result = WorkforceGovernanceService().delete_holiday(holiday_id) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not delete holiday")}), 400 + return jsonify({"message": "Holiday deleted"}) + + # ==================== Payroll Export ==================== diff --git a/app/routes/api_v1_time_entries.py b/app/routes/api_v1_time_entries.py index 86bf904a..43c8ff09 100644 --- a/app/routes/api_v1_time_entries.py +++ b/app/routes/api_v1_time_entries.py @@ -103,6 +103,7 @@ def create_time_entry(): client_id=validated.get("client_id"), start_time=start_time, end_time=end_time, + break_seconds=validated.get("break_seconds"), task_id=validated.get("task_id"), notes=validated.get("notes"), tags=validated.get("tags"), @@ -169,6 +170,7 @@ def update_time_entry(entry_id): task_id=validated.get("task_id"), start_time=validated.get("start_time"), end_time=validated.get("end_time"), + break_seconds=validated.get("break_seconds"), notes=validated.get("notes"), tags=validated.get("tags"), billable=validated.get("billable"), @@ -247,6 +249,12 @@ def start_timer(): project_id = data.get("project_id") if not project_id: return jsonify({"error": "project_id is required"}), 400 + + from app.utils.scope_filter import user_can_access_project + + if not user_can_access_project(g.api_user, project_id): + return jsonify({"error": "You do not have access to this project"}), 403 + time_tracking_service = TimeTrackingService() result = time_tracking_service.start_timer( user_id=g.api_user.id, @@ -260,6 +268,38 @@ def start_timer(): return jsonify({"message": "Timer started successfully", "timer": result["timer"].to_dict()}), 201 +@api_v1_time_entries_bp.route("/timer/pause", methods=["POST"]) +@require_api_token("write:time_entries") +def pause_timer(): + """Pause the active timer (clock stops; break accumulates on resume).""" + from app.services import TimeTrackingService + + time_tracking_service = TimeTrackingService() + result = time_tracking_service.pause_timer(user_id=g.api_user.id) + if not result.get("success"): + return jsonify({ + "error": result.get("message", "Could not pause timer"), + "error_code": result.get("error", "pause_failed"), + }), 400 + return jsonify({"message": "Timer paused", "time_entry": result["entry"].to_dict()}) + + +@api_v1_time_entries_bp.route("/timer/resume", methods=["POST"]) +@require_api_token("write:time_entries") +def resume_timer(): + """Resume a paused timer (time since pause is counted as break).""" + from app.services import TimeTrackingService + + time_tracking_service = TimeTrackingService() + result = time_tracking_service.resume_timer(user_id=g.api_user.id) + if not result.get("success"): + return jsonify({ + "error": result.get("message", "Could not resume timer"), + "error_code": result.get("error", "resume_failed"), + }), 400 + return jsonify({"message": "Timer resumed", "time_entry": result["entry"].to_dict()}) + + @api_v1_time_entries_bp.route("/timer/stop", methods=["POST"]) @require_api_token("write:time_entries") def stop_timer(): diff --git a/app/routes/kiosk.py b/app/routes/kiosk.py index b6ecd9b8..d113071b 100644 --- a/app/routes/kiosk.py +++ b/app/routes/kiosk.py @@ -461,6 +461,11 @@ def kiosk_start_timer(): if not project or project.status != "active": return jsonify({"error": "Invalid or inactive project"}), 400 + from app.utils.scope_filter import user_can_access_project + + if not user_can_access_project(current_user, project_id): + return jsonify({"error": "You do not have access to this project"}), 403 + # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: diff --git a/app/routes/main.py b/app/routes/main.py index ca189499..10152282 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -1,6 +1,6 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify from flask_login import login_required, current_user -from app.models import User, Project, TimeEntry, Settings, WeeklyTimeGoal, TimeEntryTemplate, Activity +from app.models import User, Project, TimeEntry, Settings, WeeklyTimeGoal, TimeEntryTemplate, Activity, Client from datetime import datetime, timedelta from app import db, track_page_view from sqlalchemy import text @@ -39,19 +39,25 @@ def dashboard(): time_entry_repo = TimeEntryRepository() recent_entries = time_entry_repo.get_by_user(user_id=current_user.id, limit=10, include_relations=True) - # Get active projects for timer dropdown (using repository) - from app.repositories import ProjectRepository, ClientRepository - - project_repo = ProjectRepository() - client_repo = ClientRepository() - active_projects = project_repo.get_active_projects() - active_clients = client_repo.get_active_clients() + # Get active projects and clients for timer dropdown (scoped for subcontractors) + from app.utils.scope_filter import apply_project_scope_to_model, apply_client_scope_to_model + + projects_query = Project.query.filter_by(status="active").order_by(Project.name) + scope_p = apply_project_scope_to_model(Project, current_user) + if scope_p is not None: + projects_query = projects_query.filter(scope_p) + active_projects = projects_query.all() + clients_query = Client.query.filter_by(status="active").order_by(Client.name) + scope_c = apply_client_scope_to_model(Client, current_user) + if scope_c is not None: + clients_query = clients_query.filter(scope_c) + active_clients = clients_query.all() only_one_client = len(active_clients) == 1 single_client = active_clients[0] if only_one_client else None # Get user statistics and dashboard aggregations via analytics service from app.services import AnalyticsService - from app.utils.overtime import calculate_period_overtime, get_week_start_for_date + from app.utils.overtime import calculate_period_overtime, get_week_start_for_date, get_overtime_ytd analytics_service = AnalyticsService() stats = analytics_service.get_dashboard_stats(user_id=current_user.id) @@ -64,6 +70,7 @@ def dashboard(): week_start_dt = get_week_start_for_date(today_dt, current_user) today_overtime = calculate_period_overtime(current_user, today_dt, today_dt) week_overtime = calculate_period_overtime(current_user, week_start_dt, today_dt) + overtime_ytd = get_overtime_ytd(current_user) standard_hours_per_day = float(getattr(current_user, "standard_hours_per_day", 8.0) or 8.0) # Top projects (last 30 days) and time-by-project chart (last 7 days) from service @@ -144,7 +151,7 @@ def dashboard(): # Post-timer toast data (show "Logged Xh on Project" + link to time entries) timer_stopped_toast = session.pop("timer_stopped_toast", None) if timer_stopped_toast: - timer_stopped_toast["time_entries_url"] = url_for("timer.time_entries") + timer_stopped_toast["time_entries_url"] = url_for("timer.time_entries_overview") # Get user stats for smart banner and donation widget try: @@ -181,6 +188,8 @@ def dashboard(): "today_overtime_hours": today_overtime["overtime_hours"], "week_regular_hours": week_overtime["regular_hours"], "week_overtime_hours": week_overtime["overtime_hours"], + "overtime_ytd_hours": overtime_ytd["overtime_hours"], + "overtime_ytd_regular": overtime_ytd["regular_hours"], "top_projects": top_projects, "time_by_project_7d": time_by_project_7d, "chart_labels_7d": chart_labels_7d, diff --git a/app/routes/mileage.py b/app/routes/mileage.py index 1f418fa2..53561c24 100644 --- a/app/routes/mileage.py +++ b/app/routes/mileage.py @@ -142,6 +142,175 @@ def list_mileage(): ) +def _mileage_export_query(): + """Build the same filtered query as list_mileage (no pagination). Caller must apply .all().""" + from app.utils.client_lock import enforce_locked_client_id + from sqlalchemy.orm import joinedload + + status = request.args.get("status", "").strip() + project_id = request.args.get("project_id", type=int) + client_id = request.args.get("client_id", type=int) + client_id = enforce_locked_client_id(client_id) + start_date = request.args.get("start_date", "").strip() + end_date = request.args.get("end_date", "").strip() + search = request.args.get("search", "").strip() + + query = Mileage.query.options( + joinedload(Mileage.user), + joinedload(Mileage.project), + joinedload(Mileage.client), + ) + + if not current_user.is_admin: + query = query.filter(db.or_(Mileage.user_id == current_user.id, Mileage.approved_by == current_user.id)) + + if status: + query = query.filter(Mileage.status == status) + if project_id: + query = query.filter(Mileage.project_id == project_id) + if client_id: + query = query.filter(Mileage.client_id == client_id) + if start_date: + try: + start = datetime.strptime(start_date, "%Y-%m-%d").date() + query = query.filter(Mileage.trip_date >= start) + except ValueError: + pass + if end_date: + try: + end = datetime.strptime(end_date, "%Y-%m-%d").date() + query = query.filter(Mileage.trip_date <= end) + except ValueError: + pass + if search: + like = f"%{search}%" + query = query.filter( + db.or_( + Mileage.purpose.ilike(like), + Mileage.description.ilike(like), + Mileage.start_location.ilike(like), + Mileage.end_location.ilike(like), + ) + ) + + return query.order_by(Mileage.trip_date.desc()) + + +@mileage_bp.route("/mileage/export/csv") +@login_required +@module_enabled("mileage") +def export_mileage_csv(): + """Export (filtered) mileage entries as CSV. Uses same filters as list_mileage.""" + query = _mileage_export_query() + entries = query.all() + + settings = Settings.get_settings() + delimiter = getattr(settings, "export_delimiter", ",") or "," + output = io.StringIO() + writer = csv.writer(output, delimiter=delimiter) + + writer.writerow( + [ + "ID", + "Date", + "User", + "Purpose", + "Start Location", + "End Location", + "Distance (km)", + "Rate per km", + "Amount", + "Round Trip", + "Status", + "Project", + "Client", + "Notes", + ] + ) + + for entry in entries: + multiplier = 2 if entry.is_round_trip else 1 + amount = float(entry.calculated_amount or 0) * multiplier + writer.writerow( + [ + entry.id, + entry.trip_date.isoformat() if entry.trip_date else "", + (entry.user.display_name if entry.user else ""), + entry.purpose or "", + entry.start_location or "", + entry.end_location or "", + float(entry.distance_km or 0), + float(entry.rate_per_km or 0), + amount, + "Yes" if entry.is_round_trip else "No", + entry.status or "", + (entry.project.name if entry.project else ""), + (entry.client.name if entry.client else ""), + entry.notes or "", + ] + ) + + csv_bytes = output.getvalue().encode("utf-8") + start_part = request.args.get("start_date", "") or "all" + end_part = request.args.get("end_date", "") or "all" + filename = f"mileage_export_{start_part}_to_{end_part}.csv" + + return send_file( + io.BytesIO(csv_bytes), + mimetype="text/csv", + as_attachment=True, + download_name=filename, + ) + + +@mileage_bp.route("/mileage/export/pdf") +@login_required +@module_enabled("mileage") +def export_mileage_pdf(): + """Export (filtered) mileage entries as PDF. Uses same filters as list_mileage.""" + query = _mileage_export_query() + entries = query.all() + + start_date = request.args.get("start_date", "").strip() or None + end_date = request.args.get("end_date", "").strip() or None + + pdf_filters = {} + if request.args.get("status"): + pdf_filters["Status"] = request.args.get("status") + if request.args.get("project_id", type=int): + proj = Project.query.get(request.args.get("project_id", type=int)) + if proj: + pdf_filters["Project"] = proj.name + if request.args.get("client_id", type=int): + cli = Client.query.get(request.args.get("client_id", type=int)) + if cli: + pdf_filters["Client"] = cli.name + + try: + from app.utils.mileage_pdf import build_mileage_pdf + pdf_bytes = build_mileage_pdf( + entries, + start_date=start_date, + end_date=end_date, + filters=pdf_filters if pdf_filters else None, + ) + except Exception as e: + current_app.logger.warning("Mileage PDF export failed: %s", e, exc_info=True) + flash(_("PDF export failed: %(error)s", error=str(e)), "error") + return redirect(url_for("mileage.list_mileage")) + + start_part = start_date or "all" + end_part = end_date or "all" + filename = f"mileage_export_{start_part}_to_{end_part}.pdf" + + return send_file( + io.BytesIO(pdf_bytes), + mimetype="application/pdf", + as_attachment=True, + download_name=filename, + ) + + @mileage_bp.route("/mileage/create", methods=["GET", "POST"]) @login_required @module_enabled("mileage") diff --git a/app/routes/per_diem.py b/app/routes/per_diem.py index 9ec27e25..38ac1c80 100644 --- a/app/routes/per_diem.py +++ b/app/routes/per_diem.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, current_app from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db, log_event, track_event @@ -9,6 +9,8 @@ from app.utils.db import safe_commit from app.utils.permissions import admin_or_permission_required from app.utils.module_helpers import module_enabled +import csv +import io per_diem_bp = Blueprint("per_diem", __name__) @@ -19,6 +21,7 @@ def list_per_diem(): """List all per diem claims with filters""" from app import track_page_view + from app.utils.client_lock import enforce_locked_client_id track_page_view("per_diem_list") @@ -29,6 +32,7 @@ def list_per_diem(): status = request.args.get("status", "").strip() project_id = request.args.get("project_id", type=int) client_id = request.args.get("client_id", type=int) + client_id = enforce_locked_client_id(client_id) start_date = request.args.get("start_date", "").strip() end_date = request.args.get("end_date", "").strip() @@ -71,6 +75,8 @@ def list_per_diem(): # Get filter options projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.get_active_clients() + only_one_client = len(clients) == 1 + single_client = clients[0] if only_one_client else None # Calculate totals total_amount_query = db.session.query(db.func.sum(PerDiem.calculated_amount)).filter( @@ -91,6 +97,8 @@ def list_per_diem(): pagination=per_diem_pagination, projects=projects, clients=clients, + only_one_client=only_one_client, + single_client=single_client, total_amount=float(total_amount), currency=currency, status=status, @@ -101,6 +109,162 @@ def list_per_diem(): ) +def _per_diem_export_query(): + """Build the same filtered query as list_per_diem (no pagination).""" + from sqlalchemy.orm import joinedload + from app.utils.client_lock import enforce_locked_client_id + + status = request.args.get("status", "").strip() + project_id = request.args.get("project_id", type=int) + client_id = request.args.get("client_id", type=int) + client_id = enforce_locked_client_id(client_id) + start_date = request.args.get("start_date", "").strip() + end_date = request.args.get("end_date", "").strip() + + query = PerDiem.query.options( + joinedload(PerDiem.user), + joinedload(PerDiem.project), + joinedload(PerDiem.client), + ) + + if not current_user.is_admin: + query = query.filter(db.or_(PerDiem.user_id == current_user.id, PerDiem.approved_by == current_user.id)) + + if status: + query = query.filter(PerDiem.status == status) + if project_id: + query = query.filter(PerDiem.project_id == project_id) + if client_id: + query = query.filter(PerDiem.client_id == client_id) + if start_date: + try: + start = datetime.strptime(start_date, "%Y-%m-%d").date() + query = query.filter(PerDiem.start_date >= start) + except ValueError: + pass + if end_date: + try: + end = datetime.strptime(end_date, "%Y-%m-%d").date() + query = query.filter(PerDiem.end_date <= end) + except ValueError: + pass + + return query.order_by(PerDiem.start_date.desc()) + + +@per_diem_bp.route("/per-diem/export/csv") +@login_required +@module_enabled("per_diem") +def export_per_diem_csv(): + """Export (filtered) per diem claims as CSV. Uses same filters as list_per_diem.""" + query = _per_diem_export_query() + entries = query.all() + + settings = Settings.get_settings() + delimiter = getattr(settings, "export_delimiter", ",") or "," + output = io.StringIO() + writer = csv.writer(output, delimiter=delimiter) + + writer.writerow( + [ + "ID", + "User", + "Trip Purpose", + "Start Date", + "End Date", + "Country", + "City", + "Full Days", + "Half Days", + "Amount", + "Status", + "Project", + "Client", + "Notes", + ] + ) + + for entry in entries: + writer.writerow( + [ + entry.id, + (entry.user.display_name if entry.user else ""), + entry.trip_purpose or "", + entry.start_date.isoformat() if entry.start_date else "", + entry.end_date.isoformat() if entry.end_date else "", + entry.country or "", + entry.city or "", + entry.full_days or 0, + entry.half_days or 0, + float(entry.calculated_amount or 0), + entry.status or "", + (entry.project.name if entry.project else ""), + (entry.client.name if entry.client else ""), + entry.notes or "", + ] + ) + + csv_bytes = output.getvalue().encode("utf-8") + start_part = request.args.get("start_date", "") or "all" + end_part = request.args.get("end_date", "") or "all" + filename = f"per_diem_export_{start_part}_to_{end_part}.csv" + + return send_file( + io.BytesIO(csv_bytes), + mimetype="text/csv", + as_attachment=True, + download_name=filename, + ) + + +@per_diem_bp.route("/per-diem/export/pdf") +@login_required +@module_enabled("per_diem") +def export_per_diem_pdf(): + """Export (filtered) per diem claims as PDF. Uses same filters as list_per_diem.""" + query = _per_diem_export_query() + entries = query.all() + + start_date = request.args.get("start_date", "").strip() or None + end_date = request.args.get("end_date", "").strip() or None + + pdf_filters = {} + if request.args.get("status"): + pdf_filters["Status"] = request.args.get("status") + if request.args.get("project_id", type=int): + proj = Project.query.get(request.args.get("project_id", type=int)) + if proj: + pdf_filters["Project"] = proj.name + if request.args.get("client_id", type=int): + cli = Client.query.get(request.args.get("client_id", type=int)) + if cli: + pdf_filters["Client"] = cli.name + + try: + from app.utils.per_diem_pdf import build_per_diem_pdf + pdf_bytes = build_per_diem_pdf( + entries, + start_date=start_date, + end_date=end_date, + filters=pdf_filters if pdf_filters else None, + ) + except Exception as e: + current_app.logger.warning("Per diem PDF export failed: %s", e, exc_info=True) + flash(_("PDF export failed: %(error)s", error=str(e)), "error") + return redirect(url_for("per_diem.list_per_diem")) + + start_part = start_date or "all" + end_part = end_date or "all" + filename = f"per_diem_export_{start_part}_to_{end_part}.pdf" + + return send_file( + io.BytesIO(pdf_bytes), + mimetype="application/pdf", + as_attachment=True, + download_name=filename, + ) + + @per_diem_bp.route("/per-diem/create", methods=["GET", "POST"]) @login_required @module_enabled("per_diem") diff --git a/app/routes/timer.py b/app/routes/timer.py index 45d7fa89..48575064 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -13,6 +13,7 @@ from sqlalchemy.exc import ProgrammingError from app.services.project_service import ProjectService from app.services.client_service import ClientService +from app.utils.scope_filter import user_can_access_project, user_can_access_client _project_service = ProjectService() _client_service = ClientService() @@ -128,6 +129,16 @@ def start_timer(): ) return redirect(url_for("main.dashboard")) + # Subcontractor scope: only allow starting timer on assigned project/client + if project_id and not user_can_access_project(current_user, project_id): + flash(_("You do not have access to this project"), "error") + current_app.logger.warning("Start timer denied: user has no access to project_id=%s", project_id) + return redirect(url_for("main.dashboard")) + if client_id and not project_id and not user_can_access_client(current_user, client_id): + flash(_("You do not have access to this client"), "error") + current_app.logger.warning("Start timer denied: user has no access to client_id=%s", client_id) + return redirect(url_for("main.dashboard")) + # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: @@ -300,6 +311,13 @@ def start_timer_from_template(template_id): flash(_("Cannot start timer for this project"), "error") return redirect(url_for("time_entry_templates.list_templates")) + if not user_can_access_project(current_user, template.project_id): + flash(_("You do not have access to this project"), "error") + current_app.logger.warning( + "Start timer from template denied: user has no access to project_id=%s", template.project_id + ) + return redirect(url_for("time_entry_templates.list_templates")) + # Create new timer from template from app.models.time_entry import local_now @@ -376,6 +394,11 @@ def start_timer_for_project(project_id): current_app.logger.warning("Start timer (GET) failed: project_id=%s is not active", project_id) return redirect(url_for("main.dashboard")) + if not user_can_access_project(current_user, project_id): + flash(_("You do not have access to this project"), "error") + current_app.logger.warning("Start timer (GET) denied: user has no access to project_id=%s", project_id) + return redirect(url_for("main.dashboard")) + # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: @@ -544,6 +567,56 @@ def stop_timer(): return redirect(url_for("main.dashboard")) +@timer_bp.route("/timer/pause", methods=["POST"]) +@login_required +def pause_timer(): + """Pause the current user's active timer (clock stops; break accumulates on resume).""" + active_timer = current_user.active_timer + if not active_timer: + flash(_("No active timer to pause"), "error") + return redirect(url_for("main.dashboard")) + try: + active_timer.pause_timer() + flash(_("Timer paused"), "success") + except ValueError as e: + flash(_(str(e)), "error") + except Exception as e: + current_app.logger.exception("Error pausing timer: %s", e) + flash(_("Could not pause timer. Please try again."), "error") + try: + from app.utils.cache import get_cache + cache = get_cache() + cache.delete(f"dashboard:{current_user.id}") + except Exception: + pass + return redirect(url_for("main.dashboard")) + + +@timer_bp.route("/timer/resume", methods=["POST"]) +@login_required +def resume_timer(): + """Resume a paused timer (time since pause is counted as break).""" + active_timer = current_user.active_timer + if not active_timer: + flash(_("No active timer to resume"), "error") + return redirect(url_for("main.dashboard")) + try: + active_timer.resume_timer() + flash(_("Timer resumed"), "success") + except ValueError as e: + flash(_(str(e)), "error") + except Exception as e: + current_app.logger.exception("Error resuming timer: %s", e) + flash(_("Could not resume timer. Please try again."), "error") + try: + from app.utils.cache import get_cache + cache = get_cache() + cache.delete(f"dashboard:{current_user.id}") + except Exception: + pass + return redirect(url_for("main.dashboard")) + + @timer_bp.route("/timer/adjust", methods=["POST"]) @login_required def adjust_timer(): @@ -606,6 +679,10 @@ def timer_status(): "start_time": active_timer.start_time.isoformat(), "current_duration": active_timer.current_duration_seconds, "duration_formatted": active_timer.duration_formatted, + "paused": getattr(active_timer, "is_paused", False), + "paused_at": active_timer.paused_at.isoformat() if active_timer.paused_at else None, + "break_seconds": getattr(active_timer, "break_seconds", None) or 0, + "break_formatted": getattr(active_timer, "break_formatted", "00:00:00"), }, } ) @@ -701,6 +778,7 @@ def edit_timer(timer_id): start_time = request.form.get("start_time") end_date = request.form.get("end_date") end_time = request.form.get("end_time") + break_time = (request.form.get("break_time") or "").strip() if start_date and start_time: try: @@ -762,6 +840,14 @@ def edit_timer(timer_id): else: update_params["end_time"] = None + # Parse break time (HH:MM) to seconds; empty clears break + import re + if break_time: + m = re.match(r"^(\d{1,3}):([0-5]\d)$", break_time.strip()) + update_params["break_seconds"] = (int(m.group(1)) * 3600 + int(m.group(2)) * 60) if m else 0 + else: + update_params["break_seconds"] = 0 + # Call service layer to update result = service.update_entry(**update_params) @@ -1151,6 +1237,7 @@ def manual_entry(): end_time = request.form.get("end_time") worked_time = (request.form.get("worked_time") or "").strip() worked_time_mode = (request.form.get("worked_time_mode") or "").strip() # 'explicit' when user typed duration + break_time = (request.form.get("break_time") or "").strip() notes = sanitize_input(request.form.get("notes", "").strip(), max_length=2000) tags = sanitize_input(request.form.get("tags", "").strip(), max_length=500) billable = request.form.get("billable") == "on" @@ -1169,6 +1256,8 @@ def _parse_worked_time_minutes(raw: str): return total if total > 0 else None worked_minutes = _parse_worked_time_minutes(worked_time) + break_minutes = _parse_worked_time_minutes(break_time) + break_seconds = (break_minutes * 60) if break_minutes is not None else None has_all_times = bool(start_date and start_time and end_date and end_time) has_duration = worked_minutes is not None @@ -1195,6 +1284,7 @@ def _parse_worked_time_minutes(raw: str): prefill_end_time=end_time, prefill_worked_time=worked_time, prefill_worked_time_mode=worked_time_mode, + prefill_break_time=break_time, ) # Validate that either project or client is selected @@ -1217,6 +1307,9 @@ def _parse_worked_time_minutes(raw: str): prefill_start_time=start_time, prefill_end_date=end_date, prefill_end_time=end_time, + prefill_worked_time=worked_time, + prefill_worked_time_mode=worked_time_mode, + prefill_break_time=break_time, ) # If a locked client is configured, ensure selected project matches it. @@ -1232,6 +1325,7 @@ def _parse_worked_time_minutes(raw: str): # Parse datetime: treat form input as user's local time, store in app timezone. # If duration + start date/time are provided: end = start + duration. # If duration only (no start): end=now, start=end-duration. + # Break is subtracted from span to get worked duration. from datetime import timedelta try: if has_all_times: @@ -1239,6 +1333,8 @@ def _parse_worked_time_minutes(raw: str): end_time_parsed = parse_user_local_datetime(end_date, end_time, current_user) if worked_time_mode == "explicit" and has_duration: duration_seconds_override = worked_minutes * 60 + # When we have start/end and break, we pass break_seconds and do not override duration; + # calculate_duration() will compute (end - start) - break_seconds elif has_duration and start_date and start_time: # Combined: worked time + start date/time (user can set date and duration) start_time_parsed = parse_user_local_datetime(start_date, start_time, current_user) @@ -1271,8 +1367,13 @@ def _parse_worked_time_minutes(raw: str): prefill_end_time=end_time, prefill_worked_time=worked_time, prefill_worked_time_mode=worked_time_mode, + prefill_break_time=break_time, ) + # When user entered both duration override and break, net duration = duration - break + if duration_seconds_override is not None and break_seconds is not None: + duration_seconds_override = max(0, duration_seconds_override - break_seconds) + # Validate time range if end_time_parsed <= start_time_parsed: flash(_("End time must be after start time"), "error") @@ -1293,6 +1394,9 @@ def _parse_worked_time_minutes(raw: str): prefill_start_time=start_time, prefill_end_date=end_date, prefill_end_time=end_time, + prefill_worked_time=worked_time, + prefill_worked_time_mode=worked_time_mode, + prefill_break_time=break_time, ) # Use service to create entry (handles validation) @@ -1304,6 +1408,7 @@ def _parse_worked_time_minutes(raw: str): start_time=start_time_parsed, end_time=end_time_parsed, duration_seconds=duration_seconds_override, + break_seconds=break_seconds, task_id=task_id, notes=notes if notes else None, tags=tags if tags else None, @@ -1331,6 +1436,7 @@ def _parse_worked_time_minutes(raw: str): prefill_end_time=end_time, prefill_worked_time=worked_time, prefill_worked_time_mode=worked_time_mode, + prefill_break_time=break_time, ) entry = result.get("entry") @@ -1832,6 +1938,8 @@ def duplicate_timer(timer_id): ) # Render the manual entry form with pre-filled data + break_sec = getattr(timer, "break_seconds", None) or 0 + prefill_break = f"{break_sec // 3600}:{(break_sec % 3600) // 60:02d}" if break_sec else "" return render_template( "timer/manual_entry.html", projects=active_projects, @@ -1844,14 +1952,15 @@ def duplicate_timer(timer_id): prefill_notes=timer.notes, prefill_tags=timer.tags, prefill_billable=timer.billable, + prefill_break_time=prefill_break, is_duplicate=True, original_entry=timer, ) -@timer_bp.route("/timer/resume/") +@timer_bp.route("/timer/resume/", endpoint="resume_timer_by_id") @login_required -def resume_timer(timer_id): +def resume_timer_by_id(timer_id): """Resume an existing time entry - starts a new active timer with same properties""" timer = TimeEntry.query.get_or_404(timer_id) diff --git a/app/routes/workforce.py b/app/routes/workforce.py index 14fbf662..cf66ee58 100644 --- a/app/routes/workforce.py +++ b/app/routes/workforce.py @@ -1,4 +1,4 @@ -from datetime import datetime, date, timedelta +from datetime import datetime, date, timedelta from flask import Blueprint, render_template, request, redirect, url_for, flash, Response from flask_login import login_required, current_user @@ -71,12 +71,22 @@ def dashboard(): balances = service.get_leave_balance(selected_user_id) + from app.models import User + from app.utils.overtime import get_overtime_ytd + users = [] if current_user.is_admin: - from app.models import User - users = User.query.order_by(User.username.asc()).all() + # Accumulated overtime (YTD) for selected user and overtime leave type for "Take as paid leave" + selected_user = User.query.get(selected_user_id) + overtime_ytd_hours = 0.0 + overtime_leave_type = service.get_overtime_leave_type() + overtime_leave_type_id = overtime_leave_type.id if overtime_leave_type else None + if selected_user: + overtime_ytd = get_overtime_ytd(selected_user) + overtime_ytd_hours = float(overtime_ytd.get("overtime_hours", 0) or 0) + return render_template( "workforce/dashboard.html", periods=periods, @@ -91,6 +101,8 @@ def dashboard(): capacity=capacity, cap_start=cap_start, cap_end=cap_end, + overtime_ytd_hours=overtime_ytd_hours, + overtime_leave_type_id=overtime_leave_type_id, ) @@ -160,6 +172,14 @@ def close_period(period_id): return redirect(url_for("workforce.dashboard")) +@workforce_bp.route("/workforce/periods//delete", methods=["POST"]) +@login_required +def delete_period(period_id): + result = WorkforceGovernanceService().delete_period(period_id=period_id, actor_id=current_user.id) + flash(_(result.get("message", "Period deleted")) if result.get("success") else _(result.get("message", "Could not delete period")), "success" if result.get("success") else "error") + return redirect(url_for("workforce.dashboard")) + + @workforce_bp.route("/workforce/policy", methods=["POST"]) @login_required def update_policy(): @@ -210,6 +230,17 @@ def create_leave_type(): return redirect(url_for("workforce.dashboard")) +@workforce_bp.route("/workforce/leave-types//delete", methods=["POST"]) +@login_required +def delete_leave_type(leave_type_id): + if not current_user.is_admin: + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + result = WorkforceGovernanceService().delete_leave_type(leave_type_id) + flash(_(result.get("message", "Leave type deleted")) if result.get("success") else _(result.get("message", "Could not delete leave type")), "success" if result.get("success") else "error") + return redirect(url_for("workforce.dashboard")) + + @workforce_bp.route("/workforce/time-off/request", methods=["POST"]) @login_required def create_time_off_request(): @@ -272,6 +303,18 @@ def reject_time_off_request(request_id): return redirect(url_for("workforce.dashboard")) +@workforce_bp.route("/workforce/time-off//delete", methods=["POST"]) +@login_required +def delete_time_off_request(request_id): + result = WorkforceGovernanceService().delete_leave_request( + request_id=request_id, + actor_id=current_user.id, + actor_can_approve=_can_approve(), + ) + flash(_(result.get("message", "Time-off request deleted")) if result.get("success") else _(result.get("message", "Could not delete request")), "success" if result.get("success") else "error") + return redirect(url_for("workforce.dashboard")) + + @workforce_bp.route("/workforce/holidays/create", methods=["POST"]) @login_required def create_holiday(): @@ -293,6 +336,17 @@ def create_holiday(): return redirect(url_for("workforce.dashboard")) +@workforce_bp.route("/workforce/holidays//delete", methods=["POST"]) +@login_required +def delete_holiday(holiday_id): + if not current_user.is_admin: + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + result = WorkforceGovernanceService().delete_holiday(holiday_id) + flash(_(result.get("message", "Holiday deleted")) if result.get("success") else _(result.get("message", "Could not delete holiday")), "success" if result.get("success") else "error") + return redirect(url_for("workforce.dashboard")) + + @workforce_bp.route("/workforce/reports/payroll.csv", methods=["GET"]) @login_required def payroll_export_csv(): diff --git a/app/schemas/time_entry_schema.py b/app/schemas/time_entry_schema.py index e2a9a811..563358de 100644 --- a/app/schemas/time_entry_schema.py +++ b/app/schemas/time_entry_schema.py @@ -18,6 +18,7 @@ class TimeEntrySchema(Schema): start_time = fields.DateTime(required=True) end_time = fields.DateTime(allow_none=True) duration_seconds = fields.Int(allow_none=True) + break_seconds = fields.Int(allow_none=True) notes = fields.Str(allow_none=True) tags = fields.Str(allow_none=True) source = fields.Str(validate=validate.OneOf([s.value for s in TimeEntrySource])) @@ -42,6 +43,7 @@ class TimeEntryCreateSchema(Schema): task_id = fields.Int(allow_none=True) start_time = fields.DateTime(required=True) end_time = fields.DateTime(allow_none=True) + break_seconds = fields.Int(allow_none=True) notes = fields.Str(allow_none=True, validate=validate.Length(max=5000)) tags = fields.Str(allow_none=True, validate=validate.Length(max=500)) billable = fields.Bool(missing=True) @@ -93,6 +95,7 @@ class TimeEntryUpdateSchema(Schema): task_id = fields.Int(allow_none=True) start_time = fields.DateTime(allow_none=True) end_time = fields.DateTime(allow_none=True) + break_seconds = fields.Int(allow_none=True) notes = fields.Str(allow_none=True, validate=validate.Length(max=5000)) tags = fields.Str(allow_none=True, validate=validate.Length(max=500)) billable = fields.Bool(allow_none=True) diff --git a/app/services/time_tracking_service.py b/app/services/time_tracking_service.py index 8be46c26..001d7f55 100644 --- a/app/services/time_tracking_service.py +++ b/app/services/time_tracking_service.py @@ -191,6 +191,36 @@ def stop_timer(self, user_id: int, entry_id: Optional[int] = None) -> Dict[str, return {"success": True, "message": "Timer stopped successfully", "entry": entry} + def pause_timer(self, user_id: int) -> Dict[str, Any]: + """Pause the active timer for a user. Clock stops; break accumulates on resume.""" + entry = self.time_entry_repo.get_active_timer(user_id) + if not entry: + return {"success": False, "message": "No active timer found", "error": "no_active_timer"} + if entry.user_id != user_id: + return {"success": False, "message": "You can only pause your own timer", "error": "unauthorized"} + try: + entry.pause_timer() + except ValueError as e: + return {"success": False, "message": str(e), "error": "invalid_state"} + if not safe_commit("pause_timer", {"user_id": user_id, "entry_id": entry.id}): + return {"success": False, "message": "Could not pause timer", "error": "database_error"} + return {"success": True, "message": "Timer paused", "entry": entry} + + def resume_timer(self, user_id: int) -> Dict[str, Any]: + """Resume a paused timer; time since pause is added to break_seconds.""" + entry = self.time_entry_repo.get_active_timer(user_id) + if not entry: + return {"success": False, "message": "No active timer found", "error": "no_active_timer"} + if entry.user_id != user_id: + return {"success": False, "message": "You can only resume your own timer", "error": "unauthorized"} + try: + entry.resume_timer() + except ValueError as e: + return {"success": False, "message": str(e), "error": "invalid_state"} + if not safe_commit("resume_timer", {"user_id": user_id, "entry_id": entry.id}): + return {"success": False, "message": "Could not resume timer", "error": "database_error"} + return {"success": True, "message": "Timer resumed", "entry": entry} + def create_manual_entry( self, user_id: int, @@ -199,6 +229,7 @@ def create_manual_entry( start_time: datetime = None, end_time: datetime = None, duration_seconds: Optional[int] = None, + break_seconds: Optional[int] = None, task_id: Optional[int] = None, notes: Optional[str] = None, tags: Optional[str] = None, @@ -293,7 +324,9 @@ def create_manual_entry( if duration_seconds <= 0: return {"success": False, "message": "Duration must be positive", "error": "invalid_duration"} - # Create entry + # Create entry (duration_seconds is net; break_seconds is stored and subtracted when computing from start/end) + if break_seconds is not None: + break_seconds = max(0, int(break_seconds)) entry = self.time_entry_repo.create_manual_entry( user_id=user_id, project_id=project_id, @@ -301,6 +334,7 @@ def create_manual_entry( start_time=start_time, end_time=end_time, duration_seconds=duration_seconds, + break_seconds=break_seconds, task_id=task_id, notes=notes, tags=tags, @@ -365,6 +399,7 @@ def update_entry( task_id: Optional[int] = None, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, + break_seconds: Optional[int] = None, notes: Optional[str] = None, tags: Optional[str] = None, billable: Optional[bool] = None, @@ -460,8 +495,10 @@ def update_entry( entry.start_time = start_time if end_time is not None: entry.end_time = end_time - # Recompute stored duration when start or end time changed - if entry.end_time and (start_time is not None or end_time is not None): + if break_seconds is not None: + entry.break_seconds = max(0, int(break_seconds)) + # Recompute stored duration when start, end, or break changed + if entry.end_time and (start_time is not None or end_time is not None or break_seconds is not None): entry.calculate_duration() if notes is not None: entry.notes = notes diff --git a/app/services/workforce_governance_service.py b/app/services/workforce_governance_service.py index 2d0e3f9d..98f209cd 100644 --- a/app/services/workforce_governance_service.py +++ b/app/services/workforce_governance_service.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations from datetime import date, datetime, timedelta from decimal import Decimal @@ -179,6 +179,10 @@ def list_leave_types(self, enabled_only: bool = True) -> List[LeaveType]: q = q.filter(LeaveType.enabled.is_(True)) return q.order_by(LeaveType.name.asc()).all() + def get_overtime_leave_type(self) -> Optional[LeaveType]: + """Return the leave type used for overtime-as-paid-leave (code 'overtime'), if present.""" + return LeaveType.query.filter_by(code="overtime", enabled=True).first() + def create_leave_request( self, *, @@ -196,6 +200,22 @@ def create_leave_request( if end_date < start_date: return {"success": False, "message": "end_date must be after start_date"} + # When requesting overtime-as-leave, cap requested_hours at accumulated YTD overtime + if leave_type.code == "overtime" and requested_hours is not None and requested_hours > 0: + from app.utils.overtime import get_overtime_ytd + + user = User.query.get(user_id) + if user: + ytd = get_overtime_ytd(user) + ytd_overtime = float(ytd.get("overtime_hours", 0) or 0) + if float(requested_hours) > ytd_overtime: + return { + "success": False, + "message": f"Requested hours ({requested_hours}) exceed your accumulated overtime (YTD: {ytd_overtime:.2f}h). Please request at most {ytd_overtime:.2f} hours.", + } + else: + return {"success": False, "message": "User not found"} + status = TimeOffRequestStatus.SUBMITTED if submit_now else TimeOffRequestStatus.DRAFT req = TimeOffRequest( user_id=user_id, @@ -442,3 +462,63 @@ def payroll_rows( item["non_billable_hours"] = round(item["non_billable_hours"], 2) out.sort(key=lambda x: (x["week_year"], x["week_number"], x["username"] or "")) return out + + def delete_period(self, period_id: int, actor_id: int) -> Dict[str, Any]: + """Delete a timesheet period. Only draft or rejected periods; actor must be owner or admin.""" + period = TimesheetPeriod.query.get(period_id) + if not period: + return {"success": False, "message": "Timesheet period not found"} + user = User.query.get(actor_id) + if not user: + return {"success": False, "message": "User not found"} + if period.user_id != actor_id and not user.is_admin: + return {"success": False, "message": "Only the period owner or an admin can delete it"} + status = period.status.value if hasattr(period.status, "value") else str(period.status) + if status not in (TimesheetPeriodStatus.DRAFT.value, TimesheetPeriodStatus.REJECTED.value): + return {"success": False, "message": "Only draft or rejected periods can be deleted"} + db.session.delete(period) + db.session.commit() + return {"success": True} + + def delete_leave_request( + self, request_id: int, actor_id: int, actor_can_approve: bool = False + ) -> Dict[str, Any]: + """Delete a time-off request. Only draft, submitted, or cancelled; actor must be owner or approver.""" + req = TimeOffRequest.query.get(request_id) + if not req: + return {"success": False, "message": "Time-off request not found"} + if req.user_id != actor_id and not actor_can_approve: + return {"success": False, "message": "Only the request owner or an approver can delete it"} + status = req.status.value if hasattr(req.status, "value") else str(req.status) + if status not in ( + TimeOffRequestStatus.DRAFT.value, + TimeOffRequestStatus.SUBMITTED.value, + TimeOffRequestStatus.CANCELLED.value, + ): + return {"success": False, "message": "Only draft, submitted, or cancelled requests can be deleted"} + db.session.delete(req) + db.session.commit() + return {"success": True} + + def delete_leave_type(self, leave_type_id: int) -> Dict[str, Any]: + """Delete a leave type. Fails if any time-off request references it.""" + leave_type = LeaveType.query.get(leave_type_id) + if not leave_type: + return {"success": False, "message": "Leave type not found"} + if leave_type.requests.count() > 0: + return { + "success": False, + "message": "Cannot delete leave type that has time-off requests", + } + db.session.delete(leave_type) + db.session.commit() + return {"success": True} + + def delete_holiday(self, holiday_id: int) -> Dict[str, Any]: + """Delete a company holiday.""" + holiday = CompanyHoliday.query.get(holiday_id) + if not holiday: + return {"success": False, "message": "Holiday not found"} + db.session.delete(holiday) + db.session.commit() + return {"success": True} diff --git a/app/static/floating-timer-bar.js b/app/static/floating-timer-bar.js index d1270844..2c6c0706 100644 --- a/app/static/floating-timer-bar.js +++ b/app/static/floating-timer-bar.js @@ -53,16 +53,24 @@ startElapsedUpdater() { this.stopElapsedUpdater(); const update = () => { - if (!this.startTime || !this.bar) return; - const elapsed = Math.floor((Date.now() - this.startTime) / 1000); - const h = Math.floor(elapsed / 3600); - const m = Math.floor((elapsed % 3600) / 60); - const s = elapsed % 60; + if (!this.timerData || !this.bar) return; + let elapsedSec; + if (this.timerData.paused) { + elapsedSec = this.timerData.current_duration || 0; + } else { + elapsedSec = this.timerData.current_duration != null + ? this.timerData.current_duration + : (this.startTime ? Math.floor((Date.now() - this.startTime) / 1000) : 0); + } + const h = Math.floor(elapsedSec / 3600); + const m = Math.floor((elapsedSec % 3600) / 60); + const s = elapsedSec % 60; const formatted = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); const el = this.bar.querySelector('[data-timer-elapsed]'); if (el) el.textContent = formatted; const btn = this.bar.querySelector('button'); - if (btn) btn.title = (this.getLabel() || 'Timer') + ' – ' + formatted + ' – ' + (this.stopLabel || 'Stop'); + const label = this.timerData.paused ? (this.bar.dataset.resumeLabel || 'Resume') : (this.stopLabel || 'Stop'); + if (btn) btn.title = (this.getLabel() || 'Timer') + (this.timerData.paused ? ' (Paused) – ' : ' – ') + formatted + ' – ' + label; }; update(); this.elapsedInterval = setInterval(update, 1000); @@ -86,8 +94,7 @@ } async stopTimer() { - const tokenEl = document.querySelector('meta[name="csrf-token"]'); - const token = tokenEl ? tokenEl.getAttribute('content') : ''; + const token = this.getCsrfToken(); try { const res = await fetch('/timer/stop', { method: 'POST', @@ -108,6 +115,33 @@ } } + async resumeTimer() { + const token = this.getCsrfToken(); + try { + const res = await fetch('/timer/resume', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': token }, + body: 'csrf_token=' + encodeURIComponent(token), + credentials: 'same-origin' + }); + if (res.redirected) { + window.location.href = res.url; + } else { + await this.fetchStatus(); + } + } catch (e) { + console.error('Resume timer failed', e); + if (window.toastManager) { + window.toastManager.error('Failed to resume timer', 'Error', 3000); + } + } + } + + getCsrfToken() { + const tokenEl = document.querySelector('meta[name="csrf-token"]'); + return tokenEl ? tokenEl.getAttribute('content') || '' : ''; + } + getLabel() { if (!this.timerData) return ''; return this.timerData.project_name || this.timerData.client_name || 'Timer'; @@ -117,15 +151,19 @@ if (!this.bar) return; const baseClass = 'floating-timer-bar__round flex items-center justify-center w-10 h-10 rounded-full text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 text-sm transition-colors'; + const actionLabel = this.timerData && this.timerData.paused ? (this.bar.dataset.resumeLabel || 'Resume') : (this.stopLabel || 'Stop'); const title = this.timerData - ? (escapeHtml(this.getLabel()) + ' – ' + (this.timerData.duration_formatted || '00:00:00') + ' – ' + escapeHtml(this.stopLabel)) + ? (escapeHtml(this.getLabel()) + (this.timerData.paused ? ' (Paused) – ' : ' – ') + (this.timerData.duration_formatted || '00:00:00') + ' – ' + escapeHtml(actionLabel)) : escapeHtml(this.startLabel); if (this.timerData) { + const isPaused = this.timerData.paused; + const pulseClass = isPaused ? 'bg-amber-500' : 'bg-green-500 animate-pulse'; + const clickHandler = isPaused ? 'window.floatingTimerBar.resumeTimer()' : 'window.floatingTimerBar.stopTimer()'; this.bar.innerHTML = ` - `; diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html index 4e60bdb5..d56205be 100644 --- a/app/templates/main/dashboard.html +++ b/app/templates/main/dashboard.html @@ -73,10 +73,22 @@

{{ _('Time
+ {% if active_timer.is_paused %} + + {{ _('Paused') }} + + {% if active_timer.break_seconds %} + {{ _('Break') }}: {{ active_timer.break_formatted }} + {% endif %} + {% else %} {{ _('Running') }} + {% if active_timer.break_seconds %} + {{ _('Break so far') }}: {{ active_timer.break_formatted }} + {% endif %} + {% endif %}

{% if active_timer.project %} @@ -93,6 +105,7 @@

{{ _('Time

{{ _('Elapsed') }}: {{ active_timer.duration_formatted }}

+ {% if not active_timer.is_paused %}
{{ _('Adjust time') }}:
@@ -104,8 +117,24 @@

{{ _('Time

+ {% endif %}

+ {% if active_timer.is_paused %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %}
+ {% if overtime_ytd_hours is defined %} +
+ {{ _('Overtime (YTD)') }}: {{ "%.2f"|format(overtime_ytd_hours) }}h +
+ {% endif %}
@@ -248,7 +282,7 @@

{{ _('Rece {{ entry.start_time|user_datetime }}
- + diff --git a/app/templates/main/search.html b/app/templates/main/search.html index ae01ac93..bf0e6df3 100644 --- a/app/templates/main/search.html +++ b/app/templates/main/search.html @@ -62,7 +62,7 @@
{{ _('Results') }}
{{ entry.tags or '-' }}
- diff --git a/app/templates/mileage/list.html b/app/templates/mileage/list.html index a7fd5e2e..a3ed4553 100644 --- a/app/templates/mileage/list.html +++ b/app/templates/mileage/list.html @@ -55,11 +55,20 @@
-
+

{{ _('Filter Mileage') }}

- +
+ {{ _('Exports use current filters') }} + + {{ _('Export CSV') }} + + + {{ _('Export PDF') }} + + +
@@ -250,6 +259,57 @@

.filter-toggle-transition { transition: all 0.3s ease-in-out; } + + {% endblock %} diff --git a/app/templates/timer/manual_entry.html b/app/templates/timer/manual_entry.html index ac4a9e5e..b375f017 100644 --- a/app/templates/timer/manual_entry.html +++ b/app/templates/timer/manual_entry.html @@ -42,7 +42,11 @@ data-require-task="{{ 'true' if getattr(settings, 'time_entry_require_task', false) else 'false' }}" data-require-description="{{ 'true' if getattr(settings, 'time_entry_require_description', false) else 'false' }}" data-description-min-length="{{ getattr(settings, 'time_entry_description_min_length', 20) }}" - data-description-min-msg="{{ _('Description must be at least %(min)s characters', min=getattr(settings, 'time_entry_description_min_length', 20))|e }}"> + data-description-min-msg="{{ _('Description must be at least %(min)s characters', min=getattr(settings, 'time_entry_description_min_length', 20))|e }}" + data-break-after-hours-1="{{ getattr(settings, 'break_after_hours_1', none) or 6 }}" + data-break-minutes-1="{{ getattr(settings, 'break_minutes_1', none) or 30 }}" + data-break-after-hours-2="{{ getattr(settings, 'break_after_hours_2', none) or 9 }}" + data-break-minutes-2="{{ getattr(settings, 'break_minutes_2', none) or 45 }}"> @@ -114,6 +118,14 @@

{{ _('Optional: enter HH:MM for duration. You can combine with Start Date/Time to log time on a specific day.') }}

+
+ +
+ + +
+

{{ _('Optional: break duration (HH:MM). Subtracted from total time to get worked duration.') }}

+
@@ -152,6 +164,22 @@

6h = 30 min, >9h = 45 min) + const suggestBreakBtn = document.getElementById('suggestBreakBtn'); + const breakTimeInput = document.getElementById('break_time'); + if (suggestBreakBtn && breakTimeInput && form) { + suggestBreakBtn.addEventListener('click', function() { + let durationHours = 0; + const workedMins = parseWorkedMinutes(workedTime && workedTime.value); + if (workedMins != null) { + durationHours = workedMins / 60; + } else { + const { start, end } = getStartEnd(); + if (start && end && end > start) { + durationHours = (end.getTime() - start.getTime()) / (60 * 60 * 1000); + } + } + const h1 = parseFloat(form.dataset.breakAfterHours1 || '6'); + const m1 = parseInt(form.dataset.breakMinutes1 || '30', 10); + const h2 = parseFloat(form.dataset.breakAfterHours2 || '9'); + const m2 = parseInt(form.dataset.breakMinutes2 || '45', 10); + let suggestedMinutes = 0; + if (durationHours >= h2) suggestedMinutes = m2; + else if (durationHours >= h1) suggestedMinutes = m1; + breakTimeInput.value = formatWorkedMinutes(suggestedMinutes); + }); + } + function onStartChange() { if (suppressTimeSync) return; // If worked time is valid, keep duration constant and move end; otherwise just recompute worked time. @@ -670,17 +724,27 @@

{{ _('Filters') }}

{{ _('Exports use current filters') }} + @@ -642,7 +645,36 @@

{{ _('Mark Selected Entries as Paid/Unpai } // Initialize export links from current form/URL (Issue #555: keep export in sync with filters) - try { updateExportLink(buildFilterUrl()); } catch (_) {} + try { + updateExportLink(buildFilterUrl()); + // Fallback: if page was loaded with query params, use them for export href (e.g. right-click Open in new tab) + if (window.location.search) { + const csvBtn = document.getElementById('exportCsvBtn'); + const pdfBtn = document.getElementById('exportPdfBtn'); + const base = csvBtn ? (csvBtn.getAttribute('data-export-base') || '/time-entries/export/csv') : '/time-entries/export/csv'; + const pdfBase = pdfBtn ? (pdfBtn.getAttribute('data-export-base') || '/time-entries/export/pdf') : '/time-entries/export/pdf'; + const qs = window.location.search.slice(1); + if (csvBtn) csvBtn.href = base + (qs ? '?' + qs : ''); + if (pdfBtn) pdfBtn.href = pdfBase + (qs ? '?' + qs : ''); + } + } catch (_) {} + + // Header "Apply filters" button (Issue #555: visible filter action) + const applyFiltersBtn = document.getElementById('applyFiltersBtn'); + if (applyFiltersBtn) { + applyFiltersBtn.addEventListener('click', function() { + const filterBody = document.getElementById('filterBody'); + const icon = document.getElementById('filterToggleIcon'); + if (filterBody && filterBody.classList.contains('hidden')) { + filterBody.classList.remove('hidden'); + if (icon) { icon.classList.remove('fa-chevron-down'); icon.classList.add('fa-chevron-up'); } + } + if (filterTimeout) clearTimeout(filterTimeout); + if (searchTimeout) clearTimeout(searchTimeout); + lastUrl = null; + applyFilters(); + }); + } // Export CSV/PDF: always use current form params on click (Issue #555: export respects date/filters) const exportCsvBtn = document.getElementById('exportCsvBtn'); @@ -672,6 +704,7 @@

{{ _('Mark Selected Entries as Paid/Unpai const t = e.target; if (!t || !(t instanceof Element)) return; if (t.matches('select') || t.matches('input[type="date"]') || t.matches('input[type="text"]')) { + updateExportLink(buildFilterUrl()); // Issue #555: export href reflects current form debouncedApplyFilters(100); } }); @@ -680,6 +713,7 @@

{{ _('Mark Selected Entries as Paid/Unpai const t = e.target; if (!t || !(t instanceof Element)) return; if (t.matches('input[name="search"]')) { + updateExportLink(buildFilterUrl()); // Issue #555: export href reflects current form debouncedSearch(500); } }); diff --git a/app/templates/timer/timer_page.html b/app/templates/timer/timer_page.html index 81b4cf27..9f4b0425 100644 --- a/app/templates/timer/timer_page.html +++ b/app/templates/timer/timer_page.html @@ -68,7 +68,22 @@

-
+
+ {% if active_timer.is_paused %} + + + + + {% else %} +
+ + +
+ {% endif %}
+ {% if active_timer.break_seconds %} +

{{ _('Break') }}: {{ active_timer.break_formatted }}

+ {% endif %}
diff --git a/app/templates/workforce/dashboard.html b/app/templates/workforce/dashboard.html index a1e367c8..b5d1e6f6 100644 --- a/app/templates/workforce/dashboard.html +++ b/app/templates/workforce/dashboard.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "base.html" %} {% block title %}{{ _('Workforce Governance') }}{% endblock %} {% block content %} @@ -87,6 +87,13 @@

{{ _('Timesheet Periods') }}

{% endif %} + {% set p_status_val = p.status.value if p.status is defined and p.status.value is defined else (p.status|string) %} + {% if p_status_val in ['draft', 'rejected'] and (p.user_id == current_user.id or current_user.is_admin) %} +
+ + +
+ {% endif %}
@@ -98,6 +105,14 @@

{{ _('Timesheet Periods') }}

{{ _('Leave Balances') }}

+ {% if overtime_ytd_hours is defined %} +

+ {{ _('Accumulated overtime (YTD)') }}: {{ "%.2f"|format(overtime_ytd_hours) }}h + {% if overtime_leave_type_id and overtime_ytd_hours > 0 %} + — {{ _('Take as paid leave') }} + {% endif %} +

+ {% endif %}
@@ -128,17 +143,22 @@

{{ _('Leave Balances') }}

{{ _('Time-Off Requests') }}

-
+ - {% for lt in leave_types %} {% if lt.enabled %} - + {% endif %} {% endfor %} - +
+ 0 %}data-overtime-max="{{ "%.2f"|format(overtime_ytd_hours) }}"{% endif %}> + {% if overtime_ytd_hours is defined and overtime_ytd_hours > 0 %} + + {% endif %} +
@@ -167,6 +187,12 @@

{{ _('Time-Off Requests') }}

{% endif %} + {% if r_status in ['draft', 'submitted', 'cancelled'] and (r.user_id == current_user.id or can_approve) %} + + + + + {% endif %} {% endfor %} @@ -232,6 +258,19 @@

{{ _('Leave Types') }}

+
+ {% for lt in leave_types %} +
+ {{ lt.name }} ({{ lt.code }}){% if not lt.enabled %} — {{ _('disabled') }}{% endif %} +
+ + + +
+ {% else %} +

{{ _('No leave types configured.') }}

+ {% endfor %} +
@@ -244,9 +283,15 @@

{{ _('Company Holidays') }}

-
+
{% for h in holidays %} -
{{ h.start_date }} - {{ h.end_date }}: {{ h.name }}{% if h.region %} ({{ h.region }}){% endif %}
+
+ {{ h.start_date }} - {{ h.end_date }}: {{ h.name }}{% if h.region %} ({{ h.region }}){% endif %} +
+ + + +
{% else %}
{{ _('No holidays configured.') }}
{% endfor %} @@ -255,4 +300,53 @@

{{ _('Company Holidays') }}

{% endif %}
+{% block scripts_extra %} + +{% endblock %} {% endblock %} diff --git a/app/utils/mileage_pdf.py b/app/utils/mileage_pdf.py new file mode 100644 index 00000000..b4813340 --- /dev/null +++ b/app/utils/mileage_pdf.py @@ -0,0 +1,262 @@ +""" +Mileage PDF export – professional report using ReportLab. +Same visual style as time_entries_pdf: header, table, totals, page numbers. +""" + +from io import BytesIO +from datetime import datetime + +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4, landscape +from reportlab.lib.units import cm +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.enums import TA_LEFT +from reportlab.platypus import ( + SimpleDocTemplate, + Table, + TableStyle, + Spacer, + Paragraph, +) + +# Reuse same palette as time_entries_pdf +BRAND_COLOR = colors.HexColor("#1e3a5f") +HEADER_BG = colors.HexColor("#1e3a5f") +HEADER_FG = colors.HexColor("#ffffff") +ROW_ALT_BG = colors.HexColor("#f0f4f8") +ROW_NORMAL_BG = colors.HexColor("#ffffff") +GRID_LIGHT = colors.HexColor("#dde3ea") +TOTALS_BG = colors.HexColor("#1e3a5f") +TOTALS_FG = colors.HexColor("#ffffff") +MUTED_TEXT = colors.HexColor("#64748b") + +FONT_SIZE = 9 +HEADER_FONT_SIZE = 10 +CELL_PAD_H = 6 +CELL_PAD_V = 5 +HEADER_PAD_V = 7 + +PAGE_SIZE = landscape(A4) +MARGIN = 1.0 * cm +BOTTOM_MARGIN = 1.2 * cm +USABLE_WIDTH_CM = 27.7 + +# 7 columns: Date, User, Purpose, Route, Distance, Amount, Status +COL_WIDTHS_CM = [2.2, 2.6, 4.0, 9.0, 2.2, 2.5, 2.2] +COL_WIDTHS = [w * cm for w in COL_WIDTHS_CM] + +NOTES_STYLE = ParagraphStyle( + "NotesCell", + fontName="Helvetica", + fontSize=FONT_SIZE, + leading=FONT_SIZE + 2, + alignment=TA_LEFT, + wordWrap="CJK", + splitLongWords=True, +) + + +def _safe_str(val, fallback=""): + if val is None: + return fallback + s = str(val).strip() + return s if s else fallback + + +def _make_cell_paragraph(text): + clean = _safe_str(text) + if not clean: + return "" + clean = clean.replace("&", "&").replace("<", "<").replace(">", ">") + return Paragraph(clean, NOTES_STYLE) + + +def _page_footer(canvas, doc): + canvas.saveState() + canvas.setFont("Helvetica", 7) + canvas.setFillColor(MUTED_TEXT) + page_num = canvas.getPageNumber() + canvas.drawRightString(doc.pagesize[0] - MARGIN, 0.5 * cm, f"Page {page_num}") + canvas.restoreState() + + +def _build_report_header(start_date=None, end_date=None, filters=None): + elements = [] + + title_style = ParagraphStyle( + "ReportTitle", + fontName="Helvetica-Bold", + fontSize=18, + leading=22, + textColor=BRAND_COLOR, + ) + elements.append(Paragraph("Mileage Report", title_style)) + elements.append(Spacer(1, 4)) + + accent = Table([[""]], colWidths=[USABLE_WIDTH_CM * cm], rowHeights=[2]) + accent.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), BRAND_COLOR), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("TOPPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 0), + ])) + elements.append(accent) + elements.append(Spacer(1, 8)) + + meta_style = ParagraphStyle( + "ReportMeta", + fontName="Helvetica", + fontSize=9, + leading=13, + textColor=MUTED_TEXT, + ) + + if start_date and end_date: + period = f"Period: {start_date} to {end_date}" + elif start_date: + period = f"From: {start_date}" + elif end_date: + period = f"Until: {end_date}" + else: + period = "Period: All dates" + + try: + from app.utils.timezone import get_user_datetime_format + gen_fmt = get_user_datetime_format() + except Exception: + gen_fmt = "%Y-%m-%d %H:%M" + generated = f"Generated: {datetime.now().strftime(gen_fmt)}" + + meta_left = Paragraph(period, meta_style) + meta_right_style = ParagraphStyle("ReportMetaRight", parent=meta_style, alignment=2) + meta_right = Paragraph(generated, meta_right_style) + meta_table = Table( + [[meta_left, meta_right]], + colWidths=[USABLE_WIDTH_CM * 0.6 * cm, USABLE_WIDTH_CM * 0.4 * cm], + ) + meta_table.setStyle(TableStyle([ + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("TOPPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 0), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ])) + elements.append(meta_table) + + if filters: + filter_parts = [f"{label}: {value}" for label, value in filters.items() if value] + if filter_parts: + filter_style = ParagraphStyle( + "FilterMeta", + fontName="Helvetica-Oblique", + fontSize=8, + leading=11, + textColor=MUTED_TEXT, + ) + elements.append(Spacer(1, 2)) + elements.append(Paragraph("Filters: " + " | ".join(filter_parts), filter_style)) + + elements.append(Spacer(1, 12)) + return elements + + +def build_mileage_pdf(entries, start_date=None, end_date=None, filters=None): + """ + Build a PDF report of mileage entries. + + Args: + entries: List of Mileage objects (with user, project, client loaded). + start_date: Optional start date string for the report header. + end_date: Optional end date string for the report header. + filters: Optional dict of active filter labels. + + Returns: + bytes: PDF file content. + """ + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=PAGE_SIZE, + leftMargin=MARGIN, + rightMargin=MARGIN, + topMargin=MARGIN, + bottomMargin=BOTTOM_MARGIN, + ) + + story = [] + story.extend(_build_report_header(start_date, end_date, filters)) + + headers = ["Date", "User", "Purpose", "Route", "Distance (km)", "Amount", "Status"] + + if not entries: + empty_style = ParagraphStyle( + "EmptyState", + fontName="Helvetica-Oblique", + fontSize=11, + leading=14, + textColor=MUTED_TEXT, + ) + story.append(Spacer(1, 20)) + story.append(Paragraph("No mileage entries found for the selected filters.", empty_style)) + else: + table_data = [headers] + total_km = 0 + total_amount = 0 + + for i, entry in enumerate(entries): + mult = 2 if entry.is_round_trip else 1 + dist_km = float(entry.distance_km or 0) + amount = float(entry.calculated_amount or 0) * mult + total_km += dist_km + total_amount += amount + + route = f"{_safe_str(entry.start_location)} → {_safe_str(entry.end_location)}" + route_cell = _make_cell_paragraph(route) if route.strip() else " " + + row = [ + entry.trip_date.strftime("%Y-%m-%d") if entry.trip_date else "", + _safe_str(entry.user.display_name if entry.user else ""), + _make_cell_paragraph(entry.purpose or "") or " ", + route_cell, + f"{dist_km:.2f}", + f"{amount:.2f}", + _safe_str(entry.status), + ] + table_data.append(row) + + table = Table(table_data, colWidths=COL_WIDTHS, repeatRows=1) + nrows = len(table_data) + + style = [ + ("BACKGROUND", (0, 0), (-1, 0), HEADER_BG), + ("TEXTCOLOR", (0, 0), (-1, 0), HEADER_FG), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), HEADER_FONT_SIZE), + ("BOTTOMPADDING", (0, 0), (-1, 0), HEADER_PAD_V), + ("TOPPADDING", (0, 0), (-1, 0), HEADER_PAD_V), + ("LEFTPADDING", (0, 0), (-1, -1), CELL_PAD_H), + ("RIGHTPADDING", (0, 0), (-1, -1), CELL_PAD_H), + ("TOPPADDING", (0, 0), (-1, -1), CELL_PAD_V), + ("BOTTOMPADDING", (0, 0), (-1, -1), CELL_PAD_V), + ("GRID", (0, 0), (-1, -1), 0.5, GRID_LIGHT), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ] + + for r in range(1, nrows): + bg = ROW_ALT_BG if r % 2 == 1 else ROW_NORMAL_BG + style.append(("BACKGROUND", (0, r), (-1, r), bg)) + + # Totals row + table_data.append(["", "", "Total", "", f"{total_km:.2f}", f"{total_amount:.2f}", ""]) + total_row_idx = len(table_data) - 1 + style.append(("BACKGROUND", (0, total_row_idx), (-1, total_row_idx), TOTALS_BG)) + style.append(("TEXTCOLOR", (0, total_row_idx), (-1, total_row_idx), TOTALS_FG)) + style.append(("FONTNAME", (0, total_row_idx), (-1, total_row_idx), "Helvetica-Bold")) + + table = Table(table_data, colWidths=COL_WIDTHS, repeatRows=1) + table.setStyle(TableStyle(style)) + story.append(table) + + doc.build(story, onFirstPage=_page_footer, onLaterPages=_page_footer) + return buffer.getvalue() diff --git a/app/utils/overtime.py b/app/utils/overtime.py index dca904b8..5ce3d563 100644 --- a/app/utils/overtime.py +++ b/app/utils/overtime.py @@ -358,6 +358,25 @@ def get_weekly_overtime_summary(user, weeks: int = 4) -> List[Dict]: return weekly_summary +def get_overtime_ytd(user) -> Dict[str, float]: + """ + Return overtime for the current year to date (Jan 1 through today). + Uses calculate_period_overtime; no stored balance. + """ + today = datetime.now().date() + start_ytd = date(today.year, 1, 1) + return calculate_period_overtime(user, start_ytd, today) + + +def get_overtime_last_12_months(user) -> Dict[str, float]: + """ + Return overtime for the last 12 months (rolling). + """ + today = datetime.now().date() + start = today - timedelta(days=365) + return calculate_period_overtime(user, start, today) + + def get_overtime_statistics(user, start_date: date, end_date: date) -> Dict: """ Get comprehensive overtime statistics for a period. diff --git a/app/utils/per_diem_pdf.py b/app/utils/per_diem_pdf.py new file mode 100644 index 00000000..fc6a1a99 --- /dev/null +++ b/app/utils/per_diem_pdf.py @@ -0,0 +1,255 @@ +""" +Per diem PDF export – professional report using ReportLab. +Same visual style as time_entries_pdf and mileage_pdf. +""" + +from io import BytesIO +from datetime import datetime + +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4, landscape +from reportlab.lib.units import cm +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.enums import TA_LEFT +from reportlab.platypus import ( + SimpleDocTemplate, + Table, + TableStyle, + Spacer, + Paragraph, +) + +BRAND_COLOR = colors.HexColor("#1e3a5f") +HEADER_BG = colors.HexColor("#1e3a5f") +HEADER_FG = colors.HexColor("#ffffff") +ROW_ALT_BG = colors.HexColor("#f0f4f8") +ROW_NORMAL_BG = colors.HexColor("#ffffff") +GRID_LIGHT = colors.HexColor("#dde3ea") +TOTALS_BG = colors.HexColor("#1e3a5f") +TOTALS_FG = colors.HexColor("#ffffff") +MUTED_TEXT = colors.HexColor("#64748b") + +FONT_SIZE = 9 +HEADER_FONT_SIZE = 10 +CELL_PAD_H = 6 +CELL_PAD_V = 5 +HEADER_PAD_V = 7 + +PAGE_SIZE = landscape(A4) +MARGIN = 1.0 * cm +BOTTOM_MARGIN = 1.2 * cm +USABLE_WIDTH_CM = 27.7 + +# 8 columns: Start Date, End Date, User, Trip Purpose, Location, Full/Half Days, Amount, Status +COL_WIDTHS_CM = [2.2, 2.2, 2.4, 5.0, 5.0, 2.4, 2.5, 2.0] +COL_WIDTHS = [w * cm for w in COL_WIDTHS_CM] + +NOTES_STYLE = ParagraphStyle( + "NotesCell", + fontName="Helvetica", + fontSize=FONT_SIZE, + leading=FONT_SIZE + 2, + alignment=TA_LEFT, + wordWrap="CJK", + splitLongWords=True, +) + + +def _safe_str(val, fallback=""): + if val is None: + return fallback + s = str(val).strip() + return s if s else fallback + + +def _make_cell_paragraph(text): + clean = _safe_str(text) + if not clean: + return "" + clean = clean.replace("&", "&").replace("<", "<").replace(">", ">") + return Paragraph(clean, NOTES_STYLE) + + +def _page_footer(canvas, doc): + canvas.saveState() + canvas.setFont("Helvetica", 7) + canvas.setFillColor(MUTED_TEXT) + page_num = canvas.getPageNumber() + canvas.drawRightString(doc.pagesize[0] - MARGIN, 0.5 * cm, f"Page {page_num}") + canvas.restoreState() + + +def _build_report_header(start_date=None, end_date=None, filters=None): + elements = [] + + title_style = ParagraphStyle( + "ReportTitle", + fontName="Helvetica-Bold", + fontSize=18, + leading=22, + textColor=BRAND_COLOR, + ) + elements.append(Paragraph("Per Diem Report", title_style)) + elements.append(Spacer(1, 4)) + + accent = Table([[""]], colWidths=[USABLE_WIDTH_CM * cm], rowHeights=[2]) + accent.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), BRAND_COLOR), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("TOPPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 0), + ])) + elements.append(accent) + elements.append(Spacer(1, 8)) + + meta_style = ParagraphStyle( + "ReportMeta", + fontName="Helvetica", + fontSize=9, + leading=13, + textColor=MUTED_TEXT, + ) + + if start_date and end_date: + period = f"Period: {start_date} to {end_date}" + elif start_date: + period = f"From: {start_date}" + elif end_date: + period = f"Until: {end_date}" + else: + period = "Period: All dates" + + try: + from app.utils.timezone import get_user_datetime_format + gen_fmt = get_user_datetime_format() + except Exception: + gen_fmt = "%Y-%m-%d %H:%M" + generated = f"Generated: {datetime.now().strftime(gen_fmt)}" + + meta_left = Paragraph(period, meta_style) + meta_right_style = ParagraphStyle("ReportMetaRight", parent=meta_style, alignment=2) + meta_right = Paragraph(generated, meta_right_style) + meta_table = Table( + [[meta_left, meta_right]], + colWidths=[USABLE_WIDTH_CM * 0.6 * cm, USABLE_WIDTH_CM * 0.4 * cm], + ) + meta_table.setStyle(TableStyle([ + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("TOPPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 0), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ])) + elements.append(meta_table) + + if filters: + filter_parts = [f"{label}: {value}" for label, value in filters.items() if value] + if filter_parts: + filter_style = ParagraphStyle( + "FilterMeta", + fontName="Helvetica-Oblique", + fontSize=8, + leading=11, + textColor=MUTED_TEXT, + ) + elements.append(Spacer(1, 2)) + elements.append(Paragraph("Filters: " + " | ".join(filter_parts), filter_style)) + + elements.append(Spacer(1, 12)) + return elements + + +def build_per_diem_pdf(entries, start_date=None, end_date=None, filters=None): + """ + Build a PDF report of per diem claims. + + Args: + entries: List of PerDiem objects (with user, project, client loaded). + start_date: Optional start date string for the report header. + end_date: Optional end date string for the report header. + filters: Optional dict of active filter labels. + + Returns: + bytes: PDF file content. + """ + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=PAGE_SIZE, + leftMargin=MARGIN, + rightMargin=MARGIN, + topMargin=MARGIN, + bottomMargin=BOTTOM_MARGIN, + ) + + story = [] + story.extend(_build_report_header(start_date, end_date, filters)) + + headers = ["Start Date", "End Date", "User", "Trip Purpose", "Location", "Full / Half Days", "Amount", "Status"] + + if not entries: + empty_style = ParagraphStyle( + "EmptyState", + fontName="Helvetica-Oblique", + fontSize=11, + leading=14, + textColor=MUTED_TEXT, + ) + story.append(Spacer(1, 20)) + story.append(Paragraph("No per diem claims found for the selected filters.", empty_style)) + else: + table_data = [headers] + total_amount = 0 + + for entry in entries: + amount = float(entry.calculated_amount or 0) + total_amount += amount + + location = f"{_safe_str(entry.city)}, {_safe_str(entry.country)}" if entry.city else _safe_str(entry.country) + days_str = f"{entry.full_days or 0} / {entry.half_days or 0}" + + row = [ + entry.start_date.strftime("%Y-%m-%d") if entry.start_date else "", + entry.end_date.strftime("%Y-%m-%d") if entry.end_date else "", + _safe_str(entry.user.display_name if entry.user else ""), + _make_cell_paragraph(entry.trip_purpose or "") or " ", + _safe_str(location), + days_str, + f"{amount:.2f}", + _safe_str(entry.status), + ] + table_data.append(row) + + table_data.append(["", "", "", "Total", "", "", f"{total_amount:.2f}", ""]) + total_row_idx = len(table_data) - 1 + + style = [ + ("BACKGROUND", (0, 0), (-1, 0), HEADER_BG), + ("TEXTCOLOR", (0, 0), (-1, 0), HEADER_FG), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), HEADER_FONT_SIZE), + ("BOTTOMPADDING", (0, 0), (-1, 0), HEADER_PAD_V), + ("TOPPADDING", (0, 0), (-1, 0), HEADER_PAD_V), + ("LEFTPADDING", (0, 0), (-1, -1), CELL_PAD_H), + ("RIGHTPADDING", (0, 0), (-1, -1), CELL_PAD_H), + ("TOPPADDING", (0, 0), (-1, -1), CELL_PAD_V), + ("BOTTOMPADDING", (0, 0), (-1, -1), CELL_PAD_V), + ("GRID", (0, 0), (-1, -1), 0.5, GRID_LIGHT), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("BACKGROUND", (0, total_row_idx), (-1, total_row_idx), TOTALS_BG), + ("TEXTCOLOR", (0, total_row_idx), (-1, total_row_idx), TOTALS_FG), + ("FONTNAME", (0, total_row_idx), (-1, total_row_idx), "Helvetica-Bold"), + ] + + nrows = len(table_data) + for r in range(1, nrows - 1): + bg = ROW_ALT_BG if r % 2 == 1 else ROW_NORMAL_BG + style.append(("BACKGROUND", (0, r), (-1, r), bg)) + + table = Table(table_data, colWidths=COL_WIDTHS, repeatRows=1) + table.setStyle(TableStyle(style)) + story.append(table) + + doc.build(story, onFirstPage=_page_footer, onLaterPages=_page_footer) + return buffer.getvalue() diff --git a/desktop/src/renderer/js/api/client.js b/desktop/src/renderer/js/api/client.js index 6a6c86e1..f07c27ea 100644 --- a/desktop/src/renderer/js/api/client.js +++ b/desktop/src/renderer/js/api/client.js @@ -234,6 +234,10 @@ class ApiClient { return await this.client.post(`/api/v1/timesheet-periods/${periodId}/reject`, data); } + async deleteTimesheetPeriod(periodId) { + return await this.client.delete(`/api/v1/timesheet-periods/${periodId}`); + } + async getLeaveTypes() { return await this.client.get('/api/v1/time-off/leave-types'); } @@ -275,6 +279,10 @@ class ApiClient { if (comment) data.comment = comment; return await this.client.post(`/api/v1/time-off/requests/${requestId}/reject`, data); } + + async deleteTimeOffRequest(requestId) { + return await this.client.delete(`/api/v1/time-off/requests/${requestId}`); + } } // Export for use in other files diff --git a/desktop/src/renderer/js/app.js b/desktop/src/renderer/js/app.js index 6bcf3c2b..47dc9ca7 100644 --- a/desktop/src/renderer/js/app.js +++ b/desktop/src/renderer/js/app.js @@ -81,11 +81,12 @@ async function loadCurrentUserProfile() { const role = String(user.role || '').toLowerCase(); const roleCanApprove = ['admin', 'owner', 'manager', 'approver'].includes(role); state.currentUserProfile = { + id: user.id, is_admin: Boolean(user.is_admin), can_approve: Boolean(user.is_admin) || roleCanApprove, }; } catch (_) { - state.currentUserProfile = { is_admin: false, can_approve: false }; + state.currentUserProfile = { id: null, is_admin: false, can_approve: false }; } } @@ -947,6 +948,9 @@ function renderPeriods() { ${(String(period.status || '').toLowerCase() === 'submitted' && state.currentUserProfile.can_approve) ? `` : ''} + ${['draft', 'rejected'].includes(String(period.status || '').toLowerCase()) + ? `` + : ''} `).join(''); @@ -1007,6 +1011,9 @@ function renderTimeOffRequests() {
${status}
${canReview ? `` : ''} ${canReview ? `` : ''} + ${['draft', 'submitted', 'cancelled'].includes(String(status).toLowerCase()) && (req.user_id === state.currentUserProfile.id || state.currentUserProfile.can_approve) + ? `` + : ''} `; @@ -1152,6 +1159,18 @@ async function reviewTimesheetPeriodAction(periodId, approve) { } } +async function deleteTimesheetPeriodAction(periodId) { + if (!state.apiClient) return; + if (!confirm('Are you sure you want to delete this timesheet period?')) return; + try { + await state.apiClient.deleteTimesheetPeriod(periodId); + showSuccess('Timesheet period deleted'); + await loadWorkforce(); + } catch (error) { + showError('Failed to delete period: ' + (error.response?.data?.error || error.message)); + } +} + async function showCreateTimeOffDialog() { if (!state.apiClient) return; @@ -1348,6 +1367,18 @@ async function reviewTimeOffRequestAction(requestId, approve) { } } +async function deleteTimeOffRequestAction(requestId) { + if (!state.apiClient) return; + if (!confirm('Are you sure you want to delete this time-off request?')) return; + try { + await state.apiClient.deleteTimeOffRequest(requestId); + showSuccess('Time-off request deleted'); + await loadWorkforce(); + } catch (error) { + showError('Failed to delete time-off request: ' + (error.response?.data?.error || error.message)); + } +} + async function loadSettings() { // Load current settings const serverUrl = await storeGet('server_url') || ''; diff --git a/docs/BREAK_TIME_FEATURE.md b/docs/BREAK_TIME_FEATURE.md new file mode 100644 index 00000000..1eedf53c --- /dev/null +++ b/docs/BREAK_TIME_FEATURE.md @@ -0,0 +1,72 @@ +# Break Time for Timers and Manual Time Entries + +**Issue:** [#561](https://github.com/DRYTRIX/TimeTracker/issues/561) + +This feature lets you account for break time when tracking work: either by pausing a running timer (so time while paused counts as break) or by entering break duration on manual time entries. Stored duration is always **worked time** (total span minus break). + +--- + +## For Running Timers + +### Pause and Resume + +- **Pause** — Stops the clock. Time while paused is not counted as work. When you click **Resume**, the elapsed pause time is added to **break** for that entry. +- **Resume** — Continues the timer and records the time you were paused as break. You can pause and resume multiple times; all pause segments are summed as break. +- **Stop & save** — Saves the entry. Stored **duration** = (end time − start time) − break time, then rounded according to your rounding settings. + +**Where it appears** + +- **Dashboard** — When a timer is running: **Pause** and **Stop & save**. When paused: **Resume** and **Stop & save**, plus a “Break: HH:MM:SS” line and a “Paused” badge. Elapsed time does not increase while paused. +- **Timer page** — Same Pause / Resume / Stop controls and break display. +- **Floating timer bar** — Shows paused state (e.g. amber icon); click to Resume or Stop depending on state. + +**API** + +- `POST /timer/pause` (web) and `POST /api/v1/timer/pause` (API) +- `POST /timer/resume` (web) and `POST /api/v1/timer/resume` (API) +- Timer status (`GET /timer/status`, `GET /api/v1/timer/status`) includes `paused`, `paused_at`, `break_seconds`, `break_formatted`. + +--- + +## For Manual Time Entries + +### Break field + +- On **Log Time** (manual entry) and **Edit time entry** you can enter **Break** in HH:MM (e.g. `0:30` for 30 minutes). +- **Effective duration** (what is stored and shown) = (end − start) − break. If you also use **Worked time**, that value is treated as net (after break); break can still be entered and is subtracted when both are present. +- Break is optional; leave it empty for no break. + +### Suggest break (manual entry) + +- A **Suggest** button next to the Break field uses optional default rules (e.g. Germany: >6 h → 30 min, >9 h → 45 min) to propose a break from the current start/end or worked time. You can change or clear the suggestion. + +--- + +## Default Break Rules (optional) + +Admins can configure default break rules in **Settings** (e.g. for labour-law style rules): + +- **Break after hours 1** / **Break minutes 1** — e.g. 6 h → 30 min +- **Break after hours 2** / **Break minutes 2** — e.g. 9 h → 45 min + +These are used only to **suggest** break in the manual entry form; the user can always override or leave break empty. They do not auto-apply. + +--- + +## Data model + +- **`time_entries.break_seconds`** — Total break in seconds for this entry (timer pauses or manual break). +- **`time_entries.paused_at`** — When set, the timer is paused; on resume, `(now − paused_at)` is added to `break_seconds` and `paused_at` is cleared. +- **`duration_seconds`** — Always **worked time**: (end − start) − break, then rounding. Reports and lists use this. + +--- + +## API summary + +| Action | Web route | API v1 route | +|---------------|---------------------|---------------------------| +| Pause timer | `POST /timer/pause` | `POST /api/v1/timer/pause` | +| Resume timer | `POST /timer/resume`| `POST /api/v1/timer/resume`| +| Timer status | `GET /timer/status` | `GET /api/v1/timer/status` | + +Time entry create/update (manual and API) accept optional **`break_seconds`**; response includes `break_seconds` and (for active timers) `paused_at`, `break_formatted`. diff --git a/docs/FEATURES_COMPLETE.md b/docs/FEATURES_COMPLETE.md index 6c11061b..1750ee81 100644 --- a/docs/FEATURES_COMPLETE.md +++ b/docs/FEATURES_COMPLETE.md @@ -50,6 +50,7 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management #### 3. **Manual Time Entry** - Add historical time entries +- **Break field (HH:MM)** — Optional break duration; effective duration = (end − start) − break. Suggest button uses default rules (e.g. >6 h → 30 min). See [Break Time Feature](BREAK_TIME_FEATURE.md). - Flexible date/time selection - Notes and tags support - Billable/non-billable flagging @@ -57,7 +58,8 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management #### 4. **Timer Management** - Start, stop, pause, and resume timers -- **Dashboard timer widget**: Pause (saves segment) and Stop; one-click "Resume (project)" to continue with the same project/task/notes; quick time adjustment (−15 / −5 / +5 / +15 min) while running +- **Break time (Issue #561)** — Pause a running timer so time while paused counts as break; on resume, that time is added to the entry’s break. Stored duration = (end − start) − break. See [Break Time Feature](BREAK_TIME_FEATURE.md). +- **Dashboard timer widget**: Pause (accumulates break) and Stop; one-click "Resume (project)" to continue with the same project/task/notes; quick time adjustment (−15 / −5 / +5 / +15 min) while running - Edit active timers - Delete timers - Timer history and audit trail diff --git a/docs/MOBILE_IMPROVEMENTS.md b/docs/MOBILE_IMPROVEMENTS.md index f7cce370..0872b963 100644 --- a/docs/MOBILE_IMPROVEMENTS.md +++ b/docs/MOBILE_IMPROVEMENTS.md @@ -50,6 +50,9 @@ The TimeTracker application has been completely redesigned with a mobile-first a - **Efficient Scrolling**: Smooth, optimized scrolling performance - **Mobile-Specific CSS**: Dedicated mobile stylesheets for better performance +### 8. **Log Time and Edit Time Entry on Mobile (Issue #557)** +- **No browser freeze**: The manual time entry ("Log Time") and edit time entry pages no longer load the Toast UI Editor on mobile viewports (≤767px). The rich editor is resource-heavy and could freeze or crash mobile browsers. On mobile, the notes field is a plain textarea; on desktop, the full WYSIWYG editor still loads. Users can create and edit time entries on mobile without freezing the browser. + ## 🛠️ Technical Implementation ### CSS Improvements diff --git a/docs/README.md b/docs/README.md index caf49779..94221b9b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -72,6 +72,7 @@ docs/ - **[Bulk Time Entry](BULK_TIME_ENTRY_README.md)** — Create multiple time entries at once - **[Time Entry Templates](TIME_ENTRY_TEMPLATES.md)** — Reusable time entry templates - **[Weekly Time Goals](WEEKLY_TIME_GOALS.md)** — Set and track weekly hour targets +- **[Break Time for timers and manual entries](BREAK_TIME_FEATURE.md)** — Pause timers (break time) and optional break field on manual entries (Issue #561) - **[Time Rounding](TIME_ROUNDING_PREFERENCES.md)** — Configurable time rounding - **[Role-Based Permissions](ADVANCED_PERMISSIONS.md)** — Granular access control - **[Subcontractor role and assigned clients](SUBCONTRACTOR_ROLE.md)** — Restrict users to specific clients and projects diff --git a/docs/SUBCONTRACTOR_ROLE.md b/docs/SUBCONTRACTOR_ROLE.md index 9c2e01e5..ad71b803 100644 --- a/docs/SUBCONTRACTOR_ROLE.md +++ b/docs/SUBCONTRACTOR_ROLE.md @@ -37,7 +37,7 @@ If you change the role away from Subcontractor, assigned clients are cleared. If - **Clients**: List and detail views; edit client. Other clients are hidden and direct URLs return 403. - **Projects**: List, export, view, edit. Only projects belonging to assigned clients are shown; others 403. -- **Time entries**: Timer (manual entry, edit), time entries report and exports. Only entries for allowed projects are included. +- **Time entries**: Timer start (web POST/GET, from template, legacy API, API v1, kiosk), manual entry, and edit. Starting a timer on a project or client the user is not assigned to returns 403 or a redirect with an error. Time entries report and exports only include allowed projects. - **Invoices**: Create invoice (project dropdown), and invoice data for reports. - **Reports**: All report screens and export form use scoped clients and projects; time entries report only includes allowed projects. - **API v1**: List/get clients and projects, global search, and client contacts are scoped; direct access to other resources returns 403. diff --git a/docs/api/API_TOKEN_SCOPES.md b/docs/api/API_TOKEN_SCOPES.md index 510d6d85..55d5f665 100644 --- a/docs/api/API_TOKEN_SCOPES.md +++ b/docs/api/API_TOKEN_SCOPES.md @@ -86,13 +86,15 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \ ``` #### `write:time_entries` -**Grants**: Create, update, and delete time entries; control timer +**Grants**: Create, update, and delete time entries; control timer; timesheet periods and time-off requests **Endpoints**: - `POST /api/v1/time-entries` - Create time entry - `PUT /api/v1/time-entries/{id}` - Update time entry - `DELETE /api/v1/time-entries/{id}` - Delete time entry - `POST /api/v1/timer/start` - Start timer - `POST /api/v1/timer/stop` - Stop timer +- `DELETE /api/v1/timesheet-periods/{id}` - Delete timesheet period (draft/rejected only; owner or admin) +- `DELETE /api/v1/time-off/requests/{id}` - Delete time-off request (draft/submitted/cancelled; owner or approver) **Use Cases**: - Time tracking integrations @@ -199,9 +201,11 @@ curl -X POST https://your-domain.com/api/v1/clients \ ### Reports #### `read:reports` -**Grants**: Access reporting and analytics endpoints +**Grants**: Access reporting and analytics endpoints; read leave types and holidays **Endpoints**: - `GET /api/v1/reports/summary` - Get summary reports +- `GET /api/v1/time-off/leave-types` - List leave types +- `GET /api/v1/time-off/holidays` - List company holidays **Use Cases**: - Business intelligence tools @@ -219,6 +223,16 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \ "https://your-domain.com/api/v1/reports/summary?start_date=2024-01-01&end_date=2024-01-31" ``` +#### `write:reports` +**Grants**: Create and delete leave types and company holidays (workforce admin) +**Endpoints**: +- `POST /api/v1/time-off/leave-types` - Create leave type (admin only) +- `DELETE /api/v1/time-off/leave-types/{id}` - Delete leave type (admin only; blocked if it has time-off requests) +- `POST /api/v1/time-off/holidays` - Create company holiday (admin only) +- `DELETE /api/v1/time-off/holidays/{id}` - Delete company holiday (admin only) + +**Permissions**: Admin only for these endpoints. + --- ### Users diff --git a/docs/development/PROJECT_STRUCTURE.md b/docs/development/PROJECT_STRUCTURE.md index a18558d6..5dbc3a18 100644 --- a/docs/development/PROJECT_STRUCTURE.md +++ b/docs/development/PROJECT_STRUCTURE.md @@ -115,10 +115,11 @@ TimeTracker/ Timesheet periods, policies, and time-off tracking for payroll and compliance: - **Models**: `TimesheetPeriod`, `TimesheetPolicy`, `TimeOff` (in `app/models/`) -- **Routes**: `workforce` blueprint — dashboard, period close, policies, time-off -- **Services**: `workforce_governance_service.py` — period close, policy checks, time-off logic -- **Templates**: `app/templates/workforce/` (e.g. dashboard) +- **Routes**: `workforce` blueprint — dashboard, period close, policies, time-off, **delete** (periods, time-off requests, leave types, holidays) +- **Services**: `workforce_governance_service.py` — period close, policy checks, time-off logic, **delete** (period, leave request, leave type, holiday) +- **Templates**: `app/templates/workforce/` (e.g. dashboard, with delete buttons where allowed) - **Migration**: `132_add_timesheet_governance_and_time_off.py` +- **Docs**: [Workforce delete feature](../features/WORKFORCE_DELETE.md) (Issue #562) ## ✅ Task Management Feature diff --git a/docs/features/OVERTIME_TRACKING.md b/docs/features/OVERTIME_TRACKING.md index afcc814a..f8622eb0 100644 --- a/docs/features/OVERTIME_TRACKING.md +++ b/docs/features/OVERTIME_TRACKING.md @@ -17,6 +17,13 @@ The Overtime Tracking feature allows users to track hours worked beyond their st - Navigate to Reports → User Report - Select your date range - View overtime breakdown in the report table + - **Accumulated overtime (YTD):** Your year-to-date overtime is shown on the main Dashboard (in the Month's Hours card), in Analytics (overtime API with `period=ytd`), and on the Workforce / Time-off page next to Leave Balances. + +3. **Take Overtime as Paid Leave (Issue #560)** + - Go to Workforce (or Time-off / Leave). + - Your **Accumulated overtime (YTD)** is displayed next to Leave Balances. + - Click **Take as paid leave** to scroll to the time-off request form with the "Overtime" leave type selected. + - Enter the number of hours to request (capped at your YTD overtime); submit the request as usual. Approved requests record the hours taken as leave (no automatic balance deduction in v1). ### For Developers @@ -28,10 +35,10 @@ The Overtime Tracking feature allows users to track hours worked beyond their st - `migrations/versions/031_add_standard_hours_per_day.py` - Database migration (daily) - `migrations/versions/134_add_overtime_weekly_mode.py` - Weekly mode (Issue #551) -**API Endpoint:** -``` -GET /api/analytics/overtime?days=30 -``` +**API Endpoints:** +- `GET /api/analytics/overtime?days=30` — Overtime for the last N days. +- `GET /api/analytics/overtime?period=ytd` — Year-to-date accumulated overtime. +- `GET /api/dashboard/stats` and dashboard stats APIs include `overtime_ytd_hours`. **Key Functions:** ```python @@ -41,15 +48,17 @@ from app.utils.overtime import ( get_daily_breakdown, get_week_start_for_date, get_weekly_overtime_summary, - get_overtime_statistics + get_overtime_statistics, + get_overtime_ytd, # YTD accumulated overtime + get_overtime_last_12_months # Optional: last 12 months ) ``` ### Testing ```bash -# Run all overtime tests -pytest tests/test_overtime.py tests/test_overtime_smoke.py -v +# Run all overtime tests (including YTD and overtime-as-leave) +pytest tests/test_overtime.py tests/test_overtime_smoke.py tests/test_overtime_leave.py -v # With coverage pytest tests/test_overtime*.py --cov=app.utils.overtime --cov-report=html @@ -100,14 +109,16 @@ pytest tests/test_overtime*.py --cov=app.utils.overtime --cov-report=html - `overtime_calculation_mode`: `VARCHAR(10)`, default `'daily'`, NOT NULL - `standard_hours_per_week`: `FLOAT`, nullable -**Migrations:** `031_add_standard_hours_per_day`, `134_add_overtime_weekly_mode` +**Migrations:** `031_add_standard_hours_per_day`, `134_add_overtime_weekly_mode`, `136_seed_overtime_leave_type` (seeds "Overtime" leave type for take-as-paid-leave) ## Features ✅ User-configurable standard hours ✅ Automatic overtime calculation ✅ Display in user reports -✅ Analytics API endpoint +✅ Analytics API endpoint (including `period=ytd`) +✅ **Accumulated overtime (YTD)** on Dashboard, Analytics, and Workforce +✅ **Take overtime as paid leave** — Overtime leave type and request flow (Issue #560) ✅ Daily overtime breakdown ✅ Weekly overtime summaries ✅ Comprehensive statistics @@ -117,6 +128,7 @@ pytest tests/test_overtime*.py --cov=app.utils.overtime --cov-report=html ## Future Enhancements - Weekly overtime thresholds (implemented as optional weekly mode; see Issue #551) +- Stored overtime balance and deduction when overtime leave is approved (v1 records only) - Overtime approval workflows - Overtime pay rate calculations - Email notifications for excessive overtime @@ -132,7 +144,7 @@ For questions or issues: --- -**Version:** 1.1.0 +**Version:** 1.2.0 **Status:** ✅ Production Ready -**Last Updated:** March 9, 2026 +**Last Updated:** March 11, 2026 diff --git a/docs/features/WORKFORCE_DELETE.md b/docs/features/WORKFORCE_DELETE.md new file mode 100644 index 00000000..78c78e74 --- /dev/null +++ b/docs/features/WORKFORCE_DELETE.md @@ -0,0 +1,67 @@ +# Workforce Tab: Delete Entries + +This feature adds the ability to delete timesheet periods, time-off requests, leave types, and company holidays from the Workforce tab (web, desktop, and mobile). It addresses [Issue #562](https://github.com/DRYTRIX/TimeTracker/issues/562). + +## What Can Be Deleted + +| Entity | Who can delete | When | +|--------|----------------|------| +| **Timesheet period** | Owner or admin | Only when status is **draft** or **rejected** | +| **Time-off request** | Owner or approver/admin | Only when status is **draft**, **submitted**, or **cancelled** | +| **Leave type** | Admin only | Only if no time-off request uses this leave type | +| **Company holiday** | Admin only | Always (no dependencies) | + +Submitted, approved, closed, or rejected records that affect audit or reporting cannot be deleted. + +## Web (Workforce dashboard) + +- **Timesheet periods:** Each draft or rejected period has a **Delete** button (owner or admin). +- **Time-off requests:** Each draft, submitted, or cancelled request has a **Delete** button (owner or approver). +- **Leave types:** In the admin section, each leave type is listed with a **Delete** button. Delete is blocked with an error if the leave type has any time-off requests. +- **Company holidays:** Each holiday in the list has a **Delete** button (admin only). + +All delete actions use POST forms with CSRF protection and redirect back to the dashboard after success or error. + +## API v1 (Desktop & mobile) + +Delete is exposed as HTTP `DELETE`: + +| Endpoint | Scope | Notes | +|----------|--------|--------| +| `DELETE /api/v1/timesheet-periods/{id}` | `write:time_entries` | Owner or admin; period must be draft or rejected | +| `DELETE /api/v1/time-off/requests/{id}` | `write:time_entries` | Owner or approver; request must be draft, submitted, or cancelled | +| `DELETE /api/v1/time-off/leave-types/{id}` | `write:reports` | Admin only; returns 400 if leave type has requests | +| `DELETE /api/v1/time-off/holidays/{id}` | `write:reports` | Admin only | + +Success: `200` with JSON `{ "message": "..." }`. +Failure: `400` with `{ "error": "..." }` or `403` for permission errors. + +## Desktop app + +- **Timesheet periods:** Delete button on each draft or rejected period; confirmation dialog then refresh. +- **Time-off requests:** Delete button on each draft, submitted, or cancelled request (own requests or when user can approve); confirmation then refresh. + +See `desktop/src/renderer/js/api/client.js` (`deleteTimesheetPeriod`, `deleteTimeOffRequest`) and `desktop/src/renderer/js/app.js` (workforce render and handlers). + +## Mobile app + +- **Timesheet periods:** Popup menu on each period includes **Delete** when status is draft or rejected. +- **Time-off requests:** Popup menu includes **Delete** when status is draft, submitted, or cancelled and the user is owner or approver. + +See `mobile/lib/data/api/api_client.dart` and `mobile/lib/presentation/screens/finance_workforce_screen.dart`. + +## Backend + +- **Service:** `app/services/workforce_governance_service.py` + - `delete_period(period_id, actor_id)` + - `delete_leave_request(request_id, actor_id, actor_can_approve=False)` + - `delete_leave_type(leave_type_id)` + - `delete_holiday(holiday_id)` +- **Web routes:** `app/routes/workforce.py` — POST routes for each delete, CSRF and permissions. +- **API routes:** `app/routes/api_v1.py` — DELETE endpoints with token scopes and admin checks where required. + +## Risks and notes + +- Deleting a leave type that has time-off requests is prevented; the API and web UI return a clear error. +- Only draft or rejected periods can be deleted to keep audit history for submitted/approved/closed periods. +- Only draft, submitted, or cancelled time-off requests can be deleted; approved or rejected ones are kept for reporting. diff --git a/docs/import_export/README.md b/docs/import_export/README.md index 6908ba8e..4f6d6573 100644 --- a/docs/import_export/README.md +++ b/docs/import_export/README.md @@ -12,6 +12,8 @@ The TimeTracker Import/Export system enables seamless data migration, GDPR-compl - 🔍 **Filtered Export** - Export specific data with custom filters - 💾 **Backup/Restore** - Full database backup (admin only) - 📊 **History Tracking** - Monitor all import/export operations +- 🚗 **Mileage export** - From the Mileage list page: Export CSV and Export PDF use the current filters (search, status, project, client, date range). Files: `mileage_export__to_.csv` / `.pdf`. +- 🧾 **Per diem export** - From the Per Diem list page: Client filter plus Export CSV and Export PDF; exports respect the same filters (status, project, client, date range). Files: `per_diem_export__to_.csv` / `.pdf`. ## Quick Links diff --git a/migrations/versions/136_seed_overtime_leave_type.py b/migrations/versions/136_seed_overtime_leave_type.py new file mode 100644 index 00000000..d6ee35f8 --- /dev/null +++ b/migrations/versions/136_seed_overtime_leave_type.py @@ -0,0 +1,50 @@ +"""Seed Overtime leave type for take-overtime-as-paid-leave (Issue #560) + +Revision ID: 136_seed_overtime_leave_type +Revises: 135_remind_to_log +Create Date: 2026-03-11 + +Creates a leave type with code 'overtime' if it does not exist, so users can +request time off from their accumulated overtime (YTD) as paid leave. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "136_seed_overtime_leave_type" +down_revision = "135_remind_to_log" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + if "leave_types" not in inspector.get_table_names(): + return + # Insert Overtime leave type only if not present (by code) + if bind.dialect.name == "sqlite": + op.execute( + sa.text(""" + INSERT INTO leave_types (name, code, is_paid, annual_allowance_hours, accrual_hours_per_month, enabled, created_at, updated_at) + SELECT 'Overtime', 'overtime', 1, NULL, NULL, 1, datetime('now'), datetime('now') + WHERE NOT EXISTS (SELECT 1 FROM leave_types WHERE code = 'overtime') + """) + ) + else: + op.execute( + sa.text(""" + INSERT INTO leave_types (name, code, is_paid, annual_allowance_hours, accrual_hours_per_month, enabled, created_at, updated_at) + SELECT 'Overtime', 'overtime', true, NULL, NULL, true, now(), now() + WHERE NOT EXISTS (SELECT 1 FROM leave_types WHERE code = 'overtime') + """) + ) + + +def downgrade(): + # Remove the seeded leave type (may break if time-off requests reference it) + bind = op.get_bind() + inspector = sa.inspect(bind) + if "leave_types" not in inspector.get_table_names(): + return + op.execute(sa.text("DELETE FROM leave_types WHERE code = 'overtime'")) diff --git a/migrations/versions/137_add_break_time_to_time_entries.py b/migrations/versions/137_add_break_time_to_time_entries.py new file mode 100644 index 00000000..fdde3130 --- /dev/null +++ b/migrations/versions/137_add_break_time_to_time_entries.py @@ -0,0 +1,51 @@ +"""Add break_seconds and paused_at to time_entries (Issue #561) + +Revision ID: 137_add_break_time +Revises: 136_seed_overtime_leave_type +Create Date: 2026-03-11 + +Break time for timers (pause/resume) and manual time entries. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "137_add_break_time" +down_revision = "136_seed_overtime_leave_type" +branch_labels = None +depends_on = None + + +def upgrade(): + from sqlalchemy import inspect + + bind = op.get_bind() + inspector = inspect(bind) + if "time_entries" not in inspector.get_table_names(): + return + columns = {c["name"] for c in inspector.get_columns("time_entries")} + + if "break_seconds" not in columns: + op.add_column( + "time_entries", + sa.Column("break_seconds", sa.Integer(), nullable=True, server_default="0"), + ) + if "paused_at" not in columns: + op.add_column( + "time_entries", + sa.Column("paused_at", sa.DateTime(), nullable=True), + ) + + +def downgrade(): + from sqlalchemy import inspect + + bind = op.get_bind() + inspector = inspect(bind) + if "time_entries" not in inspector.get_table_names(): + return + columns = {c["name"] for c in inspector.get_columns("time_entries")} + if "paused_at" in columns: + op.drop_column("time_entries", "paused_at") + if "break_seconds" in columns: + op.drop_column("time_entries", "break_seconds") diff --git a/migrations/versions/138_add_default_break_rules_settings.py b/migrations/versions/138_add_default_break_rules_settings.py new file mode 100644 index 00000000..0e4d4bcd --- /dev/null +++ b/migrations/versions/138_add_default_break_rules_settings.py @@ -0,0 +1,48 @@ +"""Add default break rules to settings (Issue #561) + +Revision ID: 138_add_break_rules +Revises: 137_add_break_time +Create Date: 2026-03-11 + +Optional default break rules (e.g. Germany: >6h = 30 min, >9h = 45 min). +""" +from alembic import op +import sqlalchemy as sa + + +revision = "138_add_break_rules" +down_revision = "137_add_break_time" +branch_labels = None +depends_on = None + + +def upgrade(): + from sqlalchemy import inspect + + bind = op.get_bind() + inspector = inspect(bind) + if "settings" not in inspector.get_table_names(): + return + columns = {c["name"] for c in inspector.get_columns("settings")} + + if "break_after_hours_1" not in columns: + op.add_column("settings", sa.Column("break_after_hours_1", sa.Float(), nullable=True, server_default="6")) + if "break_minutes_1" not in columns: + op.add_column("settings", sa.Column("break_minutes_1", sa.Integer(), nullable=True, server_default="30")) + if "break_after_hours_2" not in columns: + op.add_column("settings", sa.Column("break_after_hours_2", sa.Float(), nullable=True, server_default="9")) + if "break_minutes_2" not in columns: + op.add_column("settings", sa.Column("break_minutes_2", sa.Integer(), nullable=True, server_default="45")) + + +def downgrade(): + from sqlalchemy import inspect + + bind = op.get_bind() + inspector = inspect(bind) + if "settings" not in inspector.get_table_names(): + return + columns = {c["name"] for c in inspector.get_columns("settings")} + for name in ["break_after_hours_2", "break_minutes_2", "break_after_hours_1", "break_minutes_1"]: + if name in columns: + op.drop_column("settings", name) diff --git a/mobile/lib/data/api/api_client.dart b/mobile/lib/data/api/api_client.dart index a2130d95..be4dccae 100644 --- a/mobile/lib/data/api/api_client.dart +++ b/mobile/lib/data/api/api_client.dart @@ -319,6 +319,11 @@ class ApiClient { return response.data as Map; } + Future> deleteTimesheetPeriod(int periodId) async { + final response = await _dio.delete('/api/v1/timesheet-periods/$periodId'); + return response.data as Map; + } + Future> getLeaveTypes() async { final response = await _dio.get('/api/v1/time-off/leave-types'); return response.data as Map; @@ -377,4 +382,9 @@ class ApiClient { final response = await _dio.post('/api/v1/time-off/requests/$requestId/reject', data: data); return response.data as Map; } + + Future> deleteTimeOffRequest(int requestId) async { + final response = await _dio.delete('/api/v1/time-off/requests/$requestId'); + return response.data as Map; + } } diff --git a/mobile/lib/presentation/screens/finance_workforce_screen.dart b/mobile/lib/presentation/screens/finance_workforce_screen.dart index 0be400b0..b2ad847f 100644 --- a/mobile/lib/presentation/screens/finance_workforce_screen.dart +++ b/mobile/lib/presentation/screens/finance_workforce_screen.dart @@ -18,6 +18,7 @@ class _FinanceWorkforceScreenState extends ConsumerState final TextEditingController _expenseFilterController = TextEditingController(); final TextEditingController _timeOffFilterController = TextEditingController(); bool _canApprove = false; + int? _currentUserId; String _invoiceFilter = ''; String _expenseFilter = ''; String _timeOffFilter = ''; @@ -76,6 +77,7 @@ class _FinanceWorkforceScreenState extends ConsumerState final roleCanApprove = role == 'admin' || role == 'owner' || role == 'manager' || role == 'approver'; if (mounted) { _canApprove = (user['is_admin'] == true) || roleCanApprove; + _currentUserId = (user['id'] as num?)?.toInt(); } final invoiceTotalPages = ((invoicesRes['pagination'] ?? const {})['pages'] as num?)?.toInt() ?? 1; @@ -189,6 +191,24 @@ class _FinanceWorkforceScreenState extends ConsumerState } } + Future _deletePeriod(int periodId) async { + try { + final client = await ref.read(apiClientProvider.future); + if (client == null) return; + await client.deleteTimesheetPeriod(periodId); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Timesheet period deleted')), + ); + await _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Delete failed: $e')), + ); + } + } + Future _createExpense({ required String title, required String category, @@ -563,6 +583,24 @@ class _FinanceWorkforceScreenState extends ConsumerState } } + Future _deleteTimeOffRequest(int requestId) async { + try { + final client = await ref.read(apiClientProvider.future); + if (client == null) return; + await client.deleteTimeOffRequest(requestId); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Time-off request deleted')), + ); + await _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Delete failed: $e')), + ); + } + } + Future _openCreateTimeOffDialog(_FinanceWorkforceData data) async { if (data.leaveTypes.isEmpty) { if (!mounted) return; @@ -946,7 +984,12 @@ class _FinanceWorkforceScreenState extends ConsumerState final start = (req['start_date'] ?? '').toString(); final end = (req['end_date'] ?? '').toString(); final leaveType = (req['leave_type_name'] ?? 'Leave').toString(); + final reqUserId = (req['user_id'] as num?)?.toInt(); final isSubmitted = status.toLowerCase() == 'submitted'; + final canDelete = requestId != null && + ['draft', 'submitted', 'cancelled'].contains(status.toLowerCase()) && + (reqUserId == _currentUserId || _canApprove); + final showMenu = (isSubmitted && _canApprove) || canDelete; return ListTile( dense: true, contentPadding: EdgeInsets.zero, @@ -956,19 +999,28 @@ class _FinanceWorkforceScreenState extends ConsumerState mainAxisSize: MainAxisSize.min, children: [ Text(status), - if (isSubmitted && requestId != null && _canApprove) + if (showMenu && requestId != null) PopupMenuButton( onSelected: (value) async { if (value == 'approve') { await _reviewTimeOffRequest(requestId: requestId, approve: true); } else if (value == 'reject') { await _reviewTimeOffRequest(requestId: requestId, approve: false); + } else if (value == 'delete') { + await _deleteTimeOffRequest(requestId); } }, - itemBuilder: (context) => const [ - PopupMenuItem(value: 'approve', child: Text('Approve')), - PopupMenuItem(value: 'reject', child: Text('Reject')), - ], + itemBuilder: (context) { + final items = >[]; + if (isSubmitted && _canApprove) { + items.add(const PopupMenuItem(value: 'approve', child: Text('Approve'))); + items.add(const PopupMenuItem(value: 'reject', child: Text('Reject'))); + } + if (canDelete) { + items.add(const PopupMenuItem(value: 'delete', child: Text('Delete'))); + } + return items; + }, ), ], ), @@ -1034,6 +1086,8 @@ class _FinanceWorkforceScreenState extends ConsumerState final periodId = period['id'] as int?; final canSubmit = status.toLowerCase() == 'draft' && periodId != null; final canReview = _canApprove && status.toLowerCase() == 'submitted' && periodId != null; + final canDelete = periodId != null && + ['draft', 'rejected'].contains(status.toLowerCase()); return ListTile( dense: true, contentPadding: EdgeInsets.zero, @@ -1044,19 +1098,28 @@ class _FinanceWorkforceScreenState extends ConsumerState onPressed: () => _submitPeriod(periodId), child: const Text('Submit'), ) - : (canReview + : (canReview || canDelete ? PopupMenuButton( onSelected: (value) async { if (value == 'approve') { - await _reviewPeriod(periodId: periodId, approve: true); + await _reviewPeriod(periodId: periodId!, approve: true); } else if (value == 'reject') { - await _reviewPeriod(periodId: periodId, approve: false); + await _reviewPeriod(periodId: periodId!, approve: false); + } else if (value == 'delete') { + await _deletePeriod(periodId!); + } + }, + itemBuilder: (context) { + final items = >[]; + if (canReview) { + items.add(const PopupMenuItem(value: 'approve', child: Text('Approve'))); + items.add(const PopupMenuItem(value: 'reject', child: Text('Reject'))); + } + if (canDelete) { + items.add(const PopupMenuItem(value: 'delete', child: Text('Delete'))); } + return items; }, - itemBuilder: (context) => const [ - PopupMenuItem(value: 'approve', child: Text('Approve')), - PopupMenuItem(value: 'reject', child: Text('Reject')), - ], ) : null), ); diff --git a/setup.py b/setup.py index c09a8d69..2ef7aaa8 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='timetracker', - version='4.22.0', + version='4.22.1', packages=find_packages(), include_package_data=True, install_requires=[ diff --git a/tests/test_overtime.py b/tests/test_overtime.py index 62ad5cf4..574cb60e 100644 --- a/tests/test_overtime.py +++ b/tests/test_overtime.py @@ -14,6 +14,8 @@ get_week_start_for_date, get_weekly_overtime_summary, get_overtime_statistics, + get_overtime_ytd, + get_overtime_last_12_months, ) @@ -501,3 +503,65 @@ def test_period_overtime_weekly_with_overtime(self, app, user_weekly, client_and assert result["regular_hours"] == 20.0 assert result["overtime_hours"] == 1.0 + +class TestOvertimeYTD: + """Tests for accumulated YTD and last-12-months overtime (Issue #560).""" + + @pytest.fixture + def ytd_user(self, app): + user = UserFactory() + user.standard_hours_per_day = 8.0 + db.session.add(user) + db.session.commit() + return user + + @pytest.fixture + def ytd_client_project(self, app): + client = ClientFactory(name="YTD Client") + db.session.commit() + project = ProjectFactory(client_id=client.id, name="YTD Project") + db.session.commit() + return client, project + + def test_get_overtime_ytd_returns_dict(self, app, ytd_user): + """get_overtime_ytd returns dict with expected keys.""" + result = get_overtime_ytd(ytd_user) + assert isinstance(result, dict) + assert "regular_hours" in result + assert "overtime_hours" in result + assert "total_hours" in result + assert "days_with_overtime" in result + + def test_get_overtime_ytd_no_entries(self, app, ytd_user): + """get_overtime_ytd with no entries returns zeros.""" + result = get_overtime_ytd(ytd_user) + assert result["total_hours"] == 0.0 + assert result["overtime_hours"] == 0.0 + assert result["regular_hours"] == 0.0 + + def test_get_overtime_ytd_with_entries(self, app, ytd_user, ytd_client_project): + """get_overtime_ytd includes YTD overtime from time entries.""" + _client, project = ytd_client_project + today = date.today() + # One day with 10h (2h overtime) + entry_start = datetime.combine(today, datetime.min.time().replace(hour=9)) + entry_end = entry_start + timedelta(hours=10) + TimeEntryFactory( + user_id=ytd_user.id, + project_id=project.id, + start_time=entry_start, + end_time=entry_end, + ) + db.session.commit() + result = get_overtime_ytd(ytd_user) + assert result["total_hours"] == 10.0 + assert result["regular_hours"] == 8.0 + assert result["overtime_hours"] == 2.0 + + def test_get_overtime_last_12_months_returns_dict(self, app, ytd_user): + """get_overtime_last_12_months returns dict with expected keys.""" + result = get_overtime_last_12_months(ytd_user) + assert isinstance(result, dict) + assert "overtime_hours" in result + assert "total_hours" in result + diff --git a/tests/test_overtime_leave.py b/tests/test_overtime_leave.py new file mode 100644 index 00000000..3df6a2ef --- /dev/null +++ b/tests/test_overtime_leave.py @@ -0,0 +1,100 @@ +""" +Tests for overtime-as-paid-leave flow (Issue #560). +- Leave type 'overtime' and create_leave_request validation (requested_hours <= YTD). +""" + +import pytest +from datetime import datetime, timedelta, date +from decimal import Decimal + +from app import db +from app.models import User, TimeEntry, Project, Client +from app.models.time_off import LeaveType, TimeOffRequest +from app.services.workforce_governance_service import WorkforceGovernanceService +from app.utils.overtime import get_overtime_ytd +from factories import UserFactory, ClientFactory, ProjectFactory, TimeEntryFactory + + +@pytest.fixture +def overtime_leave_type(app): + """Ensure an overtime leave type exists (code 'overtime').""" + lt = LeaveType.query.filter_by(code="overtime").first() + if not lt: + lt = LeaveType( + name="Overtime", + code="overtime", + is_paid=True, + annual_allowance_hours=None, + accrual_hours_per_month=None, + enabled=True, + ) + db.session.add(lt) + db.session.commit() + return lt + + +@pytest.fixture +def user_with_ytd_overtime(app, overtime_leave_type): + """User with 3 hours YTD overtime (one day 11h with 8h standard).""" + user = UserFactory() + user.standard_hours_per_day = 8.0 + db.session.add(user) + client = ClientFactory(name="OT Leave Client") + db.session.commit() + project = ProjectFactory(client_id=client.id, name="OT Leave Project") + db.session.commit() + today = date.today() + entry_start = datetime.combine(today, datetime.min.time().replace(hour=9)) + entry_end = entry_start + timedelta(hours=11) + TimeEntryFactory( + user_id=user.id, + project_id=project.id, + start_time=entry_start, + end_time=entry_end, + ) + db.session.commit() + return user + + +def test_overtime_leave_request_within_ytd_succeeds(app, user_with_ytd_overtime, overtime_leave_type): + """Requesting overtime leave with requested_hours <= YTD overtime succeeds.""" + user = user_with_ytd_overtime + ytd = get_overtime_ytd(user) + assert ytd["overtime_hours"] >= 3.0, "test user should have at least 3h YTD overtime" + service = WorkforceGovernanceService() + start = date.today() + timedelta(days=7) + end = start + timedelta(days=1) + result = service.create_leave_request( + user_id=user.id, + leave_type_id=overtime_leave_type.id, + start_date=start, + end_date=end, + requested_hours=Decimal("2.5"), + comment="Take 2.5h as leave", + submit_now=True, + ) + assert result["success"] is True + req = TimeOffRequest.query.filter_by(user_id=user.id, leave_type_id=overtime_leave_type.id).first() + assert req is not None + assert float(req.requested_hours) == 2.5 + + +def test_overtime_leave_request_exceeding_ytd_fails(app, user_with_ytd_overtime, overtime_leave_type): + """Requesting overtime leave with requested_hours > YTD overtime returns error.""" + user = user_with_ytd_overtime + ytd = get_overtime_ytd(user) + max_ytd = ytd["overtime_hours"] + service = WorkforceGovernanceService() + start = date.today() + timedelta(days=7) + end = start + timedelta(days=1) + result = service.create_leave_request( + user_id=user.id, + leave_type_id=overtime_leave_type.id, + start_date=start, + end_date=end, + requested_hours=Decimal(str(float(max_ytd) + 10.0)), + comment="Too many hours", + submit_now=True, + ) + assert result["success"] is False + assert "exceed" in result.get("message", "").lower() or "accumulated" in result.get("message", "").lower() diff --git a/tests/test_overtime_smoke.py b/tests/test_overtime_smoke.py index e59ca1fe..63bdaeb2 100644 --- a/tests/test_overtime_smoke.py +++ b/tests/test_overtime_smoke.py @@ -24,6 +24,7 @@ def test_overtime_utils_import(self): assert hasattr(overtime, "get_daily_breakdown") assert hasattr(overtime, "get_weekly_overtime_summary") assert hasattr(overtime, "get_overtime_statistics") + assert hasattr(overtime, "get_overtime_ytd") def test_user_model_has_standard_hours(self, app): """Smoke test: verify User model has standard_hours_per_day field"""