diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 06f61982..b6f137d5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,9 @@ "Bash(find:*)", "Bash(npm run build:*)", "Bash(npm run type-check:*)", - "Bash(npm run:*)" + "Bash(npm run:*)", + "Bash(grep:*)", + "Bash(rm:*)" ], "deny": [] } diff --git a/worklenz-backend/database/sql/indexes.sql b/worklenz-backend/database/sql/indexes.sql index b9df6ab1..1c9b3855 100644 --- a/worklenz-backend/database/sql/indexes.sql +++ b/worklenz-backend/database/sql/indexes.sql @@ -132,3 +132,139 @@ CREATE INDEX IF NOT EXISTS projects_team_id_index CREATE INDEX IF NOT EXISTS projects_team_id_name_index ON projects (team_id, name); +-- Performance indexes for optimized tasks queries +-- From migration: 20250115000000-performance-indexes.sql + +-- Composite index for main task filtering +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived_parent +ON tasks(project_id, archived, parent_task_id) +WHERE archived = FALSE; + +-- Index for status joins +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project +ON tasks(status_id, project_id) +WHERE archived = FALSE; + +-- Index for assignees lookup +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member +ON tasks_assignees(task_id, team_member_id); + +-- Index for phase lookup +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase +ON task_phase(task_id, phase_id); + +-- Index for subtask counting +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_archived +ON tasks(parent_task_id, archived) +WHERE parent_task_id IS NOT NULL AND archived = FALSE; + +-- Index for labels +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label +ON task_labels(task_id, label_id); + +-- Index for comments count +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task +ON task_comments(task_id); + +-- Index for attachments count +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task +ON task_attachments(task_id); + +-- Index for work log aggregation +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task +ON task_work_log(task_id); + +-- Index for subscribers check +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task +ON task_subscribers(task_id); + +-- Index for dependencies check +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task +ON task_dependencies(task_id); + +-- Index for timers lookup +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_task_user +ON task_timers(task_id, user_id); + +-- Index for custom columns +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task +ON cc_column_values(task_id); + +-- Index for team member info view optimization +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_team_user +ON team_members(team_id, user_id) +WHERE active = TRUE; + +-- Index for notification settings +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notification_settings_user_team +ON notification_settings(user_id, team_id); + +-- Index for task status categories +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_category +ON task_statuses(category_id, project_id); + +-- Index for project phases +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_phases_project_sort +ON project_phases(project_id, sort_index); + +-- Index for task priorities +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_priorities_value +ON task_priorities(value); + +-- Index for team labels +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_labels_team +ON team_labels(team_id); + +-- Advanced performance indexes for task optimization + +-- Composite index for task main query optimization (covers most WHERE conditions) +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_performance_main +ON tasks(project_id, archived, parent_task_id, status_id, priority_id) +WHERE archived = FALSE; + +-- Index for sorting by sort_order with project filter +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort_order +ON tasks(project_id, sort_order) +WHERE archived = FALSE; + +-- Index for email_invitations to optimize team_member_info_view +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_invitations_team_member +ON email_invitations(team_member_id); + +-- Covering index for task status with category information +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_covering +ON task_statuses(id, category_id, project_id); + +-- Index for task aggregation queries (parent task progress calculation) +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_status_archived +ON tasks(parent_task_id, status_id, archived) +WHERE archived = FALSE; + +-- Index for project team member filtering +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_project_lookup +ON team_members(team_id, active, user_id) +WHERE active = TRUE; + +-- Covering index for tasks with frequently accessed columns +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_covering_main +ON tasks(id, project_id, archived, parent_task_id, status_id, priority_id, sort_order, name) +WHERE archived = FALSE; + +-- Index for task search functionality +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_name_search +ON tasks USING gin(to_tsvector('english', name)) +WHERE archived = FALSE; + +-- Index for date-based filtering (if used) +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_dates +ON tasks(project_id, start_date, end_date) +WHERE archived = FALSE; + +-- Index for task timers with user filtering +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_user_task +ON task_timers(user_id, task_id); + +-- Index for sys_task_status_categories lookups +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sys_task_status_categories_covering +ON sys_task_status_categories(id, color_code, color_code_dark, is_done, is_doing, is_todo); + diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller-updated.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller-updated.ts new file mode 100644 index 00000000..9d23c03d --- /dev/null +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller-updated.ts @@ -0,0 +1,179 @@ +// Example of updated getMemberTimeSheets method with timezone support +// This shows the key changes needed to handle timezones properly + +import moment from "moment-timezone"; +import db from "../../config/db"; +import { IWorkLenzRequest } from "../../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../../interfaces/worklenz-response"; +import { ServerResponse } from "../../models/server-response"; +import { DATE_RANGES } from "../../shared/constants"; + +export async function getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const archived = req.query.archived === "true"; + const teams = (req.body.teams || []) as string[]; + const teamIds = teams.map(id => `'${id}'`).join(","); + const projects = (req.body.projects || []) as string[]; + const projectIds = projects.map(p => `'${p}'`).join(","); + const {billable} = req.body; + + // Get user timezone from request or database + const userTimezone = req.body.timezone || await getUserTimezone(req.user?.id || ""); + + if (!teamIds || !projectIds.length) + return res.status(200).send(new ServerResponse(true, { users: [], projects: [] })); + + const { duration, date_range } = req.body; + + // Calculate date range with timezone support + let startDate: moment.Moment; + let endDate: moment.Moment; + + if (date_range && date_range.length === 2) { + // Convert user's local dates to their timezone's start/end of day + startDate = moment.tz(date_range[0], userTimezone).startOf("day"); + endDate = moment.tz(date_range[1], userTimezone).endOf("day"); + } else if (duration === DATE_RANGES.ALL_TIME) { + const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`; + const minDateResult = await db.query(minDateQuery, []); + const minDate = minDateResult.rows[0]?.min_date; + startDate = minDate ? moment.tz(minDate, userTimezone) : moment.tz("2000-01-01", userTimezone); + endDate = moment.tz(userTimezone); + } else { + // Calculate ranges based on user's timezone + const now = moment.tz(userTimezone); + + switch (duration) { + case DATE_RANGES.YESTERDAY: + startDate = now.clone().subtract(1, "day").startOf("day"); + endDate = now.clone().subtract(1, "day").endOf("day"); + break; + case DATE_RANGES.LAST_WEEK: + startDate = now.clone().subtract(1, "week").startOf("isoWeek"); + endDate = now.clone().subtract(1, "week").endOf("isoWeek"); + break; + case DATE_RANGES.LAST_MONTH: + startDate = now.clone().subtract(1, "month").startOf("month"); + endDate = now.clone().subtract(1, "month").endOf("month"); + break; + case DATE_RANGES.LAST_QUARTER: + startDate = now.clone().subtract(3, "months").startOf("day"); + endDate = now.clone().endOf("day"); + break; + default: + startDate = now.clone().startOf("day"); + endDate = now.clone().endOf("day"); + } + } + + // Convert to UTC for database queries + const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss"); + const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss"); + + // Calculate working days in user's timezone + const totalDays = endDate.diff(startDate, "days") + 1; + let workingDays = 0; + + const current = startDate.clone(); + while (current.isSameOrBefore(endDate, "day")) { + if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) { + workingDays++; + } + current.add(1, "day"); + } + + // Updated SQL query with proper timezone handling + const billableQuery = buildBillableQuery(billable); + const archivedClause = archived ? "" : `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND user_id = '${req.user?.id}')`; + + const q = ` + WITH project_hours AS ( + SELECT + id, + COALESCE(hours_per_day, 8) as hours_per_day + FROM projects + WHERE id IN (${projectIds}) + ), + total_working_hours AS ( + SELECT + SUM(hours_per_day) * ${workingDays} as total_hours + FROM project_hours + ) + SELECT + u.id, + u.email, + tm.name, + tm.color_code, + COALESCE(SUM(twl.time_spent), 0) as logged_time, + COALESCE(SUM(twl.time_spent), 0) / 3600.0 as value, + (SELECT total_hours FROM total_working_hours) as total_working_hours, + CASE + WHEN (SELECT total_hours FROM total_working_hours) > 0 + THEN ROUND((COALESCE(SUM(twl.time_spent), 0) / 3600.0) / (SELECT total_hours FROM total_working_hours) * 100, 2) + ELSE 0 + END as utilization_percent, + ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0, 2) as utilized_hours, + ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0 - (SELECT total_hours FROM total_working_hours), 2) as over_under_utilized_hours, + '${userTimezone}' as user_timezone, + '${startDate.format("YYYY-MM-DD")}' as report_start_date, + '${endDate.format("YYYY-MM-DD")}' as report_end_date + FROM team_members tm + LEFT JOIN users u ON tm.user_id = u.id + LEFT JOIN task_work_log twl ON twl.user_id = u.id + LEFT JOIN tasks t ON twl.task_id = t.id ${billableQuery} + LEFT JOIN projects p ON t.project_id = p.id + WHERE tm.team_id IN (${teamIds}) + AND ( + twl.id IS NULL + OR ( + p.id IN (${projectIds}) + AND twl.created_at >= '${startUtc}'::TIMESTAMP + AND twl.created_at <= '${endUtc}'::TIMESTAMP + ${archivedClause} + ) + ) + GROUP BY u.id, u.email, tm.name, tm.color_code + ORDER BY logged_time DESC`; + + const result = await db.query(q, []); + + // Add timezone context to response + const response = { + data: result.rows, + timezone_info: { + user_timezone: userTimezone, + report_period: { + start: startDate.format("YYYY-MM-DD HH:mm:ss z"), + end: endDate.format("YYYY-MM-DD HH:mm:ss z"), + working_days: workingDays, + total_days: totalDays + } + } + }; + + return res.status(200).send(new ServerResponse(true, response)); +} + +async function getUserTimezone(userId: string): Promise { + const q = `SELECT tz.name as timezone + FROM users u + JOIN timezones tz ON u.timezone_id = tz.id + WHERE u.id = $1`; + const result = await db.query(q, [userId]); + return result.rows[0]?.timezone || "UTC"; +} + +function buildBillableQuery(billable: { billable: boolean; nonBillable: boolean }): string { + if (!billable) return ""; + + const { billable: isBillable, nonBillable } = billable; + + if (isBillable && nonBillable) { + return ""; + } else if (isBillable) { + return " AND tasks.billable IS TRUE"; + } else if (nonBillable) { + return " AND tasks.billable IS FALSE"; + } + + return ""; +} \ No newline at end of file diff --git a/worklenz-backend/src/controllers/reporting/reporting-controller-base-with-timezone.ts b/worklenz-backend/src/controllers/reporting/reporting-controller-base-with-timezone.ts new file mode 100644 index 00000000..59fc9a50 --- /dev/null +++ b/worklenz-backend/src/controllers/reporting/reporting-controller-base-with-timezone.ts @@ -0,0 +1,117 @@ +import WorklenzControllerBase from "../worklenz-controller-base"; +import { IWorkLenzRequest } from "../../interfaces/worklenz-request"; +import db from "../../config/db"; +import moment from "moment-timezone"; +import { DATE_RANGES } from "../../shared/constants"; + +export default abstract class ReportingControllerBaseWithTimezone extends WorklenzControllerBase { + + /** + * Get the user's timezone from the database or request + * @param userId - The user ID + * @returns The user's timezone or 'UTC' as default + */ + protected static async getUserTimezone(userId: string): Promise { + const q = `SELECT tz.name as timezone + FROM users u + JOIN timezones tz ON u.timezone_id = tz.id + WHERE u.id = $1`; + const result = await db.query(q, [userId]); + return result.rows[0]?.timezone || 'UTC'; + } + + /** + * Generate date range clause with timezone support + * @param key - Date range key (e.g., YESTERDAY, LAST_WEEK) + * @param dateRange - Array of date strings + * @param userTimezone - User's timezone (e.g., 'America/New_York') + * @returns SQL clause for date filtering + */ + protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) { + // For custom date ranges + if (dateRange.length === 2) { + // Convert dates to user's timezone start/end of day + const start = moment.tz(dateRange[0], userTimezone).startOf('day'); + const end = moment.tz(dateRange[1], userTimezone).endOf('day'); + + // Convert to UTC for database comparison + const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss"); + const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss"); + + if (start.isSame(end, 'day')) { + // Single day selection + return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`; + } + + return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`; + } + + // For predefined ranges, calculate based on user's timezone + const now = moment.tz(userTimezone); + let startDate, endDate; + + switch (key) { + case DATE_RANGES.YESTERDAY: + startDate = now.clone().subtract(1, 'day').startOf('day'); + endDate = now.clone().subtract(1, 'day').endOf('day'); + break; + case DATE_RANGES.LAST_WEEK: + startDate = now.clone().subtract(1, 'week').startOf('week'); + endDate = now.clone().subtract(1, 'week').endOf('week'); + break; + case DATE_RANGES.LAST_MONTH: + startDate = now.clone().subtract(1, 'month').startOf('month'); + endDate = now.clone().subtract(1, 'month').endOf('month'); + break; + case DATE_RANGES.LAST_QUARTER: + startDate = now.clone().subtract(3, 'months').startOf('day'); + endDate = now.clone().endOf('day'); + break; + default: + return ""; + } + + if (startDate && endDate) { + const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss"); + const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss"); + return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`; + } + + return ""; + } + + /** + * Format dates for display in user's timezone + * @param date - Date to format + * @param userTimezone - User's timezone + * @param format - Moment format string + * @returns Formatted date string + */ + protected static formatDateInTimezone(date: string | Date, userTimezone: string, format: string = "YYYY-MM-DD HH:mm:ss") { + return moment.tz(date, userTimezone).format(format); + } + + /** + * Get working days count between two dates in user's timezone + * @param startDate - Start date + * @param endDate - End date + * @param userTimezone - User's timezone + * @returns Number of working days + */ + protected static getWorkingDaysInTimezone(startDate: string, endDate: string, userTimezone: string): number { + const start = moment.tz(startDate, userTimezone); + const end = moment.tz(endDate, userTimezone); + let workingDays = 0; + + const current = start.clone(); + while (current.isSameOrBefore(end, 'day')) { + // Monday = 1, Friday = 5 + if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) { + workingDays++; + } + current.add(1, 'day'); + } + + return workingDays; + } +} \ No newline at end of file diff --git a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts index 97500437..5789bf02 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts @@ -6,10 +6,69 @@ import { IWorkLenzResponse } from "../../interfaces/worklenz-response"; import { ServerResponse } from "../../models/server-response"; import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants"; import { formatDuration, getColor, int } from "../../shared/utils"; -import ReportingControllerBase from "./reporting-controller-base"; +import ReportingControllerBaseWithTimezone from "./reporting-controller-base-with-timezone"; import Excel from "exceljs"; -export default class ReportingMembersController extends ReportingControllerBase { +export default class ReportingMembersController extends ReportingControllerBaseWithTimezone { + + protected static getPercentage(n: number, total: number) { + return +(n ? (n / total) * 100 : 0).toFixed(); + } + + protected static getCurrentTeamId(req: IWorkLenzRequest): string | null { + return req.user?.team_id ?? null; + } + + public static convertMinutesToHoursAndMinutes(totalMinutes: number) { + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}h ${minutes}m`; + } + + public static convertSecondsToHoursAndMinutes(seconds: number) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; + } + + protected static formatEndDate(endDate: string) { + const end = moment(endDate).format("YYYY-MM-DD"); + const fEndDate = moment(end); + return fEndDate; + } + + protected static formatCurrentDate() { + const current = moment().format("YYYY-MM-DD"); + const fCurrentDate = moment(current); + return fCurrentDate; + } + + protected static getDaysLeft(endDate: string): number | null { + if (!endDate) return null; + + const fCurrentDate = this.formatCurrentDate(); + const fEndDate = this.formatEndDate(endDate); + + return fEndDate.diff(fCurrentDate, "days"); + } + + protected static isOverdue(endDate: string): boolean { + if (!endDate) return false; + + const fCurrentDate = this.formatCurrentDate(); + const fEndDate = this.formatEndDate(endDate); + + return fEndDate.isBefore(fCurrentDate); + } + + protected static isToday(endDate: string): boolean { + if (!endDate) return false; + + const fCurrentDate = this.formatCurrentDate(); + const fEndDate = this.formatEndDate(endDate); + + return fEndDate.isSame(fCurrentDate); + } private static async getMembers( teamId: string, searchQuery = "", @@ -487,7 +546,9 @@ export default class ReportingMembersController extends ReportingControllerBase dateRange = date_range.split(","); } - const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "twl"); + // Get user timezone for proper date filtering + const userTimezone = await this.getUserTimezone(req.user?.id as string); + const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone); const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log"); const memberName = (req.query.member_name as string)?.trim() || null; @@ -1038,7 +1099,9 @@ export default class ReportingMembersController extends ReportingControllerBase public static async getMemberTimelogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const { team_member_id, team_id, duration, date_range, archived, billable } = req.body; - const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration || DATE_RANGES.LAST_WEEK, date_range, "twl"); + // Get user timezone for proper date filtering + const userTimezone = await this.getUserTimezone(req.user?.id as string); + const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone); const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log"); const billableQuery = this.buildBillableQuery(billable); @@ -1230,8 +1293,8 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen row.actual_time = int(row.actual_time); row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time)); row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time)); - row.days_left = ReportingControllerBase.getDaysLeft(row.end_date); - row.is_overdue = ReportingControllerBase.isOverdue(row.end_date); + row.days_left = this.getDaysLeft(row.end_date); + row.is_overdue = this.isOverdue(row.end_date); if (row.days_left && row.is_overdue) { row.days_left = row.days_left.toString().replace(/-/g, ""); } diff --git a/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.updated.ts b/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.updated.ts new file mode 100644 index 00000000..a02b5ca2 --- /dev/null +++ b/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.updated.ts @@ -0,0 +1,92 @@ +import { API_BASE_URL } from '@/shared/constants'; +import { toQueryString } from '@/utils/toQueryString'; +import apiClient from '../api-client'; +import { IServerResponse } from '@/types/common.types'; +import { IAllocationViewModel } from '@/types/reporting/reporting-allocation.types'; +import { + IProjectLogsBreakdown, + IRPTTimeMember, + IRPTTimeProject, + ITimeLogBreakdownReq, +} from '@/types/reporting/reporting.types'; + +const rootUrl = `${API_BASE_URL}/reporting`; + +// Helper function to get user's timezone +const getUserTimezone = () => { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +}; + +export const reportingTimesheetApiService = { + getTimeSheetData: async ( + body = {}, + archived = false + ): Promise> => { + const q = toQueryString({ archived }); + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/allocation/${q}`, bodyWithTimezone); + return response.data; + }, + + getAllocationProjects: async (body = {}) => { + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/allocation/allocation-projects`, bodyWithTimezone); + return response.data; + }, + + getProjectTimeSheets: async ( + body = {}, + archived = false + ): Promise> => { + const q = toQueryString({ archived }); + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/time-reports/projects/${q}`, bodyWithTimezone); + return response.data; + }, + + getMemberTimeSheets: async ( + body = {}, + archived = false + ): Promise> => { + const q = toQueryString({ archived }); + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, bodyWithTimezone); + return response.data; + }, + + getProjectTimeLogs: async ( + body: ITimeLogBreakdownReq + ): Promise> => { + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/project-timelogs`, bodyWithTimezone); + return response.data; + }, + + getProjectEstimatedVsActual: async ( + body = {}, + archived = false + ): Promise> => { + const q = toQueryString({ archived }); + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/time-reports/estimated-vs-actual${q}`, bodyWithTimezone); + return response.data; + }, +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx index 3f786959..28126441 100644 --- a/worklenz-frontend/src/components/AssigneeSelector.tsx +++ b/worklenz-frontend/src/components/AssigneeSelector.tsx @@ -13,6 +13,8 @@ import { sortTeamMembers } from '@/utils/sort-team-members'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { setIsFromAssigner, toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import useIsProjectManager from '@/hooks/useIsProjectManager'; +import { useAuthStatus } from '@/hooks/useAuthStatus'; interface AssigneeSelectorProps { task: IProjectTask; @@ -21,9 +23,9 @@ interface AssigneeSelectorProps { kanbanMode?: boolean; } -const AssigneeSelector: React.FC = ({ - task, - groupId = null, +const AssigneeSelector: React.FC = ({ + task, + groupId = null, isDarkMode = false, kanbanMode = false }) => { @@ -42,6 +44,8 @@ const AssigneeSelector: React.FC = ({ const currentSession = useAuthService().getCurrentSession(); const { socket } = useSocket(); const dispatch = useAppDispatch(); + const { isAdmin } = useAuthStatus(); + const isProjectManager = useIsProjectManager(); const filteredMembers = useMemo(() => { return teamMembers?.data?.filter(member => @@ -64,7 +68,7 @@ const AssigneeSelector: React.FC = ({ useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) && - buttonRef.current && !buttonRef.current.contains(event.target as Node)) { + buttonRef.current && !buttonRef.current.contains(event.target as Node)) { setIsOpen(false); } }; @@ -74,10 +78,10 @@ const AssigneeSelector: React.FC = ({ // Check if the button is still visible in the viewport if (buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect(); - const isVisible = rect.top >= 0 && rect.left >= 0 && - rect.bottom <= window.innerHeight && - rect.right <= window.innerWidth; - + const isVisible = rect.top >= 0 && rect.left >= 0 && + rect.bottom <= window.innerHeight && + rect.right <= window.innerWidth; + if (isVisible) { updateDropdownPosition(); } else { @@ -98,7 +102,7 @@ const AssigneeSelector: React.FC = ({ document.addEventListener('mousedown', handleClickOutside); window.addEventListener('scroll', handleScroll, true); window.addEventListener('resize', handleResize); - + return () => { document.removeEventListener('mousedown', handleClickOutside); window.removeEventListener('scroll', handleScroll, true); @@ -113,10 +117,10 @@ const AssigneeSelector: React.FC = ({ const handleDropdownToggle = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - + if (!isOpen) { updateDropdownPosition(); - + // Prepare team members data when opening const assignees = task?.assignees?.map(assignee => assignee.team_member_id); const membersData = (members?.data || []).map(member => ({ @@ -125,7 +129,7 @@ const AssigneeSelector: React.FC = ({ })); const sortedMembers = sortTeamMembers(membersData); setTeamMembers({ data: sortedMembers }); - + setIsOpen(true); // Focus search input after opening setTimeout(() => { @@ -160,8 +164,8 @@ const AssigneeSelector: React.FC = ({ // Update local team members state for dropdown UI setTeamMembers(prev => ({ ...prev, - data: (prev.data || []).map(member => - member.id === memberId + data: (prev.data || []).map(member => + member.id === memberId ? { ...member, selected: checked } : member ) @@ -198,8 +202,8 @@ const AssigneeSelector: React.FC = ({ const checkMemberSelected = (memberId: string) => { if (!memberId) return false; // Use optimistic assignees if available, otherwise fall back to task assignees - const assignees = optimisticAssignees.length > 0 - ? optimisticAssignees + const assignees = optimisticAssignees.length > 0 + ? optimisticAssignees : task?.assignees?.map(assignee => assignee.team_member_id) || []; return assignees.includes(memberId); }; @@ -218,12 +222,12 @@ const AssigneeSelector: React.FC = ({ className={` w-5 h-5 rounded-full border border-dashed flex items-center justify-center transition-colors duration-200 - ${isOpen - ? isDarkMode - ? 'border-blue-500 bg-blue-900/20 text-blue-400' + ${isOpen + ? isDarkMode + ? 'border-blue-500 bg-blue-900/20 text-blue-400' : 'border-blue-500 bg-blue-50 text-blue-600' - : isDarkMode - ? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400' + : isDarkMode + ? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400' : 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600' } `} @@ -237,8 +241,8 @@ const AssigneeSelector: React.FC = ({ onClick={e => e.stopPropagation()} className={` fixed z-[99999] w-72 rounded-md shadow-lg border - ${isDarkMode - ? 'bg-gray-800 border-gray-600' + ${isDarkMode + ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200' } `} @@ -274,10 +278,10 @@ const AssigneeSelector: React.FC = ({ key={member.id} className={` flex items-center gap-2 p-2 cursor-pointer transition-colors - ${member.pending_invitation - ? 'opacity-50 cursor-not-allowed' - : isDarkMode - ? 'hover:bg-gray-700' + ${member.pending_invitation + ? 'opacity-50 cursor-not-allowed' + : isDarkMode + ? 'hover:bg-gray-700' : 'hover:bg-gray-50' } `} @@ -302,23 +306,21 @@ const AssigneeSelector: React.FC = ({ /> {pendingChanges.has(member.id || '') && ( -
-
+
+
)}
- + - +
{member.name} @@ -340,22 +342,26 @@ const AssigneeSelector: React.FC = ({
{/* Footer */} -
- -
+ + {(isAdmin || isProjectManager) && ( +
+ +
+ )} +
, document.body )} diff --git a/worklenz-frontend/src/components/projects/project-member-invite-drawer/project-member-invite-drawer.tsx b/worklenz-frontend/src/components/projects/project-member-invite-drawer/project-member-invite-drawer.tsx index 00c01d93..01f423e8 100644 --- a/worklenz-frontend/src/components/projects/project-member-invite-drawer/project-member-invite-drawer.tsx +++ b/worklenz-frontend/src/components/projects/project-member-invite-drawer/project-member-invite-drawer.tsx @@ -33,6 +33,12 @@ const ProjectMemberDrawer = () => { const [members, setMembers] = useState({ data: [], total: 0 }); const [teamMembersLoading, setTeamMembersLoading] = useState(false); + // Filter out members already in the project + const currentProjectMemberIds = (currentMembersList || []).map(m => m.team_member_id).filter(Boolean); + const availableMembers = (members?.data || []).filter( + member => member.id && !currentProjectMemberIds.includes(member.id) + ); + const fetchProjectMembers = async () => { if (!projectId) return; dispatch(getAllProjectMembers(projectId)); @@ -226,7 +232,7 @@ const ProjectMemberDrawer = () => { onSearch={handleSearch} onChange={handleSelectChange} onKeyDown={handleKeyDown} - options={members?.data?.map(member => ({ + options={availableMembers.map(member => ({ key: member.id, value: member.id, name: member.name, diff --git a/worklenz-frontend/src/components/settings/update-member-drawer.tsx b/worklenz-frontend/src/components/settings/update-member-drawer.tsx index 5be63897..27050dc3 100644 --- a/worklenz-frontend/src/components/settings/update-member-drawer.tsx +++ b/worklenz-frontend/src/components/settings/update-member-drawer.tsx @@ -94,7 +94,7 @@ const UpdateMemberDrawer = ({ selectedMemberId, onRoleUpdate }: UpdateMemberDraw try { const body: ITeamMemberCreateRequest = { - job_title: selectedJobTitle, + job_title: form.getFieldValue('jobTitle'), emails: [teamMember.email], is_admin: values.access === 'admin', }; diff --git a/worklenz-frontend/src/hooks/useUserTimezone.ts b/worklenz-frontend/src/hooks/useUserTimezone.ts new file mode 100644 index 00000000..5d665a37 --- /dev/null +++ b/worklenz-frontend/src/hooks/useUserTimezone.ts @@ -0,0 +1,70 @@ +import { useState, useEffect } from 'react'; + +/** + * Custom hook to get and manage user's timezone + * @returns {Object} Object containing timezone and related utilities + */ +export const useUserTimezone = () => { + const [timezone, setTimezone] = useState('UTC'); + const [timezoneOffset, setTimezoneOffset] = useState('+00:00'); + + useEffect(() => { + // Get browser's timezone + const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + setTimezone(browserTimezone); + + // Calculate timezone offset + const date = new Date(); + const offset = -date.getTimezoneOffset(); + const hours = Math.floor(Math.abs(offset) / 60); + const minutes = Math.abs(offset) % 60; + const sign = offset >= 0 ? '+' : '-'; + const formattedOffset = `${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + setTimezoneOffset(formattedOffset); + }, []); + + /** + * Format a date in the user's timezone + * @param date - Date to format + * @param format - Format options + * @returns Formatted date string + */ + const formatInUserTimezone = (date: Date | string, format?: Intl.DateTimeFormatOptions) => { + const dateObj = typeof date === 'string' ? new Date(date) : date; + return dateObj.toLocaleString('en-US', { + timeZone: timezone, + ...format + }); + }; + + /** + * Get the start of day in user's timezone + * @param date - Date to get start of day for + * @returns Date object representing start of day + */ + const getStartOfDayInTimezone = (date: Date = new Date()) => { + const localDate = new Date(date.toLocaleString('en-US', { timeZone: timezone })); + localDate.setHours(0, 0, 0, 0); + return localDate; + }; + + /** + * Get the end of day in user's timezone + * @param date - Date to get end of day for + * @returns Date object representing end of day + */ + const getEndOfDayInTimezone = (date: Date = new Date()) => { + const localDate = new Date(date.toLocaleString('en-US', { timeZone: timezone })); + localDate.setHours(23, 59, 59, 999); + return localDate; + }; + + return { + timezone, + timezoneOffset, + formatInUserTimezone, + getStartOfDayInTimezone, + getEndOfDayInTimezone, + setTimezone + }; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/reporting/timeReports/members-time-reports-updated.tsx b/worklenz-frontend/src/pages/reporting/timeReports/members-time-reports-updated.tsx new file mode 100644 index 00000000..7eb4e5d6 --- /dev/null +++ b/worklenz-frontend/src/pages/reporting/timeReports/members-time-reports-updated.tsx @@ -0,0 +1,67 @@ +import { Card, Flex, Typography, Tooltip } from '@/shared/antd-imports'; +import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header'; +import MembersTimeSheet, { + MembersTimeSheetRef, +} from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet'; +import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader'; +import { useTranslation } from 'react-i18next'; +import { useDocumentTitle } from '@/hooks/useDoumentTItle'; +import { useRef } from 'react'; +import { useUserTimezone } from '@/hooks/useUserTimezone'; +import { InfoCircleOutlined } from '@ant-design/icons'; + +const { Text } = Typography; + +const MembersTimeReports = () => { + const { t } = useTranslation('time-report'); + const chartRef = useRef(null); + const { timezone, timezoneOffset } = useUserTimezone(); + + useDocumentTitle('Reporting - Allocation'); + + const handleExport = (type: string) => { + if (type === 'png') { + chartRef.current?.exportChart(); + } + }; + + return ( + + + + + + + + + + Timezone: {timezone} ({timezoneOffset}) + + + +
+ } + styles={{ + body: { + maxHeight: 'calc(100vh - 300px)', + overflowY: 'auto', + padding: '16px', + }, + }} + > + + + + ); +}; + +export default MembersTimeReports; \ No newline at end of file