From 1c339919b72fcd10e2b86961c9224f20b5743314 Mon Sep 17 00:00:00 2001 From: robinroy03 Date: Fri, 5 Jun 2026 03:17:15 +0530 Subject: [PATCH 1/2] feat: leave summary view --- next_pms/api/dashboard.py | 61 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/next_pms/api/dashboard.py b/next_pms/api/dashboard.py index db22eb75f..9327a35c3 100644 --- a/next_pms/api/dashboard.py +++ b/next_pms/api/dashboard.py @@ -15,6 +15,67 @@ ALLOWED_ROLES = ["Projects Manager", "Projects User"] +@whitelist(methods=["GET"]) +def get_employees_on_leave() -> list: + """Return direct reports of the current user who are on leave today or upcoming. + + Returns + ------- + list of dict + employee, employee_name, from_date, to_date, + half_day, custom_first_halfsecond_half, user_image. + Empty list if the user has no employee record or no direct reports. + Ordered by from_date ascending. + """ + only_for(["Projects Manager", "Projects User", "System Manager"], message=True) + + from next_pms.timesheet.api.employee import get_employee_from_user + + manager_employee = get_employee_from_user() + if not manager_employee: + return [] + + return _get_employees_on_leave(manager_employee) + + +@redis_cache(user=True) +def _get_employees_on_leave(manager_employee: str) -> list: + reportee_ids = frappe.get_all( + "Employee", + filters={"reports_to": manager_employee, "status": "Active"}, + pluck="name", + ) + if not reportee_ids: + return [] + + LeaveApplication = DocType("Leave Application") + Employee = DocType("Employee") + User = DocType("User") + + return ( + frappe.qb.from_(LeaveApplication) + .join(Employee) + .on(Employee.name == LeaveApplication.employee) + .left_join(User) + .on(User.name == Employee.user_id) + .select( + LeaveApplication.employee, + LeaveApplication.employee_name, + LeaveApplication.from_date, + LeaveApplication.to_date, + LeaveApplication.half_day, + LeaveApplication.custom_first_halfsecond_half, + User.user_image, + ) + .where(LeaveApplication.docstatus == 1) + .where(LeaveApplication.status == "Approved") + .where(LeaveApplication.to_date >= frappe.utils.today()) + .where(LeaveApplication.employee.isin(reportee_ids)) + .orderby(LeaveApplication.from_date) + .run(as_dict=True) + ) + + @whitelist(methods=["GET"]) def get_leadership_kpis( cur_start: str, From f6b3e373f9ee796b6ea7603b0ac79d0dce0bfab1 Mon Sep 17 00:00:00 2001 From: robinroy03 Date: Thu, 11 Jun 2026 17:12:18 +0530 Subject: [PATCH 2/2] feat: timesheets view backend + some refactoring --- next_pms/api/dashboard.py | 261 ++++++++++++++++++++++---------------- next_pms/api/utils.py | 135 ++++++++++++++++++++ 2 files changed, 289 insertions(+), 107 deletions(-) diff --git a/next_pms/api/dashboard.py b/next_pms/api/dashboard.py index 7c9b50e44..68e0c930c 100644 --- a/next_pms/api/dashboard.py +++ b/next_pms/api/dashboard.py @@ -5,7 +5,6 @@ import frappe from erpnext.setup.doctype.employee.employee import get_holiday_list_for_employee -from erpnext.setup.utils import get_exchange_rate from frappe import only_for, whitelist from frappe.core.doctype.recorder.recorder import redis_cache from frappe.query_builder import DocType @@ -14,10 +13,17 @@ from pypika import Case from pypika.functions import Date +from next_pms.api.utils import ( + get_employee_allocated_hours_for_date, + get_holidays_by_employee, + get_working_dates_for_range, + is_full_day_leave, + is_holiday, + sum_to_usd, +) from next_pms.resource_management.api.utils.query import attach_extra_entries, get_employee_leaves -from next_pms.timesheet.api.employee import get_employee_from_user +from next_pms.timesheet.api.employee import get_employee_daily_working_norm, get_employee_from_user -CURRENCY = "USD" ALLOWED_ROLES = ["Projects Manager", "Projects User"] @@ -242,7 +248,7 @@ def _get_members_without_allocation(days: int) -> dict: gap_dates = [] for date in working_dates: - allocated_hours = _get_employee_allocated_hours_for_date( + allocated_hours = get_employee_allocated_hours_for_date( allocations_by_employee.get(employee.name, []), date, ) @@ -274,71 +280,6 @@ def _get_members_without_allocation(days: int) -> dict: } -def _get_employee_allocated_hours_for_date(allocations: list, date) -> float: - """Sum allocation hours for one employee on a single date. - - Iterates the employee's allocations that overlap the given date. For each - allocation, skips it when the date is outside the allocation range or when a - per-day override marks that date as cancelled. Uses override hours when set, - otherwise hours_allocated_per_day. - - Args: - allocations: Resource Allocation dicts for one employee, each - optionally containing an override list from attach_extra_entries. - date: The calendar date to evaluate. - - Returns: - Total allocated hours across all matching allocations for the date. - """ - allocated_hours = 0.0 - for allocation in allocations: - if not (allocation.allocation_start_date <= date <= allocation.allocation_end_date): - continue - - override = None - for row in allocation.get("override", []): - if getdate(row.date) == date: - override = row - break - - if override and override.cancelled: - continue - - allocated_hours += ( - flt(override.hours) if override and override.hours is not None else flt(allocation.hours_allocated_per_day) - ) - - return allocated_hours - - -def _get_working_dates_for_range(start_date, end_date, allow_weekend_entries: int) -> set: - working_dates = set() - date = getdate(start_date) - end = getdate(end_date) - while date <= end: - if allow_weekend_entries or date.weekday() < 5: - working_dates.add(date) - date += timedelta(days=1) - return working_dates - - -def _is_full_day_leave(date, leaves: list) -> bool: - for leave in leaves: - if not (leave.from_date <= date <= leave.to_date): - continue - if leave.half_day: - continue - return True - return False - - -def _is_holiday(date, holidays: list) -> bool: - for holiday in holidays: - if holiday.holiday_date == date: - return True - return False - - @whitelist(methods=["GET"]) def get_outstanding_timesheets( days: int = 7, @@ -385,7 +326,7 @@ def _get_outstanding_timesheets(user: str, days: int, client: str | None, projec end_date = getdate(add_days(today(), -1)) start_date = end_date - timedelta(days=days - 1) allow_weekend_entries = cint(frappe.db.get_single_value("Timesheet Settings", "allow_weekend_entries")) - working_dates = _get_working_dates_for_range(start_date, end_date, allow_weekend_entries) + working_dates = get_working_dates_for_range(start_date, end_date, allow_weekend_entries) empty_response = { "count": 0, @@ -484,7 +425,7 @@ def _get_outstanding_timesheets(user: str, days: int, client: str | None, projec required_count = 0 for date in sorted(working_dates): - if _is_full_day_leave(date, employee_leaves) or _is_holiday(date, employee_holidays): + if is_full_day_leave(date, employee_leaves) or is_holiday(date, employee_holidays): continue required_count += 1 if date not in covered_dates: @@ -748,7 +689,7 @@ def get_revenue(cur_start, cur_end, prev_start, prev_end, client, project) -> tu query = query.where(SalesInvoice.project == project) rows = query.run(as_dict=True) - return _sum_to_usd(rows, "cur_revenue", "prev_revenue") + return sum_to_usd(rows, "cur_revenue", "prev_revenue") def get_cost(cur_start, cur_end, prev_start, prev_end, client, project) -> tuple[float, float]: @@ -803,35 +744,7 @@ def get_cost(cur_start, cur_end, prev_start, prev_end, client, project) -> tuple query = query.where(TimesheetDetail.project == project) rows = query.run(as_dict=True) - return _sum_to_usd(rows, "cur_cost", "prev_cost") - - -def _sum_to_usd(rows: list, cur_key: str, prev_key: str) -> tuple[float, float]: - """Convert per-currency query rows to a single USD total for each period. - - Parameters - ---------- - rows : list of dict - Query result rows, each with currency, transaction_date, cur_key, prev_key fields. - cur_key : str - Field name for the current period amount. - prev_key : str - Field name for the previous period amount. - - Returns - ------- - tuple[float, float] - (current_usd, previous_usd) - """ - current = 0.0 - previous = 0.0 - for row in rows: - rate = 1.0 - if row.currency != CURRENCY: - rate = get_exchange_rate(row.currency, CURRENCY, row.transaction_date) or 1 - current += flt(row[cur_key]) * rate - previous += flt(row[prev_key]) * rate - return current, previous + return sum_to_usd(rows, "cur_cost", "prev_cost") @whitelist(methods=["GET"]) @@ -943,7 +856,7 @@ def _get_forecast_breakdown(days: int, role: str | None) -> dict: start_date = getdate(today()) end_date = start_date + timedelta(days=days - 1) allow_weekend_entries = cint(frappe.db.get_single_value("Timesheet Settings", "allow_weekend_entries")) - working_dates = _get_working_dates_for_range(start_date, end_date, allow_weekend_entries) + working_dates = get_working_dates_for_range(start_date, end_date, allow_weekend_entries) empty_response = { "days": days, @@ -1023,10 +936,10 @@ def _get_forecast_breakdown(days: int, role: str | None) -> dict: employee_holidays = holidays_by_list.get(holiday_list_by_employee.get(employee.name), []) for date in working_dates: - if _is_holiday(date, employee_holidays): + if is_holiday(date, employee_holidays): continue - confirmed = _get_employee_allocated_hours_for_date(confirmed_by_employee.get(employee.name, []), date) - tentative = _get_employee_allocated_hours_for_date(tentative_by_employee.get(employee.name, []), date) + confirmed = get_employee_allocated_hours_for_date(confirmed_by_employee.get(employee.name, []), date) + tentative = get_employee_allocated_hours_for_date(tentative_by_employee.get(employee.name, []), date) total_allocated += confirmed total_tentative += tentative total_unallocated += max(0.0, daily_working_hours - confirmed - tentative) @@ -1055,8 +968,6 @@ def get_employees_on_leave() -> list: """ only_for(["Projects Manager", "Projects User", "System Manager"], message=True) - from next_pms.timesheet.api.employee import get_employee_from_user - manager_employee = get_employee_from_user() if not manager_employee: return [] @@ -1100,3 +1011,139 @@ def _get_employees_on_leave(manager_employee: str) -> list: .orderby(LeaveApplication.from_date) .run(as_dict=True) ) + + +@whitelist(methods=["GET"]) +def get_team_timesheets(days: int = 7) -> list: + """Return a per-member timesheet summary for the current user's direct reports. + + For each active employee reporting to the caller, sums billable and + non-billable hours logged in the window, computes expected hours from the + employee's daily working norm over the working days (weekends and the + employee's holidays excluded; leaves are not), the delta between expected and + logged hours, and the approval status of each timesheet in the window. + + Args: + days: Inclusive look-back window of calendar days ending today. Must be at + least 1. For example, days=7 on 11 Jun covers 5 Jun through 11 Jun. + + Returns: + A list of dicts, each with employee ID, + employee_name, user_image, billable_hours, non_billable_hours, + expected_hours, delta, and timesheet_statuses (a list of + {name, status} for each timesheet in the window). Empty list when the + caller has no linked Employee record or no active direct reports. + + Raises: + frappe.PermissionError: If the caller lacks Projects Manager, Projects User, + or System Manager role. + frappe.ValidationError: If days is less than 1. + """ + only_for(["Projects Manager", "Projects User", "System Manager"], message=True) + + if days < 1: + frappe.throw(frappe._("days must be at least 1")) + + manager_employee = get_employee_from_user() + if not manager_employee: + return [] + + return _get_team_timesheets(manager_employee, days) + + +@redis_cache(user=True, ttl=86400) +def _get_team_timesheets(manager_employee: str, days: int) -> list: + end_date = getdate(today()) + start_date = end_date - timedelta(days=days - 1) + + employees = frappe.get_all( + "Employee", + filters={"reports_to": manager_employee, "status": "Active"}, + fields=["name", "employee_name", "user_id"], + ) + if not employees: + return [] + + employee_names = [employee.name for employee in employees] + allow_weekend_entries = cint(frappe.db.get_single_value("Timesheet Settings", "allow_weekend_entries")) + working_dates = get_working_dates_for_range(start_date, end_date, allow_weekend_entries) + holidays_by_employee = get_holidays_by_employee(employee_names, start_date, end_date) + + user_ids = [employee.user_id for employee in employees if employee.user_id] + user_image_by_id = {} + if user_ids: + users = frappe.get_all("User", filters={"name": ["in", user_ids]}, fields=["name", "user_image"]) + for user in users: + user_image_by_id[user.name] = user.user_image + + Timesheet = DocType("Timesheet") + TimesheetDetail = DocType("Timesheet Detail") + hours_rows = ( + frappe.qb.from_(TimesheetDetail) + .join(Timesheet) + .on(TimesheetDetail.parent == Timesheet.name) + .select( + Timesheet.employee, + TimesheetDetail.is_billable, + Sum(TimesheetDetail.hours).as_("total_hours"), + ) + .where(Timesheet.employee.isin(employee_names)) + .where(Timesheet.start_date >= start_date) + .where(Timesheet.start_date <= end_date) + .where(Timesheet.docstatus.isin([0, 1])) + .groupby(Timesheet.employee, TimesheetDetail.is_billable) + .run(as_dict=True) + ) + + billable_by_employee = {} + non_billable_by_employee = {} + for row in hours_rows: + if row.is_billable: + billable_by_employee[row.employee] = flt(row.total_hours) + else: + non_billable_by_employee[row.employee] = flt(row.total_hours) + + statuses_by_employee = {} + timesheets = frappe.get_all( + "Timesheet", + filters={ + "employee": ["in", employee_names], + "start_date": ["between", (start_date, end_date)], + "docstatus": ["in", [0, 1]], + }, + fields=["name", "employee", "custom_approval_status"], + ) + for timesheet in timesheets: + if timesheet.employee not in statuses_by_employee: + statuses_by_employee[timesheet.employee] = [] + statuses_by_employee[timesheet.employee].append( + {"name": timesheet.name, "status": timesheet.custom_approval_status} + ) + + members = [] + for employee in employees: + daily_norm = flt(get_employee_daily_working_norm(employee.name)) + employee_holidays = holidays_by_employee.get(employee.name, set()) + expected_days = 0 + for date in working_dates: + if date in employee_holidays: + continue + expected_days += 1 + expected_hours = flt(daily_norm * expected_days, 2) + billable = flt(billable_by_employee.get(employee.name, 0.0), 2) + non_billable = flt(non_billable_by_employee.get(employee.name, 0.0), 2) + + members.append( + { + "employee": employee.name, + "employee_name": employee.employee_name, + "user_image": user_image_by_id.get(employee.user_id), + "billable_hours": billable, + "non_billable_hours": non_billable, + "expected_hours": expected_hours, + "delta": flt(expected_hours - billable - non_billable, 2), + "timesheet_statuses": statuses_by_employee.get(employee.name, []), + } + ) + + return members diff --git a/next_pms/api/utils.py b/next_pms/api/utils.py index 62719996e..95e67239a 100644 --- a/next_pms/api/utils.py +++ b/next_pms/api/utils.py @@ -2,7 +2,11 @@ from functools import wraps from typing import Any +import frappe +from erpnext.setup.doctype.employee.employee import get_holiday_list_for_employee +from erpnext.setup.utils import get_exchange_rate from frappe import log_error +from frappe.utils import flt, getdate def transform_google_events(events: dict[str, Any]) -> list[dict[str, Any]]: @@ -78,3 +82,134 @@ def innerfn(*args, **kwargs): raise return innerfn + + +def get_employee_allocated_hours_for_date(allocations: list, date) -> float: + """Sum allocation hours for one employee on a single date. + + Iterates the employee's allocations that overlap the given date. For each + allocation, skips it when the date is outside the allocation range or when a + per-day override marks that date as cancelled. Uses override hours when set, + otherwise hours_allocated_per_day. + + Args: + allocations: Resource Allocation dicts for one employee, each + optionally containing an override list from attach_extra_entries. + date: The calendar date to evaluate. + + Returns: + Total allocated hours across all matching allocations for the date. + """ + allocated_hours = 0.0 + for allocation in allocations: + if not (allocation.allocation_start_date <= date <= allocation.allocation_end_date): + continue + + override = None + for row in allocation.get("override", []): + if getdate(row.date) == date: + override = row + break + + if override and override.cancelled: + continue + + allocated_hours += ( + flt(override.hours) if override and override.hours is not None else flt(allocation.hours_allocated_per_day) + ) + + return allocated_hours + + +def get_working_dates_for_range(start_date, end_date, allow_weekend_entries: int) -> set: + working_dates = set() + date = getdate(start_date) + end = getdate(end_date) + while date <= end: + if allow_weekend_entries or date.weekday() < 5: + working_dates.add(date) + date += timedelta(days=1) + return working_dates + + +def is_full_day_leave(date, leaves: list) -> bool: + for leave in leaves: + if not (leave.from_date <= date <= leave.to_date): + continue + if leave.half_day: + continue + return True + return False + + +def is_holiday(date, holidays: list) -> bool: + for holiday in holidays: + if holiday.holiday_date == date: + return True + return False + + +def sum_to_usd(rows: list, cur_key: str, prev_key: str) -> tuple[float, float]: + """Convert per-currency query rows to a single USD total for each period. + + Parameters + ---------- + rows : list of dict + Query result rows, each with currency, transaction_date, cur_key, prev_key fields. + cur_key : str + Field name for the current period amount. + prev_key : str + Field name for the previous period amount. + + Returns + ------- + tuple[float, float] + (current_usd, previous_usd) + """ + current = 0.0 + previous = 0.0 + for row in rows: + rate = 1.0 + if row.currency != "USD": + rate = get_exchange_rate(row.currency, "USD", row.transaction_date) or 1 + current += flt(row[cur_key]) * rate + previous += flt(row[prev_key]) * rate + return current, previous + + +def get_holidays_by_employee(employee_names: list, start_date, end_date) -> dict: + """Map each employee to the set of their holiday dates within the window. + + Resolves each employee's holiday list once, then fetches holidays per unique + list to avoid a query per employee. + + Args: + employee_names: Employee doctype names to resolve holidays for. + start_date: Inclusive window start. + end_date: Inclusive window end. + + Returns: + A dict of employee name to a set of holiday dates. Employees without a + holiday list map to an empty set. + """ + holiday_list_by_employee = {} + for employee_name in employee_names: + holiday_list_by_employee[employee_name] = get_holiday_list_for_employee(employee_name, raise_exception=False) + + unique_holiday_lists = {holiday_list for holiday_list in holiday_list_by_employee.values() if holiday_list} + dates_by_list = {} + for holiday_list in unique_holiday_lists: + holidays = frappe.get_all( + "Holiday", + filters={"parent": holiday_list, "holiday_date": ["between", (start_date, end_date)]}, + fields=["holiday_date"], + ) + holiday_dates = set() + for holiday in holidays: + holiday_dates.add(holiday.holiday_date) + dates_by_list[holiday_list] = holiday_dates + + return { + employee_name: dates_by_list.get(holiday_list_by_employee.get(employee_name), set()) + for employee_name in employee_names + }