Compare commits

..

26 Commits

Author SHA1 Message Date
Chamika J
136dac17fb feat(labels): implement label update functionality and enhance UI
- Added `updateLabel` method in `LabelsController` to handle label updates with validation for name and color.
- Updated API routes to include the new label update endpoint.
- Introduced `LabelsDrawer` component for editing labels, including a color picker and form validation.
- Enhanced localization files to support new UI strings for label management.
- Implemented a new `LabelsSettings` page to manage labels with search and edit capabilities.
- Improved color handling with a comprehensive color palette for better user experience.
2025-08-04 12:28:52 +05:30
Chamika J
884cb9c462 refactor(SettingsLayout): streamline layout and improve styling
- Removed unused imports and simplified margin handling in SettingsLayout.
- Updated layout classes for better responsiveness and visual consistency.
- Enhanced sidebar and outlet rendering with improved Flex component usage.
- Streamlined overall layout for a cleaner and more modern appearance.
2025-08-04 09:58:49 +05:30
Chamika J
d1bd36e0a4 refactor(AdminCenterLayout): simplify layout structure and improve styling
- Removed unused imports and simplified margin handling in AdminCenterLayout.
- Updated layout classes for better responsiveness and visual consistency.
- Enhanced sidebar and outlet rendering with improved Flex component usage.
- Streamlined overall layout for a cleaner and more modern appearance.
2025-08-04 09:55:56 +05:30
Chamika J
7c42087854 feat(update-notification): implement update notification system for new versions
- Added a service worker message handler to check for updates and notify users.
- Created `UpdateNotification` component to display update prompts with options to reload or dismiss.
- Introduced `UpdateNotificationProvider` to manage update state and notifications globally.
- Implemented `useUpdateChecker` hook for periodic update checks and user notification management.
- Updated localization files to include new strings related to update notifications.
- Enhanced service worker functionality to support hard reloads and update checks.
2025-07-31 16:12:04 +05:30
Chamika J
14c89dec24 chore(tests): remove obsolete SQL test scripts for sort order validation
- Deleted `test_sort_fix.sql` and `test_sort_orders.sql` as they are no longer needed for the current implementation.
- These scripts were previously used to verify sort order constraints and helper functions, but have been deemed unnecessary.
2025-07-31 15:51:43 +05:30
Chamika J
b1bdf0ac11 feat(hubspot): add dark mode support and light color scheme for chat widget
- Removed production check to ensure HubSpot script loads in all environments.
- Implemented dark mode CSS injection when the dark theme is active.
- Added a style block to enforce a light color scheme for the HubSpot chat widget, improving visibility and user experience.
2025-07-31 15:44:04 +05:30
Chamika J
7635676289 feat(trial-user-limits): implement trial member limit checks in project and team controllers
- Added TRIAL_MEMBER_LIMIT constant to enforce a maximum number of trial users in project and team member controllers.
- Implemented logic to check current trial members against the limit during user addition, providing appropriate responses for exceeding limits.
- Updated relevant controllers to utilize the new trial member limit functionality, enhancing subscription management for trial users.
- Enhanced error messaging to guide users on upgrading their subscription for additional members.
2025-07-31 12:56:28 +05:30
chamikaJ
2bd6c19c13 refactor(layouts): simplify MainLayout and enhance styling
- Removed unused imports and performance monitoring hooks from MainLayout.
- Updated layout structure to improve responsiveness and styling, including sticky header and optimized content padding.
- Adjusted home page layout to reduce margin and improve spacing for better visual consistency.
- Enhanced TodoList component with collapsible sections for improved user interaction and task management.
- Streamlined project and schedule pages by removing unnecessary margin adjustments, ensuring a cleaner layout.
2025-07-30 17:20:20 +05:30
chamikaJ
374595261f feat(task-list-v2): enhance sticky column behavior and dark mode support
- Updated DropSpacer and EmptyGroupMessage components to accept an optional isDarkMode prop for improved styling in dark mode.
- Enhanced task rendering in TaskRow to dynamically adjust background colors based on dark mode and drag states.
- Refactored useTaskRowColumns to support sticky column positioning and hover effects, ensuring a consistent user experience across different themes.
- Improved overall visual feedback during task interactions, including drag-and-drop operations.
2025-07-30 16:25:29 +05:30
chamikaJ
b6c056dd1a feat(task-status-dropdown): enhance task status update and group movement handling
- Added logic to optimistically update task status in Redux for immediate feedback.
- Implemented group movement handling when tasks are updated based on their status, ensuring tasks are moved between groups as needed.
- Improved socket event emissions for real-time updates, including parent task handling.
- Refactored group selection logic to streamline target group identification based on status ID and group value.
2025-07-30 15:26:27 +05:30
chamikaJ
81e1872c1f refactor(task-list): simplify drag-and-drop functionality and enhance task rendering
- Removed droppable functionality from TaskGroupHeader and replaced it with a more streamlined approach in TaskListV2Table.
- Introduced DropSpacer component to improve visual feedback during task dragging.
- Updated task rendering logic in TaskRow to enhance user experience with clearer drop indicators.
- Refactored useDragAndDrop hook to manage drop positions more effectively, ensuring tasks can only be reordered within the same group.
- Improved socket event handling for task sorting to ensure accurate updates during drag-and-drop operations.
2025-07-30 15:08:28 +05:30
chamikaJ
5cce3bc613 feat(localization): add timer conflict handling and update translations
- Introduced a new hook `useTaskTimerWithConflictCheck` to manage timer conflicts, prompting users when a timer is already running for a different task.
- Updated localization files for Albanian, German, English, Spanish, Portuguese, and Chinese to include new translation keys related to timer conflict handling and cancellation.
- Refactored components to utilize the new timer hook, enhancing user experience by preventing overlapping timers.
2025-07-30 10:13:08 +05:30
chamikaJ
c53ab511bf Merge branch 'main' of https://github.com/Worklenz/worklenz into release-v2.1.4 2025-07-29 14:19:44 +05:30
Chamika J
7b9a16fd72 Merge pull request #307 from Worklenz/development
refactor: update .gitignore and remove obsolete files
2025-07-29 14:16:55 +05:30
chamikaJ
b915de2b93 feat(reporting): enhance date handling and export functionality
- Improved date range handling in ReportingControllerBaseWithTimezone to support various date formats from the frontend, ensuring robust parsing and timezone conversion.
- Updated SQL queries to use consistent table aliases for clarity.
- Added export functionality for project members and tasks in ProjectReportsDrawer, allowing users to download relevant data.
- Enhanced the Excel export handler in ProjectsReports to streamline project data exports based on the current session's team name.
2025-07-29 13:05:55 +05:30
chamikaJ
29b8c1b2af feat(task-context-menu): add copy link functionality and update translations
- Implemented a new "Copy link to task" feature in the task context menu, allowing users to easily copy task links to the clipboard.
- Added corresponding success and error messages for link copying.
- Updated localization files for Albanian, German, English, Spanish, Portuguese, and Chinese to include new translation keys for the copy link feature.
2025-07-29 12:49:51 +05:30
chamikaJ
c2b231d5cc feat(survey-modal): add survey modal configuration to environment files
- Introduced a new environment variable VITE_ENABLE_SURVEY_MODAL to control the visibility of the survey modal.
- Updated .env.example to include the new variable with a default value of true.
- Modified SurveyPromptModal component to check the environment variable before displaying the modal, enhancing user experience by allowing toggling of the feature.
2025-07-29 11:13:08 +05:30
chamikaJ
53a28cf489 refactor(localization): update task-related translations and improve user activity feed
- Added new translation keys for recent tasks and time logged tasks in Albanian, German, English, Spanish, Portuguese, and Chinese localization files.
- Enhanced user activity feed to switch between recent tasks and time logged tasks, improving user experience.
- Updated the date formatting utility to support locale-specific formatting for better internationalization.
- Refactored task activity list and time logged task list components to utilize a table layout for improved readability.
2025-07-29 10:19:28 +05:30
Chamika J
e8ccc2a533 Merge pull request #306 from Worklenz/feature/task-activities-by-user
Feature/task activities by user
2025-07-29 08:46:13 +05:30
Chamika J
f24c0d8955 Merge branch 'release-v2.1.4' into feature/task-activities-by-user 2025-07-29 08:46:05 +05:30
chamikaJ
61461bb776 feat(user-activity): enhance user activity logs with additional data and improved queries
- Added optional fields for project color, task status, and status color in IUserRecentTask and IUserTimeLoggedTask interfaces.
- Optimized SQL queries to include team filtering and additional data such as project color and task status.
- Updated frontend components to support new data fields and improved styling for better user experience.
- Enhanced dark mode detection and styling in task activity lists.
- Implemented refetching of data on tab change in the user activity feed.
2025-07-14 13:26:28 +05:30
chamikaJ
2a7019c64c refactor(home-page): simplify layout by removing unnecessary Card component
- Removed the Card wrapper around the TasksList for a cleaner layout.
- Cleaned up whitespace in the home-page.tsx file for improved readability.
2025-07-14 12:48:13 +05:30
chamikaJ
5b1cbb0c46 Merge branch 'main' of https://github.com/Worklenz/worklenz into feature/task-activities-by-user 2025-07-14 12:46:18 +05:30
Chamika J
7c04598264 Merge pull request #253 from OminduHirushka/imp/user-activity-feed
user activity feed
2025-07-11 11:27:42 +05:30
Omindu Hirushka
5222d75064 user activity feed - frontend 2025-07-09 07:44:23 +05:30
Omindu Hirushka
2587b8afd9 user activity feed - backend 2025-07-09 07:43:44 +05:30
101 changed files with 5450 additions and 1120 deletions

View File

@@ -1,41 +0,0 @@
-- Test script to verify the sort order constraint fix
-- Test the helper function
SELECT get_sort_column_name('status'); -- Should return 'status_sort_order'
SELECT get_sort_column_name('priority'); -- Should return 'priority_sort_order'
SELECT get_sort_column_name('phase'); -- Should return 'phase_sort_order'
SELECT get_sort_column_name('members'); -- Should return 'member_sort_order'
SELECT get_sort_column_name('unknown'); -- Should return 'status_sort_order' (default)
-- Test bulk update function (example - would need real project_id and task_ids)
/*
SELECT update_task_sort_orders_bulk(
'[
{"task_id": "example-uuid", "sort_order": 1, "status_id": "status-uuid"},
{"task_id": "example-uuid-2", "sort_order": 2, "status_id": "status-uuid"}
]'::json,
'status'
);
*/
-- Verify that sort_order constraint still exists and works
SELECT
tc.constraint_name,
tc.table_name,
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_name = 'tasks_sort_order_unique';
-- Check that new sort order columns don't have unique constraints (which is correct)
SELECT
tc.constraint_name,
tc.table_name,
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE kcu.table_name = 'tasks'
AND kcu.column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order')
AND tc.constraint_type = 'UNIQUE';

View File

@@ -1,30 +0,0 @@
-- Test script to validate the separate sort order implementation
-- Check if new columns exist
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'tasks'
AND column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order')
ORDER BY column_name;
-- Check if helper function exists
SELECT routine_name, routine_type
FROM information_schema.routines
WHERE routine_name IN ('get_sort_column_name', 'update_task_sort_orders_bulk', 'handle_task_list_sort_order_change');
-- Sample test data to verify different sort orders work
-- (This would be run after the migrations)
/*
-- Test: Tasks should have different orders for different groupings
SELECT
id,
name,
sort_order,
status_sort_order,
priority_sort_order,
phase_sort_order,
member_sort_order
FROM tasks
WHERE project_id = '<test-project-id>'
ORDER BY status_sort_order;
*/

View File

@@ -80,6 +80,37 @@ export default class LabelsController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async updateLabel(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const updates = [];
const values = [req.params.id, req.user?.team_id];
let paramIndex = 3;
if (req.body.name) {
updates.push(`name = $${paramIndex++}`);
values.push(req.body.name);
}
if (req.body.color) {
if (!WorklenzColorCodes.includes(req.body.color))
return res.status(400).send(new ServerResponse(false, null));
updates.push(`color_code = $${paramIndex++}`);
values.push(req.body.color);
}
if (updates.length === 0) {
return res.status(400).send(new ServerResponse(false, "No valid fields to update"));
}
const q = `UPDATE team_labels
SET ${updates.join(', ')}
WHERE id = $1
AND team_id = $2;`;
const result = await db.query(q, values);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `DELETE

View File

@@ -9,7 +9,7 @@ import {getColor} from "../shared/utils";
import TeamMembersController from "./team-members-controller";
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
import {updateUsers} from "../shared/paddle-requests";
import {statusExclude} from "../shared/constants";
import {statusExclude, TRIAL_MEMBER_LIMIT} from "../shared/constants";
import {NotificationsService} from "../services/notifications/notifications.service";
export default class ProjectMembersController extends WorklenzControllerBase {
@@ -118,6 +118,17 @@ export default class ProjectMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
if (currentTrialMembers + 1 > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
// if (subscriptionData.status === "trialing") break;
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
// if (subscriptionData.subscription_status === "active") {

View File

@@ -17,7 +17,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
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';
return result.rows[0]?.timezone || "UTC";
}
/**
@@ -25,26 +25,48 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
* @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')
* @param tableAlias - Table alias to use (e.g., 'twl', 'task_work_log')
* @returns SQL clause for date filtering
*/
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string, tableAlias: string = 'task_work_log') {
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 ${tableAlias}.created_at >= '${startUtc}'::TIMESTAMP AND ${tableAlias}.created_at <= '${endUtc}'::TIMESTAMP`;
try {
// Handle different date formats that might come from frontend
let startDate, endDate;
// Try to parse the date - it might be a full JS Date string or ISO string
if (dateRange[0].includes("GMT") || dateRange[0].includes("(")) {
// Parse JavaScript Date toString() format
startDate = moment(new Date(dateRange[0]));
endDate = moment(new Date(dateRange[1]));
} else {
// Parse ISO format or other formats
startDate = moment(dateRange[0]);
endDate = moment(dateRange[1]);
}
// Convert to user's timezone and get start/end of day
const start = startDate.tz(userTimezone).startOf("day");
const end = endDate.tz(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 twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
}
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
} catch (error) {
console.error("Error parsing date range:", error, { dateRange, userTimezone });
// Fallback to current date if parsing fails
const now = moment.tz(userTimezone);
const startUtc = now.clone().startOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
const endUtc = now.clone().endOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
}
return `AND ${tableAlias}.created_at >= '${startUtc}'::TIMESTAMP AND ${tableAlias}.created_at <= '${endUtc}'::TIMESTAMP`;
}
// For predefined ranges, calculate based on user's timezone
@@ -53,20 +75,20 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
switch (key) {
case DATE_RANGES.YESTERDAY:
startDate = now.clone().subtract(1, 'day').startOf('day');
endDate = now.clone().subtract(1, 'day').endOf('day');
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');
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');
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');
startDate = now.clone().subtract(3, "months").startOf("day");
endDate = now.clone().endOf("day");
break;
default:
return "";
@@ -75,7 +97,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
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 ${tableAlias}.created_at >= '${startUtc}'::TIMESTAMP AND ${tableAlias}.created_at <= '${endUtc}'::TIMESTAMP`;
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
}
return "";
@@ -88,7 +110,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
* @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") {
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format = "YYYY-MM-DD HH:mm:ss") {
return moment.tz(date, userTimezone).format(format);
}
@@ -105,12 +127,12 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
let workingDays = 0;
const current = start.clone();
while (current.isSameOrBefore(end, 'day')) {
while (current.isSameOrBefore(end, "day")) {
// Monday = 1, Friday = 5
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
workingDays++;
}
current.add(1, 'day');
current.add(1, "day");
}
return workingDays;

View File

@@ -324,8 +324,8 @@ export default class ReportingMembersController extends ReportingControllerBaseW
(SELECT color_code FROM project_phases WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_color,
(total_minutes * 60) AS total_minutes,
(SELECT SUM(time_spent) FROM task_work_log twl WHERE twl.task_id = t.id AND twl.user_id = (SELECT user_id FROM team_members WHERE id = $1)) AS time_logged,
((SELECT SUM(time_spent) FROM task_work_log twl WHERE twl.task_id = t.id AND twl.user_id = (SELECT user_id FROM team_members WHERE id = $1)) - (total_minutes * 60)) AS overlogged_time`;
(SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id AND ta.team_member_id = $1) AS time_logged,
((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id AND ta.team_member_id = $1) - (total_minutes * 60)) AS overlogged_time`;
}
protected static getActivityLogsOverdue(key: string, dateRange: string[]) {
@@ -548,7 +548,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
// 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, 'twl');
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;
@@ -1101,7 +1101,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
// 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, 'twl');
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);

View File

@@ -13,7 +13,7 @@ import { SocketEvents } from "../socket.io/events";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { formatDuration, getColor } from "../shared/utils";
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA } from "../shared/constants";
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA, TRIAL_MEMBER_LIMIT } from "../shared/constants";
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
import { updateUsers } from "../shared/paddle-requests";
import { NotificationsService } from "../services/notifications/notifications.service";
@@ -141,6 +141,17 @@ export default class TeamMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
if (currentTrialMembers + incrementBy > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
/**
* Checks subscription details and updates the user count if applicable.
* Sends a response if there is an issue with the subscription.
@@ -1081,6 +1092,18 @@ export default class TeamMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
}
/**
* Checks trial user team member limit
*/
if (subscriptionData.subscription_status === "trialing") {
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
const emailsToAdd = req.body.emails?.length || 1;
if (currentTrialMembers + emailsToAdd > TRIAL_MEMBER_LIMIT) {
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
}
}
// if (subscriptionData.status === "trialing") break;
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
if (subscriptionData.subscription_status === "active") {

View File

@@ -0,0 +1,117 @@
import moment from "moment";
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { formatDuration, formatLogText, getColor } from "../shared/utils";
interface IUserRecentTask {
task_id: string;
task_name: string;
project_id: string;
project_name: string;
last_activity_at: string;
activity_count: number;
project_color?: string;
task_status?: string;
status_color?: string;
}
interface IUserTimeLoggedTask {
task_id: string;
task_name: string;
project_id: string;
project_name: string;
total_time_logged: number;
total_time_logged_string: string;
last_logged_at: string;
logged_by_timer: boolean;
project_color?: string;
task_status?: string;
status_color?: string;
log_entries_count?: number;
estimated_time?: number;
}
export default class UserActivityLogsController extends WorklenzControllerBase {
@HandleExceptions()
public static async getRecentTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user) {
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
}
const { id: userId, team_id: teamId } = req.user;
const { offset = 0, limit = 10 } = req.query;
// Optimized query with better performance and team filtering
const q = `
SELECT DISTINCT tal.task_id, t.name AS task_name, tal.project_id, p.name AS project_name,
MAX(tal.created_at) AS last_activity_at,
COUNT(DISTINCT tal.id) AS activity_count,
p.color_code AS project_color,
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color
FROM task_activity_logs tal
INNER JOIN tasks t ON tal.task_id = t.id AND t.archived = FALSE
INNER JOIN projects p ON tal.project_id = p.id AND p.team_id = $1
WHERE tal.user_id = $2
AND tal.created_at >= NOW() - INTERVAL '30 days'
GROUP BY tal.task_id, t.name, tal.project_id, p.name, p.color_code, t.status_id
ORDER BY MAX(tal.created_at) DESC
LIMIT $3 OFFSET $4;
`;
const result = await db.query(q, [teamId, userId, limit, offset]);
const tasks: IUserRecentTask[] = result.rows;
return res.status(200).send(new ServerResponse(true, tasks));
}
@HandleExceptions()
public static async getTimeLoggedTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user) {
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
}
const { id: userId, team_id: teamId } = req.user;
const { offset = 0, limit = 10 } = req.query;
// Optimized query with better performance, team filtering, and useful additional data
const q = `
SELECT twl.task_id, t.name AS task_name, t.project_id, p.name AS project_name,
SUM(twl.time_spent) AS total_time_logged,
MAX(twl.created_at) AS last_logged_at,
MAX(twl.logged_by_timer::int)::boolean AS logged_by_timer,
p.color_code AS project_color,
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
COUNT(DISTINCT twl.id) AS log_entries_count,
(t.total_minutes * 60) AS estimated_time
FROM task_work_log twl
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived = FALSE
INNER JOIN projects p ON t.project_id = p.id AND p.team_id = $1
WHERE twl.user_id = $2
AND twl.created_at >= NOW() - INTERVAL '90 days'
GROUP BY twl.task_id, t.name, t.project_id, p.name, p.color_code, t.status_id, t.total_minutes
HAVING SUM(twl.time_spent) > 0
ORDER BY MAX(twl.created_at) DESC
LIMIT $3 OFFSET $4;
`;
const result = await db.query(q, [teamId, userId, limit, offset]);
const tasks: IUserTimeLoggedTask[] = result.rows.map(task => ({
...task,
total_time_logged_string: formatDuration(moment.duration(task.total_time_logged, "seconds")),
}));
return res.status(200).send(new ServerResponse(true, tasks));
}
}

View File

@@ -1,122 +1,125 @@
import express from "express";
import AccessControlsController from "../../controllers/access-controls-controller";
import AuthController from "../../controllers/auth-controller";
import LogsController from "../../controllers/logs-controller";
import OverviewController from "../../controllers/overview-controller";
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
import attachmentsApiRouter from "./attachments-api-router";
import clientsApiRouter from "./clients-api-router";
import jobTitlesApiRouter from "./job-titles-api-router";
import notificationsApiRouter from "./notifications-api-router";
import personalOverviewApiRouter from "./personal-overview-api-router";
import projectMembersApiRouter from "./project-members-api-router";
import projectsApiRouter from "./projects-api-router";
import settingsApiRouter from "./settings-api-router";
import statusesApiRouter from "./statuses-api-router";
import subTasksApiRouter from "./sub-tasks-api-router";
import taskCommentsApiRouter from "./task-comments-api-router";
import taskWorkLogApiRouter from "./task-work-log-api-router";
import tasksApiRouter from "./tasks-api-router";
import teamMembersApiRouter from "./team-members-api-router";
import teamsApiRouter from "./teams-api-router";
import timezonesApiRouter from "./timezones-api-router";
import todoListApiRouter from "./todo-list-api-router";
import projectStatusesApiRouter from "./project-statuses-api-router";
import labelsApiRouter from "./labels-api-router";
import sharedProjectsApiRouter from "./shared-projects-api-router";
import resourceAllocationApiRouter from "./resource-allocation-api-router";
import taskTemplatesApiRouter from "./task-templates-api-router";
import projectInsightsApiRouter from "./project-insights-api-router";
import passwordValidator from "../../middlewares/validators/password-validator";
import adminCenterApiRouter from "./admin-center-api-router";
import reportingApiRouter from "./reporting-api-router";
import activityLogsApiRouter from "./activity-logs-api-router";
import safeControllerFunction from "../../shared/safe-controller-function";
import projectFoldersApiRouter from "./project-folders-api-router";
import taskPhasesApiRouter from "./task-phases-api-router";
import projectCategoriesApiRouter from "./project-categories-api-router";
import homePageApiRouter from "./home-page-api-router";
import ganttApiRouter from "./gantt-api-router";
import projectCommentsApiRouter from "./project-comments-api-router";
import reportingExportApiRouter from "./reporting-export-api-router";
import projectHealthsApiRouter from "./project-healths-api-router";
import ptTasksApiRouter from "./pt-tasks-api-router";
import projectTemplatesApiRouter from "./project-templates-api";
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
import ptStatusesApiRouter from "./pt-statuses-api-router";
import workloadApiRouter from "./gannt-apis/workload-api-router";
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
import projectManagerApiRouter from "./project-managers-api-router";
import surveyApiRouter from "./survey-api-router";
import billingApiRouter from "./billing-api-router";
import taskDependenciesApiRouter from "./task-dependencies-api-router";
import taskRecurringApiRouter from "./task-recurring-api-router";
import customColumnsApiRouter from "./custom-columns-api-router";
const api = express.Router();
api.use("/projects", projectsApiRouter);
api.use("/team-members", teamMembersApiRouter);
api.use("/job-titles", jobTitlesApiRouter);
api.use("/clients", clientsApiRouter);
api.use("/teams", teamsApiRouter);
api.use("/tasks", tasksApiRouter);
api.use("/settings", settingsApiRouter);
api.use("/personal-overview", personalOverviewApiRouter);
api.use("/statuses", statusesApiRouter);
api.use("/todo-list", todoListApiRouter);
api.use("/notifications", notificationsApiRouter);
api.use("/attachments", attachmentsApiRouter);
api.use("/sub-tasks", subTasksApiRouter);
api.use("/project-members", projectMembersApiRouter);
api.use("/task-time-log", taskWorkLogApiRouter);
api.use("/task-comments", taskCommentsApiRouter);
api.use("/timezones", timezonesApiRouter);
api.use("/project-statuses", projectStatusesApiRouter);
api.use("/labels", labelsApiRouter);
api.use("/resource-allocation", resourceAllocationApiRouter);
api.use("/shared/projects", sharedProjectsApiRouter);
api.use("/task-templates", taskTemplatesApiRouter);
api.use("/project-insights", projectInsightsApiRouter);
api.use("/admin-center", adminCenterApiRouter);
api.use("/reporting", reportingApiRouter);
api.use("/activity-logs", activityLogsApiRouter);
api.use("/projects-folders", projectFoldersApiRouter);
api.use("/task-phases", taskPhasesApiRouter);
api.use("/project-categories", projectCategoriesApiRouter);
api.use("/home", homePageApiRouter);
api.use("/gantt", ganttApiRouter);
api.use("/project-comments", projectCommentsApiRouter);
api.use("/reporting-export", reportingExportApiRouter);
api.use("/project-healths", projectHealthsApiRouter);
api.use("/project-templates", projectTemplatesApiRouter);
api.use("/pt-tasks", ptTasksApiRouter);
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
api.use("/pt-statuses", ptStatusesApiRouter);
api.use("/workload-gannt", workloadApiRouter);
api.use("/roadmap-gannt", roadmapApiRouter);
api.use("/schedule-gannt", scheduleApiRouter);
api.use("/schedule-gannt-v2", scheduleApiV2Router);
api.use("/project-managers", projectManagerApiRouter);
api.use("/surveys", surveyApiRouter);
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
api.use("/billing", billingApiRouter);
api.use("/task-dependencies", taskDependenciesApiRouter);
api.use("/task-recurring", taskRecurringApiRouter);
api.use("/custom-columns", customColumnsApiRouter);
import express from "express";
import AccessControlsController from "../../controllers/access-controls-controller";
import AuthController from "../../controllers/auth-controller";
import LogsController from "../../controllers/logs-controller";
import OverviewController from "../../controllers/overview-controller";
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
import attachmentsApiRouter from "./attachments-api-router";
import clientsApiRouter from "./clients-api-router";
import jobTitlesApiRouter from "./job-titles-api-router";
import notificationsApiRouter from "./notifications-api-router";
import personalOverviewApiRouter from "./personal-overview-api-router";
import projectMembersApiRouter from "./project-members-api-router";
import projectsApiRouter from "./projects-api-router";
import settingsApiRouter from "./settings-api-router";
import statusesApiRouter from "./statuses-api-router";
import subTasksApiRouter from "./sub-tasks-api-router";
import taskCommentsApiRouter from "./task-comments-api-router";
import taskWorkLogApiRouter from "./task-work-log-api-router";
import tasksApiRouter from "./tasks-api-router";
import teamMembersApiRouter from "./team-members-api-router";
import teamsApiRouter from "./teams-api-router";
import timezonesApiRouter from "./timezones-api-router";
import todoListApiRouter from "./todo-list-api-router";
import projectStatusesApiRouter from "./project-statuses-api-router";
import labelsApiRouter from "./labels-api-router";
import sharedProjectsApiRouter from "./shared-projects-api-router";
import resourceAllocationApiRouter from "./resource-allocation-api-router";
import taskTemplatesApiRouter from "./task-templates-api-router";
import projectInsightsApiRouter from "./project-insights-api-router";
import passwordValidator from "../../middlewares/validators/password-validator";
import adminCenterApiRouter from "./admin-center-api-router";
import reportingApiRouter from "./reporting-api-router";
import activityLogsApiRouter from "./activity-logs-api-router";
import safeControllerFunction from "../../shared/safe-controller-function";
import projectFoldersApiRouter from "./project-folders-api-router";
import taskPhasesApiRouter from "./task-phases-api-router";
import projectCategoriesApiRouter from "./project-categories-api-router";
import homePageApiRouter from "./home-page-api-router";
import ganttApiRouter from "./gantt-api-router";
import projectCommentsApiRouter from "./project-comments-api-router";
import reportingExportApiRouter from "./reporting-export-api-router";
import projectHealthsApiRouter from "./project-healths-api-router";
import ptTasksApiRouter from "./pt-tasks-api-router";
import projectTemplatesApiRouter from "./project-templates-api";
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
import ptStatusesApiRouter from "./pt-statuses-api-router";
import workloadApiRouter from "./gannt-apis/workload-api-router";
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
import projectManagerApiRouter from "./project-managers-api-router";
import surveyApiRouter from "./survey-api-router";
import billingApiRouter from "./billing-api-router";
import taskDependenciesApiRouter from "./task-dependencies-api-router";
import taskRecurringApiRouter from "./task-recurring-api-router";
import customColumnsApiRouter from "./custom-columns-api-router";
import userActivityLogsApiRouter from "./user-activity-logs-api-router";
const api = express.Router();
api.use("/projects", projectsApiRouter);
api.use("/team-members", teamMembersApiRouter);
api.use("/job-titles", jobTitlesApiRouter);
api.use("/clients", clientsApiRouter);
api.use("/teams", teamsApiRouter);
api.use("/tasks", tasksApiRouter);
api.use("/settings", settingsApiRouter);
api.use("/personal-overview", personalOverviewApiRouter);
api.use("/statuses", statusesApiRouter);
api.use("/todo-list", todoListApiRouter);
api.use("/notifications", notificationsApiRouter);
api.use("/attachments", attachmentsApiRouter);
api.use("/sub-tasks", subTasksApiRouter);
api.use("/project-members", projectMembersApiRouter);
api.use("/task-time-log", taskWorkLogApiRouter);
api.use("/task-comments", taskCommentsApiRouter);
api.use("/timezones", timezonesApiRouter);
api.use("/project-statuses", projectStatusesApiRouter);
api.use("/labels", labelsApiRouter);
api.use("/resource-allocation", resourceAllocationApiRouter);
api.use("/shared/projects", sharedProjectsApiRouter);
api.use("/task-templates", taskTemplatesApiRouter);
api.use("/project-insights", projectInsightsApiRouter);
api.use("/admin-center", adminCenterApiRouter);
api.use("/reporting", reportingApiRouter);
api.use("/activity-logs", activityLogsApiRouter);
api.use("/projects-folders", projectFoldersApiRouter);
api.use("/task-phases", taskPhasesApiRouter);
api.use("/project-categories", projectCategoriesApiRouter);
api.use("/home", homePageApiRouter);
api.use("/gantt", ganttApiRouter);
api.use("/project-comments", projectCommentsApiRouter);
api.use("/reporting-export", reportingExportApiRouter);
api.use("/project-healths", projectHealthsApiRouter);
api.use("/project-templates", projectTemplatesApiRouter);
api.use("/pt-tasks", ptTasksApiRouter);
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
api.use("/pt-statuses", ptStatusesApiRouter);
api.use("/workload-gannt", workloadApiRouter);
api.use("/roadmap-gannt", roadmapApiRouter);
api.use("/schedule-gannt", scheduleApiRouter);
api.use("/schedule-gannt-v2", scheduleApiV2Router);
api.use("/project-managers", projectManagerApiRouter);
api.use("/surveys", surveyApiRouter);
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
api.use("/billing", billingApiRouter);
api.use("/task-dependencies", taskDependenciesApiRouter);
api.use("/task-recurring", taskRecurringApiRouter);
api.use("/custom-columns", customColumnsApiRouter);
api.use("/logs", userActivityLogsApiRouter);
export default api;

View File

@@ -11,6 +11,7 @@ labelsApiRouter.get("/", safeControllerFunction(LabelsController.get));
labelsApiRouter.get("/tasks/:id", idParamValidator, safeControllerFunction(LabelsController.getByTask));
labelsApiRouter.get("/project/:id", idParamValidator, safeControllerFunction(LabelsController.getByProject));
labelsApiRouter.put("/tasks/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.updateColor));
labelsApiRouter.put("/team/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.updateLabel));
labelsApiRouter.delete("/team/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.deleteById));
export default labelsApiRouter;

View File

@@ -0,0 +1,11 @@
import express from 'express';
import UserActivityLogsController from '../../controllers/user-activity-logs-controller';
import safeControllerFunction from "../../shared/safe-controller-function";
const userActivityLogsApiRouter = express.Router();
userActivityLogsApiRouter.get('/user-recent-tasks', safeControllerFunction(UserActivityLogsController.getRecentTasks));
userActivityLogsApiRouter.get('/user-time-logged-tasks', safeControllerFunction(UserActivityLogsController.getTimeLoggedTasks));
export default userActivityLogsApiRouter;

View File

@@ -6,7 +6,7 @@ export const DEFAULT_ERROR_MESSAGE = "Unknown error has occurred.";
export const SessionsStatus = {
IDLE: "IDLE",
STARTED: "STARTED",
ENDED: "ENDED"
ENDED: "ENDED",
};
export const LOG_DESCRIPTIONS = {
@@ -18,6 +18,33 @@ export const LOG_DESCRIPTIONS = {
PROJECT_MEMBER_REMOVED: "was removed from the project by",
};
export const WorklenzColorShades = {
"#154c9b": ["#0D2A50", "#112E54", "#153258", "#19365C", "#1D3A60", "#213E64", "#254268", "#29466C", "#2D4A70", "#314E74"],
"#3b7ad4": ["#224884", "#26528A", "#2A5C90", "#2E6696", "#32709C", "#367AA2", "#3A84A8", "#3E8EAE", "#4298B4", "#46A2BA"],
"#70a6f3": ["#3D5D8A", "#46679E", "#5071B2", "#597BC6", "#6385DA", "#6C8FEE", "#7699F2", "#7FA3F6", "#89ADFA", "#92B7FE"],
"#7781ca": ["#42486F", "#4C5283", "#565C97", "#6066AB", "#6A70BF", "#747AD3", "#7E84E7", "#888EFB", "#9298FF", "#9CA2FF"],
"#9877ca": ["#542D70", "#6E3A8A", "#8847A4", "#A254BE", "#BC61D8", "#D66EF2", "#E07BFC", "#EA88FF", "#F495FF", "#FEA2FF"],
"#c178c9": ["#6A2E6F", "#843B89", "#9E48A3", "#B855BD", "#D262D7", "#EC6FF1", "#F67CFB", "#FF89FF", "#FF96FF", "#FFA3FF"],
"#ee87c5": ["#832C6A", "#9D3984", "#B7469E", "#D153B8", "#EB60D2", "#FF6DEC", "#FF7AF6", "#FF87FF", "#FF94FF", "#FFA1FF"],
"#ca7881": ["#6F2C3E", "#893958", "#A34672", "#BD538C", "#D760A6", "#F16DC0", "#FB7ADA", "#FF87F4", "#FF94FF", "#FFA1FF"],
"#75c9c0": ["#3F6B66", "#497E7A", "#53918E", "#5DA4A2", "#67B7B6", "#71CBCA", "#7BDEDE", "#85F2F2", "#8FFFFF", "#99FFFF"],
"#75c997": ["#3F6B54", "#497E6A", "#53917F", "#5DA495", "#67B7AA", "#71CBBF", "#7BDED4", "#85F2E9", "#8FFFFF", "#99FFFF"],
"#80ca79": ["#456F3E", "#5A804D", "#6F935C", "#84A66B", "#99B97A", "#AECC89", "#C3DF98", "#D8F2A7", "#EDFFB6", "#FFFFC5"],
"#aacb78": ["#5F6F3E", "#7A804D", "#94935C", "#AFA66B", "#CAB97A", "#E5CC89", "#FFDF98", "#FFF2A7", "#FFFFB6", "#FFFFC5"],
"#cbbc78": ["#6F5D3E", "#8A704D", "#A4835C", "#BF966B", "#DAA97A", "#F5BC89", "#FFCF98", "#FFE2A7", "#FFF5B6", "#FFFFC5"],
"#cb9878": ["#704D3E", "#8B604D", "#A6735C", "#C1866B", "#DC997A", "#F7AC89", "#FFBF98", "#FFD2A7", "#FFE5B6", "#FFF8C5"],
"#bb774c": ["#653D27", "#80502C", "#9B6331", "#B67636", "#D1893B", "#EC9C40", "#FFAF45", "#FFC24A", "#FFD54F", "#FFE854"],
"#905b39": ["#4D2F1A", "#623C23", "#774A2C", "#8C5735", "#A1643E", "#B67147", "#CB7E50", "#E08B59", "#F59862", "#FFA56B"],
"#903737": ["#4D1A1A", "#622323", "#772C2C", "#8C3535", "#A13E3E", "#B64747", "#CB5050", "#E05959", "#F56262", "#FF6B6B"],
"#bf4949": ["#661212", "#801B1B", "#992424", "#B32D2D", "#CC3636", "#E63F3F", "#FF4848", "#FF5151", "#FF5A5A", "#FF6363"],
"#f37070": ["#853A3A", "#A04D4D", "#BA6060", "#D47373", "#EF8686", "#FF9999", "#FFA3A3", "#FFACAC", "#FFB6B6", "#FFBFBF"],
"#ff9c3c": ["#8F5614", "#AA6F1F", "#C48829", "#DFA233", "#F9BB3D", "#FFC04E", "#FFC75F", "#FFCE70", "#FFD581", "#FFDB92"],
"#fbc84c": ["#8F6D14", "#AA862F", "#C4A029", "#DFB933", "#F9D23D", "#FFD74E", "#FFDC5F", "#FFE170", "#FFE681", "#FFEB92"],
"#cbc8a1": ["#6F6D58", "#8A886F", "#A4A286", "#BFBC9D", "#DAD6B4", "#F5F0CB", "#FFFEDE", "#FFFFF2", "#FFFFCD", "#FFFFCD"],
"#a9a9a9": ["#5D5D5D", "#757575", "#8D8D8D", "#A5A5A5", "#BDBDBD", "#D5D5D5", "#EDEDED", "#F5F5F5", "#FFFFFF", "#FFFFFF"],
"#767676": ["#404040", "#4D4D4D", "#5A5A5A", "#676767", "#747474", "#818181", "#8E8E8E", "#9B9B9B", "#A8A8A8", "#B5B5B5"]
} as const;
export const WorklenzColorCodes = [
"#154c9b",
"#3b7ad4",
@@ -46,33 +73,33 @@ export const WorklenzColorCodes = [
];
export const AvatarNamesMap: { [x: string]: string } = {
"A": "#154c9b",
"B": "#3b7ad4",
"C": "#70a6f3",
"D": "#7781ca",
"E": "#9877ca",
"F": "#c178c9",
"G": "#ee87c5",
"H": "#ca7881",
"I": "#75c9c0",
"J": "#75c997",
"K": "#80ca79",
"L": "#aacb78",
"M": "#cbbc78",
"N": "#cb9878",
"O": "#bb774c",
"P": "#905b39",
"Q": "#903737",
"R": "#bf4949",
"S": "#f37070",
"T": "#ff9c3c",
"U": "#fbc84c",
"V": "#cbc8a1",
"W": "#a9a9a9",
"X": "#767676",
"Y": "#cb9878",
"Z": "#903737",
"+": "#9e9e9e"
A: "#154c9b",
B: "#3b7ad4",
C: "#70a6f3",
D: "#7781ca",
E: "#9877ca",
F: "#c178c9",
G: "#ee87c5",
H: "#ca7881",
I: "#75c9c0",
J: "#75c997",
K: "#80ca79",
L: "#aacb78",
M: "#cbbc78",
N: "#cb9878",
O: "#bb774c",
P: "#905b39",
Q: "#903737",
R: "#bf4949",
S: "#f37070",
T: "#ff9c3c",
U: "#fbc84c",
V: "#cbc8a1",
W: "#a9a9a9",
X: "#767676",
Y: "#cb9878",
Z: "#903737",
"+": "#9e9e9e",
};
export const NumbersColorMap: { [x: string]: string } = {
@@ -85,19 +112,19 @@ export const NumbersColorMap: { [x: string]: string } = {
"6": "#ee87c5",
"7": "#ca7881",
"8": "#75c9c0",
"9": "#75c997"
"9": "#75c997",
};
export const PriorityColorCodes: { [x: number]: string; } = {
export const PriorityColorCodes: { [x: number]: string } = {
0: "#2E8B57",
1: "#DAA520",
2: "#CD5C5C"
2: "#CD5C5C",
};
export const PriorityColorCodesDark: { [x: number]: string; } = {
export const PriorityColorCodesDark: { [x: number]: string } = {
0: "#3CB371",
1: "#B8860B",
2: "#F08080"
2: "#F08080",
};
export const TASK_STATUS_TODO_COLOR = "#a9a9a9";
@@ -113,7 +140,6 @@ export const TASK_DUE_UPCOMING_COLOR = "#70a6f3";
export const TASK_DUE_OVERDUE_COLOR = "#f37070";
export const TASK_DUE_NO_DUE_COLOR = "#a9a9a9";
export const DEFAULT_PAGE_SIZE = 20;
// S3 Credentials
@@ -125,7 +151,8 @@ export const S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY || "";
// Azure Blob Storage Credentials
export const STORAGE_PROVIDER = process.env.STORAGE_PROVIDER || "s3";
export const AZURE_STORAGE_ACCOUNT_NAME = process.env.AZURE_STORAGE_ACCOUNT_NAME;
export const AZURE_STORAGE_ACCOUNT_NAME =
process.env.AZURE_STORAGE_ACCOUNT_NAME;
export const AZURE_STORAGE_CONTAINER = process.env.AZURE_STORAGE_CONTAINER;
export const AZURE_STORAGE_ACCOUNT_KEY = process.env.AZURE_STORAGE_ACCOUNT_KEY;
export const AZURE_STORAGE_URL = process.env.AZURE_STORAGE_URL;
@@ -136,7 +163,7 @@ export function getStorageUrl() {
console.warn("AZURE_STORAGE_URL is not defined, falling back to S3_URL");
return S3_URL;
}
// Return just the base Azure Blob Storage URL
// AZURE_STORAGE_URL should be in the format: https://storageaccountname.blob.core.windows.net
return `${AZURE_STORAGE_URL}/${AZURE_STORAGE_CONTAINER}`;
@@ -150,16 +177,23 @@ export const TEAM_MEMBER_TREE_MAP_COLOR_ALPHA = "40";
// LICENSING SERVER URLS
export const LOCAL_URL = "http://localhost:3001";
export const UAT_SERVER_URL = process.env.UAT_SERVER_URL || "https://your-uat-server-url";
export const DEV_SERVER_URL = process.env.DEV_SERVER_URL || "https://your-dev-server-url";
export const PRODUCTION_SERVER_URL = process.env.PRODUCTION_SERVER_URL || "https://your-production-server-url";
export const UAT_SERVER_URL =
process.env.UAT_SERVER_URL || "https://your-uat-server-url";
export const DEV_SERVER_URL =
process.env.DEV_SERVER_URL || "https://your-dev-server-url";
export const PRODUCTION_SERVER_URL =
process.env.PRODUCTION_SERVER_URL || "https://your-production-server-url";
// *Sync with the client
export const PASSWORD_POLICY = "Minimum of 8 characters, with upper and lowercase and a number and a symbol.";
export const PASSWORD_POLICY =
"Minimum of 8 characters, with upper and lowercase and a number and a symbol.";
// paddle status to exclude
export const statusExclude = ["past_due", "paused", "deleted"];
// Trial user team member limit
export const TRIAL_MEMBER_LIMIT = 10;
export const HTML_TAG_REGEXP = /<\/?[^>]+>/gi;
export const UNMAPPED = "Unmapped";
@@ -169,5 +203,5 @@ export const DATE_RANGES = {
LAST_WEEK: "LAST_WEEK",
LAST_MONTH: "LAST_MONTH",
LAST_QUARTER: "LAST_QUARTER",
ALL_TIME: "ALL_TIME"
ALL_TIME: "ALL_TIME",
};

View File

@@ -75,7 +75,7 @@ export async function on_quick_assign_or_remove(_io: Server, socket: Socket, dat
assign_type: type
});
if (assignment && userId !== assignment.user_id) {
if (userId !== assignment.user_id) {
NotificationsService.createTaskUpdate(
type,
userId as string,
@@ -109,11 +109,6 @@ export async function assignMemberIfNot(taskId: string, userId: string, teamId:
const result = await db.query(q, [taskId, userId, teamId]);
const [data] = result.rows;
if (!data) {
log_error(new Error(`No team member found for userId: ${userId}, teamId: ${teamId}`));
return;
}
const body = {
team_member_id: data.team_member_id,
project_id: data.project_id,

View File

@@ -15,4 +15,8 @@ VITE_RECAPTCHA_SITE_KEY=recaptcha-site-key
VITE_WORKLENZ_SESSION_ID=worklenz-session-id
# Google Login
VITE_ENABLE_GOOGLE_LOGIN=false
VITE_ENABLE_GOOGLE_LOGIN=false
# Survey Modal Configuration
# Set to true to enable the survey modal, false to disable it
VITE_ENABLE_SURVEY_MODAL=false

View File

@@ -0,0 +1,22 @@
VITE_API_URL=http://localhost:3000
VITE_SOCKET_URL=ws://localhost:3000
VITE_APP_TITLE=Worklenz
VITE_APP_ENV=development
# Mixpanel
VITE_MIXPANEL_TOKEN=mixpanel-token
# Recaptcha
VITE_ENABLE_RECAPTCHA=false
VITE_RECAPTCHA_SITE_KEY=recaptcha-site-key
# Session ID
VITE_WORKLENZ_SESSION_ID=worklenz-session-id
# Google Login
VITE_ENABLE_GOOGLE_LOGIN=false
# Survey Modal Configuration
# Set to true to enable the survey modal, false to disable it
VITE_ENABLE_SURVEY_MODAL=false

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,11 @@
"build": "vite build",
"dev-build": "vite build",
"serve": "vite preview",
"format": "prettier --write ."
"format": "prettier --write .",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
},
"dependencies": {
"@ant-design/colors": "^7.1.0",
@@ -77,7 +81,10 @@
"@types/react-dom": "19.0.0",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.21",
"jsdom": "^26.1.0",
"postcss": "^8.5.2",
"prettier-plugin-tailwindcss": "^0.6.13",
"rollup": "^4.40.2",

View File

@@ -15,7 +15,7 @@ class HubSpotManager {
* Load HubSpot script with dark mode support
*/
init() {
if (!this.isProduction) return;
// if (!this.isProduction) return;
const loadHubSpot = () => {
const script = document.createElement('script');
@@ -52,6 +52,7 @@ class HubSpotManager {
existingStyle.remove();
}
// Apply dark mode CSS if dark theme is active
if (isDark) {
this.injectDarkModeCSS();
}
@@ -121,4 +122,11 @@ document.addEventListener('DOMContentLoaded', () => {
// Make available globally for potential cleanup
window.HubSpotManager = hubspot;
});
});
// Add this style to ensure the chat widget uses the light color scheme
(function() {
var style = document.createElement('style');
style.innerHTML = '#hubspot-messages-iframe-container { color-scheme: light !important; }';
document.head.appendChild(style);
})();

View File

@@ -5,5 +5,13 @@
"signup-failed": "Regjistrimi dështoi. Ju lutemi sigurohuni që të gjitha fushat e nevojshme janë plotësuar dhe provoni përsëri.",
"reconnecting": "Jeni shkëputur nga serveri.",
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
"connection-restored": "U lidhët me serverin me sukses"
"connection-restored": "U lidhët me serverin me sukses",
"cancel": "Anulo",
"update-available": "Worklenz u përditesua!",
"update-description": "Një version i ri i Worklenz është i disponueshëm me karakteristikat dhe përmirësimet më të fundit.",
"update-instruction": "Për eksperiencën më të mirë, ju lutemi rifreskoni faqen për të aplikuar ndryshimet e reja.",
"update-whats-new": "💡 <1>Çfarë ka të re:</1> Përmirësim i performancës, rregullime të gabimeve dhe eksperiencön e përmirësuar e përdoruesit",
"update-now": "Përditeso tani",
"update-later": "Më vonë",
"updating": "Duke u përditesuar..."
}

View File

@@ -41,6 +41,22 @@
"list": "Listë",
"calendar": "Kalendar",
"tasks": "Detyrat",
"refresh": "Rifresko"
"refresh": "Rifresko",
"recentActivity": "Aktiviteti i Fundit",
"recentTasks": "Detyrat e Fundit",
"recentTasksSegment": "Detyrat e Fundit",
"timeLogged": "Koha e Regjistruar",
"timeLoggedSegment": "Koha e Regjistruar",
"noRecentTasks": "Asnjë detyrë e fundit",
"noTimeLoggedTasks": "Asnjë detyrë me kohë të regjistruar",
"activityTag": "Aktiviteti",
"timeLogTag": "Regjistrim Kohe",
"timerTag": "Kohëmatës",
"activitySingular": "aktivitet",
"activityPlural": "aktivitete",
"recentTaskAriaLabel": "Detyrë e fundit:",
"timeLoggedTaskAriaLabel": "Detyrë me kohë të regjistruar:",
"errorLoadingRecentTasks": "Gabim në ngarkimin e detyrave të fundit",
"errorLoadingTimeLoggedTasks": "Gabim në ngarkimin e detyrave me kohë të regjistruar"
}
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Kërko sipas emrit",
"emptyText": "Etiketat mund të krijohen gjatë përditësimit ose krijimit të detyrave.",
"pinTooltip": "Klikoni për ta fiksuar në menynë kryesore",
"colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën"
"colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën",
"pageTitle": "Menaxho Etiketat",
"deleteConfirmTitle": "Jeni i sigurt që dëshironi ta fshini këtë?",
"deleteButton": "Fshi",
"cancelButton": "Anulo"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Cakto mua",
"copyLink": "Kopjo lidhjen e detyrës",
"linkCopied": "Lidhja u kopjua në clipboard",
"linkCopyFailed": "Dështoi kopjimi i lidhjes",
"moveTo": "Zhvendos në",
"unarchive": "Ç'arkivizo",
"archive": "Arkivizo",
@@ -133,5 +136,11 @@
"dependencies": "Detyra ka varësi",
"recurring": "Detyrë përsëritëse"
}
},
"timer": {
"conflictTitle": "Kronómetr Tashë Në Ecuri",
"conflictMessage": "Ju keni një kronómetr në ecuri për \"{{taskName}}\" në projektin \"{{projectName}}\". Dëshironi ta ndaloni atë kronómetr dhe të filloni një të ri për këtë detyrë?",
"stopAndStart": "Ndalo & Fillo Kronómetr të Ri"
}
}

View File

@@ -5,5 +5,13 @@
"signup-failed": "Registrierung fehlgeschlagen. Bitte füllen Sie alle erforderlichen Felder aus und versuchen Sie es erneut.",
"reconnecting": "Vom Server getrennt.",
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
"connection-restored": "Erfolgreich mit dem Server verbunden"
"connection-restored": "Erfolgreich mit dem Server verbunden",
"cancel": "Abbrechen",
"update-available": "Worklenz aktualisiert!",
"update-description": "Eine neue Version von Worklenz ist verfügbar mit den neuesten Funktionen und Verbesserungen.",
"update-instruction": "Für die beste Erfahrung laden Sie bitte die Seite neu, um die neuen Änderungen zu übernehmen.",
"update-whats-new": "💡 <1>Was ist neu:</1> Verbesserte Leistung, Fehlerbehebungen und verbesserte Benutzererfahrung",
"update-now": "Jetzt aktualisieren",
"update-later": "Später",
"updating": "Wird aktualisiert..."
}

View File

@@ -41,6 +41,22 @@
"list": "Liste",
"calendar": "Kalender",
"tasks": "Aufgaben",
"refresh": "Aktualisieren"
"refresh": "Aktualisieren",
"recentActivity": "Aktuelle Aktivitäten",
"recentTasks": "Aktuelle Aufgaben",
"recentTasksSegment": "Aktuelle Aufgaben",
"timeLogged": "Erfasste Zeit",
"timeLoggedSegment": "Erfasste Zeit",
"noRecentTasks": "Keine aktuellen Aufgaben",
"noTimeLoggedTasks": "Keine Aufgaben mit erfasster Zeit",
"activityTag": "Aktivität",
"timeLogTag": "Zeiterfassung",
"timerTag": "Timer",
"activitySingular": "Aktivität",
"activityPlural": "Aktivitäten",
"recentTaskAriaLabel": "Aktuelle Aufgabe:",
"timeLoggedTaskAriaLabel": "Aufgabe mit erfasster Zeit:",
"errorLoadingRecentTasks": "Fehler beim Laden aktueller Aufgaben",
"errorLoadingTimeLoggedTasks": "Fehler beim Laden der Zeiterfassung"
}
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Nach Name suchen",
"emptyText": "Labels können beim Aktualisieren oder Erstellen von Aufgaben erstellt werden.",
"pinTooltip": "Zum Anheften an das Hauptmenü klicken",
"colorChangeTooltip": "Zum Ändern der Farbe klicken"
"colorChangeTooltip": "Zum Ändern der Farbe klicken",
"pageTitle": "Labels verwalten",
"deleteConfirmTitle": "Sind Sie sicher, dass Sie dies löschen möchten?",
"deleteButton": "Löschen",
"cancelButton": "Abbrechen"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Mir zuweisen",
"copyLink": "Link zur Aufgabe kopieren",
"linkCopied": "Link in die Zwischenablage kopiert",
"linkCopyFailed": "Fehler beim Kopieren des Links",
"moveTo": "Verschieben nach",
"unarchive": "Dearchivieren",
"archive": "Archivieren",
@@ -133,5 +136,11 @@
"dependencies": "Aufgabe hat Abhängigkeiten",
"recurring": "Wiederkehrende Aufgabe"
}
},
"timer": {
"conflictTitle": "Timer läuft bereits",
"conflictMessage": "Sie haben einen Timer für \"{{taskName}}\" im Projekt \"{{projectName}}\" laufen. Möchten Sie diesen Timer stoppen und einen neuen für diese Aufgabe starten?",
"stopAndStart": "Stoppen & Neuen Timer starten"
}
}

View File

@@ -5,5 +5,13 @@
"signup-failed": "Signup failed. Please ensure all required fields are filled and try again.",
"reconnecting": "Disconnected from server.",
"connection-lost": "Failed to connect to server. Please check your internet connection.",
"connection-restored": "Connected to server successfully"
"connection-restored": "Connected to server successfully",
"cancel": "Cancel",
"update-available": "Worklenz Updated!",
"update-description": "A new version of Worklenz is available with the latest features and improvements.",
"update-instruction": "To get the best experience, please reload the page to apply the new changes.",
"update-whats-new": "💡 <1>What's new:</1> Enhanced performance, bug fixes, and improved user experience",
"update-now": "Update Now",
"update-later": "Later",
"updating": "Updating..."
}

View File

@@ -41,6 +41,22 @@
"list": "List",
"calendar": "Calendar",
"tasks": "Tasks",
"refresh": "Refresh"
"refresh": "Refresh",
"recentActivity": "Recent Activity",
"recentTasks": "Recent Tasks",
"recentTasksSegment": "Recent Tasks",
"timeLogged": "Time Logged",
"timeLoggedSegment": "Time Logged",
"noRecentTasks": "No recent tasks",
"noTimeLoggedTasks": "No time logged tasks",
"activityTag": "Activity",
"timeLogTag": "Time Log",
"timerTag": "Timer",
"activitySingular": "activity",
"activityPlural": "activities",
"recentTaskAriaLabel": "Recent task:",
"timeLoggedTaskAriaLabel": "Time logged task:",
"errorLoadingRecentTasks": "Error loading recent tasks",
"errorLoadingTimeLoggedTasks": "Error loading time logged tasks"
}
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Search by name",
"emptyText": "Labels can be created while updating or creating tasks.",
"pinTooltip": "Click to pin this into the main menu",
"colorChangeTooltip": "Click to change color"
"colorChangeTooltip": "Click to change color",
"pageTitle": "Manage Labels",
"deleteConfirmTitle": "Are you sure you want to delete this?",
"deleteButton": "Delete",
"cancelButton": "Cancel"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Assign to me",
"copyLink": "Copy link to task",
"linkCopied": "Link copied to clipboard",
"linkCopyFailed": "Failed to copy link",
"moveTo": "Move to",
"unarchive": "Unarchive",
"archive": "Archive",
@@ -133,5 +136,11 @@
"dependencies": "Task has dependencies",
"recurring": "Recurring task"
}
},
"timer": {
"conflictTitle": "Timer Already Running",
"conflictMessage": "You have a timer running for \"{{taskName}}\" in project \"{{projectName}}\". Would you like to stop that timer and start a new one for this task?",
"stopAndStart": "Stop & Start New Timer"
}
}

View File

@@ -5,5 +5,13 @@
"signup-failed": "Error al registrarse. Por favor asegúrate de llenar todos los campos requeridos e intenta nuevamente.",
"reconnecting": "Reconectando al servidor...",
"connection-lost": "Conexión perdida. Intentando reconectarse...",
"connection-restored": "Conexión restaurada. Reconectando al servidor..."
"connection-restored": "Conexión restaurada. Reconectando al servidor...",
"cancel": "Cancelar",
"update-available": "¡Worklenz actualizado!",
"update-description": "Una nueva versión de Worklenz está disponible con las últimas funciones y mejoras.",
"update-instruction": "Para obtener la mejor experiencia, por favor recarga la página para aplicar los nuevos cambios.",
"update-whats-new": "💡 <1>Qué hay de nuevo:</1> Rendimiento mejorado, correcciones de errores y experiencia de usuario mejorada",
"update-now": "Actualizar ahora",
"update-later": "Más tarde",
"updating": "Actualizando..."
}

View File

@@ -40,6 +40,22 @@
"list": "Lista",
"calendar": "Calendario",
"tasks": "Tareas",
"refresh": "Actualizar"
"refresh": "Actualizar",
"recentActivity": "Actividad Reciente",
"recentTasks": "Tareas Recientes",
"recentTasksSegment": "Tareas Recientes",
"timeLogged": "Tiempo Registrado",
"timeLoggedSegment": "Tiempo Registrado",
"noRecentTasks": "No hay tareas recientes",
"noTimeLoggedTasks": "No hay tareas con tiempo registrado",
"activityTag": "Actividad",
"timeLogTag": "Registro de Tiempo",
"timerTag": "Temporizador",
"activitySingular": "actividad",
"activityPlural": "actividades",
"recentTaskAriaLabel": "Tarea reciente:",
"timeLoggedTaskAriaLabel": "Tarea con tiempo registrado:",
"errorLoadingRecentTasks": "Error al cargar tareas recientes",
"errorLoadingTimeLoggedTasks": "Error al cargar tareas con tiempo registrado"
}
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Buscar por nombre",
"emptyText": "Las etiquetas se pueden crear al actualizar o crear tareas.",
"pinTooltip": "Haz clic para fijar esto en el menú principal",
"colorChangeTooltip": "Haz clic para cambiar el color"
"colorChangeTooltip": "Haz clic para cambiar el color",
"pageTitle": "Administrar Etiquetas",
"deleteConfirmTitle": "¿Estás seguro de que quieres eliminar esto?",
"deleteButton": "Eliminar",
"cancelButton": "Cancelar"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Asignar a mí",
"copyLink": "Copiar enlace a la tarea",
"linkCopied": "Enlace copiado al portapapeles",
"linkCopyFailed": "Error al copiar el enlace",
"moveTo": "Mover a",
"unarchive": "Desarchivar",
"archive": "Archivar",
@@ -133,5 +136,11 @@
"dependencies": "La tarea tiene dependencias",
"recurring": "Tarea recurrente"
}
},
"timer": {
"conflictTitle": "Temporizador Ya En Ejecución",
"conflictMessage": "Tiene un temporizador ejecutándose para \"{{taskName}}\" en el proyecto \"{{projectName}}\". ¿Le gustaría detener ese temporizador e iniciar uno nuevo para esta tarea?",
"stopAndStart": "Detener e Iniciar Nuevo Temporizador"
}
}

View File

@@ -5,5 +5,13 @@
"signup-failed": "Falha no cadastro. Por favor, certifique-se de que todos os campos obrigatórios estão preenchidos e tente novamente.",
"reconnecting": "Reconectando ao servidor...",
"connection-lost": "Conexão perdida. Tentando reconectar...",
"connection-restored": "Conexão restaurada. Reconectando ao servidor..."
"connection-restored": "Conexão restaurada. Reconectando ao servidor...",
"cancel": "Cancelar",
"update-available": "Worklenz atualizado!",
"update-description": "Uma nova versão do Worklenz está disponível com os recursos e melhorias mais recentes.",
"update-instruction": "Para obter a melhor experiência, por favor recarregue a página para aplicar as novas mudanças.",
"update-whats-new": "💡 <1>O que há de novo:</1> Performance aprimorada, correções de bugs e experiência do usuário melhorada",
"update-now": "Atualizar agora",
"update-later": "Mais tarde",
"updating": "Atualizando..."
}

View File

@@ -40,6 +40,22 @@
"list": "Lista",
"calendar": "Calendário",
"tasks": "Tarefas",
"refresh": "Atualizar"
"refresh": "Atualizar",
"recentActivity": "Atividade Recente",
"recentTasks": "Tarefas Recentes",
"recentTasksSegment": "Tarefas Recentes",
"timeLogged": "Tempo Registrado",
"timeLoggedSegment": "Tempo Registrado",
"noRecentTasks": "Nenhuma tarefa recente",
"noTimeLoggedTasks": "Nenhuma tarefa com tempo registrado",
"activityTag": "Atividade",
"timeLogTag": "Registro de Tempo",
"timerTag": "Cronômetro",
"activitySingular": "atividade",
"activityPlural": "atividades",
"recentTaskAriaLabel": "Tarefa recente:",
"timeLoggedTaskAriaLabel": "Tarefa com tempo registrado:",
"errorLoadingRecentTasks": "Erro ao carregar tarefas recentes",
"errorLoadingTimeLoggedTasks": "Erro ao carregar tarefas com tempo registrado"
}
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Pesquisar por nome",
"emptyText": "Os rótulos podem ser criados ao atualizar ou criar tarefas.",
"pinTooltip": "Clique para fixar isso no menu principal",
"colorChangeTooltip": "Clique para mudar a cor"
"colorChangeTooltip": "Clique para mudar a cor",
"pageTitle": "Gerenciar Rótulos",
"deleteConfirmTitle": "Tem certeza de que deseja excluir isto?",
"deleteButton": "Excluir",
"cancelButton": "Cancelar"
}

View File

@@ -57,6 +57,9 @@
"contextMenu": {
"assignToMe": "Atribuir a mim",
"copyLink": "Copiar link da tarefa",
"linkCopied": "Link copiado para a área de transferência",
"linkCopyFailed": "Falha ao copiar o link",
"moveTo": "Mover para",
"unarchive": "Desarquivar",
"archive": "Arquivar",
@@ -133,5 +136,11 @@
"dependencies": "A tarefa tem dependências",
"recurring": "Tarefa recorrente"
}
},
"timer": {
"conflictTitle": "Temporizador Já Em Execução",
"conflictMessage": "Você tem um temporizador executando para \"{{taskName}}\" no projeto \"{{projectName}}\". Gostaria de parar esse temporizador e iniciar um novo para esta tarefa?",
"stopAndStart": "Parar e Iniciar Novo Temporizador"
}
}

View File

@@ -5,5 +5,13 @@
"signup-failed": "注册失败。请确保填写所有必填字段并重试。",
"reconnecting": "与服务器断开连接。",
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
"connection-restored": "成功连接到服务器"
"connection-restored": "成功连接到服务器",
"cancel": "取消",
"update-available": "Worklenz 已更新!",
"update-description": "Worklenz 的新版本已可用,具有最新的功能和改进。",
"update-instruction": "为了获得最佳体验,请刷新页面以应用新更改。",
"update-whats-new": "💡 <1>新增内容:</1>性能增强、错误修复和用户体验改善",
"update-now": "立即更新",
"update-later": "稍后",
"updating": "正在更新..."
}

View File

@@ -41,6 +41,22 @@
"list": "列表",
"calendar": "日历",
"tasks": "任务",
"refresh": "刷新"
"refresh": "刷新",
"recentActivity": "最近活动",
"recentTasks": "最近任务",
"recentTasksSegment": "最近任务",
"timeLogged": "时间记录",
"timeLoggedSegment": "时间记录",
"noRecentTasks": "没有最近任务",
"noTimeLoggedTasks": "没有时间记录任务",
"activityTag": "活动",
"timeLogTag": "时间记录",
"timerTag": "计时器",
"activitySingular": "活动",
"activityPlural": "活动",
"recentTaskAriaLabel": "最近任务:",
"timeLoggedTaskAriaLabel": "时间记录任务:",
"errorLoadingRecentTasks": "加载最近任务时出错",
"errorLoadingTimeLoggedTasks": "加载时间记录任务时出错"
}
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "按名称搜索",
"emptyText": "标签可以在更新或创建任务时创建。",
"pinTooltip": "点击将其固定到主菜单",
"colorChangeTooltip": "点击更改颜色"
"colorChangeTooltip": "点击更改颜色",
"pageTitle": "管理标签",
"deleteConfirmTitle": "您确定要删除这个吗?",
"deleteButton": "删除",
"cancelButton": "取消"
}

View File

@@ -50,6 +50,9 @@
"pendingInvitation": "待处理邀请",
"contextMenu": {
"assignToMe": "分配给我",
"copyLink": "复制任务链接",
"linkCopied": "链接已复制到剪贴板",
"linkCopyFailed": "复制链接失败",
"moveTo": "移动到",
"unarchive": "取消归档",
"archive": "归档",
@@ -126,5 +129,11 @@
"dependencies": "任务有依赖项",
"recurring": "重复任务"
}
},
"timer": {
"conflictTitle": "计时器已在运行",
"conflictMessage": "您在项目\"{{projectName}}\"中的\"{{taskName}}\"任务正在运行计时器。您是否要停止该计时器并为此任务启动新的计时器?",
"stopAndStart": "停止并启动新计时器"
}
}

View File

@@ -325,6 +325,12 @@ self.addEventListener('message', event => {
event.ports[0].postMessage({ version: CACHE_VERSION });
break;
case 'CHECK_FOR_UPDATES':
checkForUpdates().then((hasUpdates) => {
event.ports[0].postMessage({ hasUpdates });
});
break;
case 'CLEAR_CACHE':
clearAllCaches().then(() => {
event.ports[0].postMessage({ success: true });
@@ -349,6 +355,44 @@ async function clearAllCaches() {
console.log('Service Worker: All caches cleared');
}
async function checkForUpdates() {
try {
// Check if there's a new service worker available
const registration = await self.registration.update();
const hasNewWorker = registration.installing || registration.waiting;
if (hasNewWorker) {
console.log('Service Worker: New version detected');
return true;
}
// Also check if the main app files have been updated by trying to fetch index.html
// and comparing it with the cached version
try {
const cache = await caches.open(CACHE_NAMES.STATIC);
const cachedResponse = await cache.match('/');
const networkResponse = await fetch('/', { cache: 'no-cache' });
if (cachedResponse && networkResponse.ok) {
const cachedContent = await cachedResponse.text();
const networkContent = await networkResponse.text();
if (cachedContent !== networkContent) {
console.log('Service Worker: App content has changed');
return true;
}
}
} catch (error) {
console.log('Service Worker: Could not check for content updates', error);
}
return false;
} catch (error) {
console.error('Service Worker: Error checking for updates', error);
return false;
}
}
async function handleLogout() {
try {
// Clear all caches

View File

@@ -6,6 +6,7 @@ import i18next from 'i18next';
// Components
import ThemeWrapper from './features/theme/ThemeWrapper';
import ModuleErrorBoundary from './components/ModuleErrorBoundary';
import { UpdateNotificationProvider } from './components/update-notification';
// Routes
import router from './app/routes';
@@ -202,14 +203,16 @@ const App: React.FC = memo(() => {
return (
<Suspense fallback={<SuspenseFallback />}>
<ThemeWrapper>
<ModuleErrorBoundary>
<RouterProvider
router={router}
future={{
v7_startTransition: true,
}}
/>
</ModuleErrorBoundary>
<UpdateNotificationProvider>
<ModuleErrorBoundary>
<RouterProvider
router={router}
future={{
v7_startTransition: true,
}}
/>
</ModuleErrorBoundary>
</UpdateNotificationProvider>
</ThemeWrapper>
</Suspense>
);

View File

@@ -0,0 +1,46 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { API_BASE_URL } from '@/shared/constants';
import { getCsrfToken } from '../api-client';
import { IUserRecentTask, IUserTimeLoggedTask } from '@/types/home/user-activity.types';
import config from '@/config/env';
const rootUrl = '/logs';
export const userActivityApiService = createApi({
reducerPath: 'userActivityApi',
baseQuery: fetchBaseQuery({
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
prepareHeaders: (headers) => {
headers.set('X-CSRF-Token', getCsrfToken() || '');
headers.set('Content-Type', 'application/json');
return headers;
},
credentials: 'include',
}),
tagTypes: ['UserRecentTasks', 'UserTimeLoggedTasks'],
endpoints: (builder) => ({
getUserRecentTasks: builder.query<IUserRecentTask[], { limit?: number; offset?: number }>({
query: ({ limit = 10, offset = 0 }) => ({
url: `${rootUrl}/user-recent-tasks`,
params: { limit, offset },
method: 'GET',
}),
providesTags: ['UserRecentTasks'],
}),
getUserTimeLoggedTasks: builder.query<IUserTimeLoggedTask[], { limit?: number; offset?: number }>({
query: ({ limit = 10, offset = 0 }) => ({
url: `${rootUrl}/user-time-logged-tasks`,
params: { limit, offset },
method: 'GET',
}),
providesTags: ['UserTimeLoggedTasks'],
}),
}),
});
export const {
useGetUserRecentTasksQuery,
useGetUserTimeLoggedTasksQuery,
} = userActivityApiService;

View File

@@ -27,12 +27,17 @@ export const labelsApiService = {
updateColor: async (labelId: string, color: string): Promise<IServerResponse<ITaskLabel>> => {
const response = await apiClient.put<IServerResponse<ITaskLabel>>(
`${rootUrl}/tasks/${labelId}/color`,
`${rootUrl}/tasks/${labelId}`,
{ color }
);
return response.data;
},
updateLabel: async (labelId: string, data: { name?: string; color?: string }): Promise<IServerResponse<ITaskLabel>> => {
const response = await apiClient.put<IServerResponse<ITaskLabel>>(`${rootUrl}/team/${labelId}`, data);
return response.data;
},
deleteById: async (labelId: string): Promise<IServerResponse<void>> => {
const response = await apiClient.delete<IServerResponse<void>>(`${rootUrl}/team/${labelId}`);
return response.data;

View File

@@ -11,9 +11,7 @@ import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallba
const HomePage = lazy(() => import('@/pages/home/home-page'));
const ProjectList = lazy(() => import('@/pages/projects/project-list'));
const Schedule = lazy(() => import('@/pages/schedule/schedule'));
const ProjectTemplateEditView = lazy(
() => import('@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView')
);
const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired'));
const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view'));
const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized'));
@@ -91,14 +89,6 @@ const mainRoutes: RouteObject[] = [
</Suspense>
),
},
{
path: `settings/project-templates/edit/:templateId/:templateName`,
element: (
<Suspense fallback={<SuspenseFallback />}>
<ProjectTemplateEditView />
</Suspense>
),
},
{
path: 'unauthorized',
element: (

View File

@@ -7,6 +7,7 @@ import userReducer from '@features/user/userSlice';
// Home Page
import homePageReducer from '@features/home-page/home-page.slice';
import userActivityReducer from '@features/home-page/user-activity.slice';
// Account Setup
import accountSetupReducer from '@features/account-setup/account-setup.slice';
@@ -82,6 +83,7 @@ import groupingReducer from '@/features/task-management/grouping.slice';
import selectionReducer from '@/features/task-management/selection.slice';
import homePageApiService from '@/api/home-page/home-page.api.service';
import { projectsApi } from '@/api/projects/projects.v1.api.service';
import { userActivityApiService } from '@/api/home-page/user-activity.api.service';
import projectViewReducer from '@features/project/project-view-slice';
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
@@ -90,7 +92,7 @@ export const store = configureStore({
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: false,
}).concat(homePageApiService.middleware, projectsApi.middleware),
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware),
reducer: {
// Auth & User
auth: authReducer,
@@ -103,6 +105,9 @@ export const store = configureStore({
homePageReducer: homePageReducer,
[homePageApiService.reducerPath]: homePageApiService.reducer,
[projectsApi.reducerPath]: projectsApi.reducer,
userActivityReducer: userActivityReducer,
[userActivityApiService.reducerPath]: userActivityApiService.reducer,
// Core UI
themeReducer: themeReducer,
localesReducer: localesReducer,

View File

@@ -10,3 +10,6 @@ export { default as LabelsSelector } from './LabelsSelector';
export { default as Progress } from './Progress';
export { default as Tag } from './Tag';
export { default as Tooltip } from './Tooltip';
// Update Notification Components
export * from './update-notification';

View File

@@ -30,6 +30,11 @@ export const SurveyPromptModal: React.FC<SurveyPromptModalProps> = ({ forceShow
const isDarkMode = themeMode === 'dark';
useEffect(() => {
// Check if survey modal is disabled via environment variable
if (import.meta.env.VITE_ENABLE_SURVEY_MODAL !== 'true' && !forceShow) {
return; // Don't show modal if disabled in environment
}
// Check if survey was skipped recently (within 7 days)
const skippedAt = localStorage.getItem('survey_skipped_at');
if (!forceShow && skippedAt) {

View File

@@ -12,7 +12,7 @@ import TimeLogList from './time-log-list';
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
import { useTaskTimer } from '@/hooks/useTaskTimer';
import { useTaskTimerWithConflictCheck } from '@/hooks/useTaskTimerWithConflictCheck';
import logger from '@/utils/errorLogger';
interface TaskDrawerTimeLogProps {
@@ -31,7 +31,7 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) =>
state => state.taskDrawerReducer
);
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimerWithConflictCheck(
selectedTaskId || '',
taskFormViewModel?.task?.timer_start_time || null
);

View File

@@ -2,8 +2,10 @@ import { Tag, Typography } from '@/shared/antd-imports';
import { colors } from '@/styles/colors';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
import { ALPHA_CHANNEL } from '@/shared/constants';
import { useAppSelector } from '@/hooks/useAppSelector';
const CustomColorLabel = ({ label }: { label: ITaskLabel | null }) => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
return (
<Tag
key={label?.id}
@@ -17,7 +19,7 @@ const CustomColorLabel = ({ label }: { label: ITaskLabel | null }) => {
fontSize: 11,
}}
>
<Typography.Text style={{ fontSize: 11, color: colors.darkGray }}>
<Typography.Text style={{ fontSize: 11, color: themeMode === 'dark' ? 'rgba(255, 255, 255, 0.85)' : colors.darkGray }}>
{label?.name}
</Typography.Text>
</Tag>

View File

@@ -1,5 +1,4 @@
import React, { useMemo, useCallback, useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
// @ts-ignore: Heroicons module types
import {
ChevronDownIcon,
@@ -382,24 +381,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
t,
]);
// Make the group header droppable
const { isOver, setNodeRef } = useDroppable({
id: group.id,
data: {
type: 'group',
group,
},
});
return (
<div className="relative flex items-center">
<div
ref={setNodeRef}
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
}`}
className="inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2"
style={{
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
backgroundColor: headerBackgroundColor,
color: headerTextColor,
position: 'sticky',
top: 0,

View File

@@ -9,8 +9,9 @@ import {
KeyboardSensor,
TouchSensor,
closestCenter,
useDroppable,
Modifier,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
SortableContext,
verticalListSortingStrategy,
@@ -67,106 +68,132 @@ import TaskListSkeleton from './components/TaskListSkeleton';
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
// Empty Group Drop Zone Component
const EmptyGroupDropZone: React.FC<{
groupId: string;
visibleColumns: any[];
t: (key: string) => string;
}> = ({ groupId, visibleColumns, t }) => {
const { setNodeRef, isOver, active } = useDroppable({
id: `empty-group-${groupId}`,
data: {
type: 'group',
groupId: groupId,
isEmpty: true,
},
});
return (
<div
ref={setNodeRef}
className={`relative w-full transition-colors duration-200 ${
isOver && active ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
>
<div className="flex items-center min-w-max px-1 border-t border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
{visibleColumns.map((column, index) => {
const emptyColumnStyle = {
width: column.width,
flexShrink: 0,
};
// Show text in the title column
if (column.id === 'title') {
return (
<div
key={`empty-${column.id}`}
className="flex items-center pl-1"
style={emptyColumnStyle}
>
<span className="text-sm text-gray-500 dark:text-gray-400">
No tasks in this group
</span>
</div>
);
}
return (
<div
key={`empty-${column.id}`}
className="border-r border-gray-200 dark:border-gray-700"
style={emptyColumnStyle}
/>
);
})}
</div>
{isOver && active && (
<div className="absolute inset-0 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-md pointer-events-none" />
)}
</div>
);
};
// Placeholder Drop Indicator Component
const PlaceholderDropIndicator: React.FC<{
isVisible: boolean;
visibleColumns: any[];
}> = ({ isVisible, visibleColumns }) => {
// Drop Spacer Component - creates space between tasks when dragging
const DropSpacer: React.FC<{ isVisible: boolean; visibleColumns: any[]; isDarkMode?: boolean }> = ({
isVisible,
visibleColumns,
isDarkMode = false
}) => {
if (!isVisible) return null;
return (
<div
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 rounded-md mx-1 my-1 transition-all duration-200 ease-in-out"
style={{ minWidth: 'max-content', height: '40px' }}
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 transition-all duration-200 ease-in-out"
style={{
height: isVisible ? '40px' : '0px',
opacity: isVisible ? 1 : 0,
marginTop: isVisible ? '2px' : '0px',
marginBottom: isVisible ? '2px' : '0px',
overflow: 'hidden',
}}
>
{visibleColumns.map((column, index) => {
// Calculate left position for sticky columns
let leftPosition = 0;
if (column.isSticky) {
for (let i = 0; i < index; i++) {
const prevColumn = visibleColumns[i];
if (prevColumn.isSticky) {
leftPosition += parseInt(prevColumn.width.replace('px', ''));
}
}
}
const columnStyle = {
width: column.width,
flexShrink: 0,
...(column.isSticky && {
position: 'sticky' as const,
left: leftPosition,
zIndex: 10,
backgroundColor: 'inherit', // Inherit from parent spacer
}),
};
if (column.id === 'title') {
return (
<div
key={`spacer-${column.id}`}
className="flex items-center pl-1 border-r border-blue-300 dark:border-blue-600"
style={columnStyle}
>
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
Drop here
</span>
</div>
);
}
return (
<div
key={`placeholder-${column.id}`}
className="flex items-center justify-center h-full"
key={`spacer-${column.id}`}
className="border-r border-blue-300 dark:border-blue-600"
style={columnStyle}
>
{/* Show "Drop task here" message in the title column */}
{column.id === 'title' && (
<div className="text-xs text-blue-600 dark:text-blue-400 font-medium opacity-75">
Drop task here
</div>
)}
{/* Show subtle placeholder content in other columns */}
{column.id !== 'title' && column.id !== 'dragHandle' && (
<div className="w-full h-4 mx-1 bg-white dark:bg-gray-700 rounded opacity-50" />
)}
</div>
/>
);
})}
</div>
);
};
// Empty Group Message Component
const EmptyGroupMessage: React.FC<{ visibleColumns: any[]; isDarkMode?: boolean }> = ({
visibleColumns,
isDarkMode = false
}) => {
return (
<div className="flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
{visibleColumns.map((column, index) => {
// Calculate left position for sticky columns
let leftPosition = 0;
if (column.isSticky) {
for (let i = 0; i < index; i++) {
const prevColumn = visibleColumns[i];
if (prevColumn.isSticky) {
leftPosition += parseInt(prevColumn.width.replace('px', ''));
}
}
}
const emptyColumnStyle = {
width: column.width,
flexShrink: 0,
...(column.isSticky && {
position: 'sticky' as const,
left: leftPosition,
zIndex: 10,
backgroundColor: 'inherit', // Inherit from parent container
}),
};
// Show text in the title column
if (column.id === 'title') {
return (
<div
key={`empty-${column.id}`}
className="flex items-center pl-1 border-r border-gray-200 dark:border-gray-700"
style={emptyColumnStyle}
>
<span className="text-sm text-gray-500 dark:text-gray-400 italic">
No tasks in this group
</span>
</div>
);
}
return (
<div
key={`empty-${column.id}`}
className="border-r border-gray-200 dark:border-gray-700"
style={emptyColumnStyle}
/>
);
})}
</div>
);
};
// Hooks and utilities
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { useSocket } from '@/socket/socketContext';
@@ -229,7 +256,7 @@ const TaskListV2Section: React.FC = () => {
);
// Custom hooks
const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
const { activeId, overId, dropPosition, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
allTasks,
groups
);
@@ -564,16 +591,12 @@ const TaskListV2Section: React.FC = () => {
projectId={urlProjectId || ''}
/>
{isGroupEmpty && !isGroupCollapsed && (
<EmptyGroupDropZone
groupId={group.id}
visibleColumns={visibleColumns}
t={t}
/>
<EmptyGroupMessage visibleColumns={visibleColumns} isDarkMode={isDarkMode} />
)}
</div>
);
},
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t]
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t, isDarkMode]
);
const renderTask = useCallback(
@@ -614,19 +637,40 @@ const TaskListV2Section: React.FC = () => {
const renderColumnHeaders = useCallback(
() => (
<div
className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
style={{ width: '100%', minWidth: 'max-content' }}
className="border-b border-gray-200 dark:border-gray-700"
style={{
width: '100%',
minWidth: 'max-content',
backgroundColor: isDarkMode ? '#141414' : '#f9fafb'
}}
>
<div
className="flex items-center px-1 py-3 w-full"
style={{ minWidth: 'max-content', height: '44px' }}
>
{visibleColumns.map((column, index) => {
// Calculate left position for sticky columns
let leftPosition = 0;
if (column.isSticky) {
for (let i = 0; i < index; i++) {
const prevColumn = visibleColumns[i];
if (prevColumn.isSticky) {
leftPosition += parseInt(prevColumn.width.replace('px', ''));
}
}
}
const columnStyle: ColumnStyle = {
width: column.width,
flexShrink: 0,
...((column as any).minWidth && { minWidth: (column as any).minWidth }),
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
...(column.isSticky && {
position: 'sticky' as const,
left: leftPosition,
zIndex: 15,
backgroundColor: isDarkMode ? '#141414' : '#f9fafb', // custom dark header : bg-gray-50
}),
};
return (
@@ -791,13 +835,25 @@ const TaskListV2Section: React.FC = () => {
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<>
{/* CSS for sticky column hover effects */}
<style>
{`
.hover\\:bg-gray-50:hover .sticky-column-hover,
.dark .hover\\:bg-gray-800:hover .sticky-column-hover {
background-color: var(--hover-bg) !important;
}
`}
</style>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis]}
>
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
{/* Table Container */}
<div
@@ -850,41 +906,21 @@ const TaskListV2Section: React.FC = () => {
// Check if this is the first actual task in the group (not AddTaskRow)
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
// Check if we should show drop indicators
const isTaskBeingDraggedOver = overId === task.id;
const isGroupBeingDraggedOver = overId === group.id;
const isFirstTaskInGroupBeingDraggedOver = isFirstTaskInGroup && isTaskBeingDraggedOver;
// Check if we should show drop spacer
const isOverThisTask = activeId && overId === task.id && !('isAddTaskRow' in task);
const showDropSpacerBefore = isOverThisTask && dropPosition === 'before';
const showDropSpacerAfter = isOverThisTask && dropPosition === 'after';
return (
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
{/* Placeholder drop indicator before first task in group */}
{isFirstTaskInGroupBeingDraggedOver && (
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
)}
{/* Placeholder drop indicator between tasks */}
{isTaskBeingDraggedOver && !isFirstTaskInGroup && (
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
)}
{showDropSpacerBefore && <DropSpacer isVisible={true} visibleColumns={visibleColumns} isDarkMode={isDarkMode} />}
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
{/* Placeholder drop indicator at end of group when dragging over group */}
{isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && (
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
)}
{showDropSpacerAfter && <DropSpacer isVisible={true} visibleColumns={visibleColumns} isDarkMode={isDarkMode} />}
</div>
);
})
) : (
// Handle empty groups with placeholder drop indicator
overId === group.id && (
<div style={{ minWidth: 'max-content' }}>
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
</div>
)
)
) : null
)}
</div>
))}
@@ -894,15 +930,15 @@ const TaskListV2Section: React.FC = () => {
</div>
{/* Drag Overlay */}
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease-in-out' }}>
<DragOverlay dropAnimation={{ duration: 200, easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)' }}>
{activeId ? (
<div
className="bg-white dark:bg-gray-800 shadow-lg rounded-md border border-blue-400 dark:border-blue-500 opacity-90"
className="bg-white dark:bg-gray-800 shadow-2xl rounded-lg border-2 border-blue-500 dark:border-blue-400 opacity-95"
style={{ width: visibleColumns.find(col => col.id === 'title')?.width || '300px' }}
>
<div className="px-3 py-2">
<div className="flex items-center gap-2">
<HolderOutlined className="text-gray-400 dark:text-gray-500 text-xs" />
<div className="px-4 py-3">
<div className="flex items-center gap-3">
<HolderOutlined className="text-blue-500 dark:text-blue-400 text-sm" />
<div className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1">
{allTasks.find(task => task.id === activeId)?.name ||
allTasks.find(task => task.id === activeId)?.title ||
@@ -954,6 +990,7 @@ const TaskListV2Section: React.FC = () => {
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-drawer')}
</div>
</DndContext>
</>
);
};

View File

@@ -75,7 +75,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
});
// Drag and drop functionality - only enable for parent tasks
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({
id: task.id,
data: {
type: 'task',
@@ -116,24 +116,41 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
const style = useMemo(() => ({
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging
opacity: isDragging ? 0.3 : 1, // Make original task slightly transparent while dragging
}), [transform, transition, isDragging]);
return (
<div
ref={setNodeRef}
style={{ ...style, height: '40px' }}
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
} ${
isDragging ? 'shadow-lg border border-blue-300' : ''
isDragging ? 'opacity-50' : ''
} ${
isOver && !isDragging ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
>
{visibleColumns.map((column, index) => (
<React.Fragment key={column.id}>
{renderColumn(column.id, column.width, column.isSticky, index)}
</React.Fragment>
))}
{visibleColumns.map((column, index) => {
// Calculate background state for sticky columns - custom dark mode colors
const rowBackgrounds = {
normal: isDarkMode ? '#1e1e1e' : '#ffffff', // custom dark : bg-white
hover: isDarkMode ? '#1f2937' : '#f9fafb', // slightly lighter dark : bg-gray-50
dragOver: isDarkMode ? '#1e3a8a33' : '#dbeafe', // bg-blue-900/20 : bg-blue-50
};
let currentBg = rowBackgrounds.normal;
if (isOver && !isDragging) {
currentBg = rowBackgrounds.dragOver;
}
// Note: hover state is handled by CSS, so we'll use a CSS custom property
return (
<React.Fragment key={column.id}>
{renderColumn(column.id, column.width, column.isSticky, index, currentBg, rowBackgrounds)}
</React.Fragment>
);
})}
</div>
);
});

View File

@@ -1,6 +1,6 @@
import React from 'react';
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
import { useTaskTimer } from '@/hooks/useTaskTimer';
import { useTaskTimerWithConflictCheck } from '@/hooks/useTaskTimerWithConflictCheck';
interface TaskTimeTrackingProps {
taskId: string;
@@ -8,7 +8,7 @@ interface TaskTimeTrackingProps {
}
const TaskTimeTracking: React.FC<TaskTimeTrackingProps> = React.memo(({ taskId, isDarkMode }) => {
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimerWithConflictCheck(
taskId,
null // The hook will get the timer start time from Redux
);

View File

@@ -32,6 +32,8 @@ import {
RetweetOutlined,
UserAddOutlined,
LoadingOutlined,
CopyOutlined,
message,
} from '@/shared/antd-imports';
interface TaskContextMenuProps {
@@ -325,6 +327,21 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
}
}, [task?.id, projectId, dispatch, onClose]);
const handleCopyLink = useCallback(async () => {
if (!projectId || !task.id) return;
try {
const taskLink = `${window.location.origin}/worklenz/projects/${projectId}?tab=tasks-list&pinned_tab=tasks-list&task=${task.id}`;
await navigator.clipboard.writeText(taskLink);
message.success(t('contextMenu.linkCopied'));
} catch (error) {
logger.error('Error copying link:', error);
message.error(t('contextMenu.linkCopyFailed'));
} finally {
onClose();
}
}, [projectId, task.id, onClose, t]);
const menuItems = useMemo(() => {
const items = [
{
@@ -344,6 +361,18 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
</button>
),
},
{
key: 'copyLink',
label: (
<button
onClick={handleCopyLink}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
>
<CopyOutlined className="text-gray-500 dark:text-gray-400" />
<span>{t('contextMenu.copyLink')}</span>
</button>
),
},
];
// Add Move To submenu if there are options
@@ -500,6 +529,7 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
handleArchive,
handleDelete,
handleConvertToTask,
handleCopyLink,
getMoveToOptions,
dispatch,
t,

View File

@@ -19,10 +19,11 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
const currentSession = useAuthService().getCurrentSession();
const [activeId, setActiveId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
const [dropPosition, setDropPosition] = useState<'before' | 'after' | null>(null);
// Helper function to emit socket event for persistence
// Helper function to emit socket event for persistence (within-group only)
const emitTaskSortChange = useCallback(
(taskId: string, sourceGroup: TaskGroup, targetGroup: TaskGroup, insertIndex: number) => {
(taskId: string, group: TaskGroup, insertIndex: number) => {
if (!socket || !connected || !projectId) {
logger.warning('Socket not connected or missing project ID');
return;
@@ -40,54 +41,30 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
// Use new bulk update approach - recalculate ALL task orders to prevent duplicates
const taskUpdates: any[] = [];
// Create a copy of all groups and perform the move operation
const updatedGroups = groups.map(group => ({
...group,
taskIds: [...group.taskIds]
// Create a copy of all groups
const updatedGroups = groups.map(g => ({
...g,
taskIds: [...g.taskIds]
}));
// Find the source and target groups in our copy
const sourceGroupCopy = updatedGroups.find(g => g.id === sourceGroup.id)!;
const targetGroupCopy = updatedGroups.find(g => g.id === targetGroup.id)!;
// Find the group in our copy
const groupCopy = updatedGroups.find(g => g.id === group.id)!;
if (sourceGroup.id === targetGroup.id) {
// Same group - reorder within the group
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
// Remove task from old position
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
// Insert at new position
sourceGroupCopy.taskIds.splice(insertIndex, 0, taskId);
} else {
// Different groups - move task between groups
// Remove from source group
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
// Add to target group
targetGroupCopy.taskIds.splice(insertIndex, 0, taskId);
}
// Reorder within the group
const sourceIndex = groupCopy.taskIds.indexOf(taskId);
// Remove task from old position
groupCopy.taskIds.splice(sourceIndex, 1);
// Insert at new position
groupCopy.taskIds.splice(insertIndex, 0, taskId);
// Now assign sequential sort orders to ALL tasks across ALL groups
let currentSortOrder = 0;
updatedGroups.forEach(group => {
group.taskIds.forEach(id => {
const update: any = {
updatedGroups.forEach(grp => {
grp.taskIds.forEach(id => {
taskUpdates.push({
task_id: id,
sort_order: currentSortOrder
};
// Add group-specific fields for the moved task if it changed groups
if (id === taskId && sourceGroup.id !== targetGroup.id) {
if (currentGrouping === 'status') {
update.status_id = targetGroup.id;
} else if (currentGrouping === 'priority') {
update.priority_id = targetGroup.id;
} else if (currentGrouping === 'phase') {
update.phase_id = targetGroup.id;
}
}
taskUpdates.push(update);
});
currentSortOrder++;
});
});
@@ -96,8 +73,8 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
project_id: projectId,
group_by: currentGrouping || 'status',
task_updates: taskUpdates,
from_group: sourceGroup.id,
to_group: targetGroup.id,
from_group: group.id,
to_group: group.id,
task: {
id: task.id,
project_id: projectId,
@@ -108,32 +85,6 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
};
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData);
// Also emit the specific grouping field change event for the moved task
if (sourceGroup.id !== targetGroup.id) {
if (currentGrouping === 'phase') {
// Emit phase change event
socket.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
task_id: taskId,
phase_id: targetGroup.id,
parent_task: task.parent_task_id || null,
});
} else if (currentGrouping === 'priority') {
// Emit priority change event
socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify({
task_id: taskId,
priority_id: targetGroup.id,
team_id: teamId,
}));
} else if (currentGrouping === 'status') {
// Emit status change event
socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), JSON.stringify({
task_id: taskId,
status_id: targetGroup.id,
team_id: teamId,
}));
}
}
},
[socket, connected, projectId, allTasks, groups, currentGrouping, currentSession]
);
@@ -148,32 +99,38 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
if (!over) {
setOverId(null);
setDropPosition(null);
return;
}
const activeId = active.id;
const overId = over.id;
// Set the overId for drop indicators
setOverId(overId as string);
// Find the active task and the item being dragged over
const activeTask = allTasks.find(task => task.id === activeId);
if (!activeTask) return;
// Check if we're dragging over a task or a group
const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId);
// Find the groups
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
let targetGroup = overGroup;
if (overTask) {
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
const activeTask = allTasks.find(task => task.id === active.id);
const overTask = allTasks.find(task => task.id === over.id);
if (activeTask && overTask) {
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
// Only set overId if both tasks are in the same group
if (activeGroup && overGroup && activeGroup.id === overGroup.id) {
setOverId(over.id as string);
// Calculate drop position based on task indices
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
const overIndex = activeGroup.taskIds.indexOf(overTask.id);
if (activeIndex < overIndex) {
setDropPosition('after');
} else {
setDropPosition('before');
}
} else {
setOverId(null);
setDropPosition(null);
}
} else {
setOverId(null);
setDropPosition(null);
}
if (!activeGroup || !targetGroup) return;
},
[allTasks, groups]
);
@@ -183,6 +140,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
const { active, over } = event;
setActiveId(null);
setOverId(null);
setDropPosition(null);
if (!over || active.id === over.id) {
return;
@@ -198,86 +156,50 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
return;
}
// Find the groups
// Find the active task's group
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
if (!activeGroup) {
logger.error('Could not find active group for task:', activeId);
return;
}
// Check if we're dropping on a task, group, or empty group
// Only allow dropping on tasks in the same group
const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId);
// Check if dropping on empty group drop zone
const isEmptyGroupDrop = typeof overId === 'string' && overId.startsWith('empty-group-');
const emptyGroupId = isEmptyGroupDrop ? overId.replace('empty-group-', '') : null;
const emptyGroup = emptyGroupId ? groups.find(group => group.id === emptyGroupId) : null;
let targetGroup = overGroup || emptyGroup;
let insertIndex = 0;
if (overTask) {
// Dropping on a task
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
if (targetGroup) {
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
}
} else if (overGroup) {
// Dropping on a group (at the end)
targetGroup = overGroup;
insertIndex = targetGroup.taskIds.length;
} else if (emptyGroup) {
// Dropping on an empty group
targetGroup = emptyGroup;
insertIndex = 0; // First position in empty group
}
if (!targetGroup) {
logger.error('Could not find target group');
if (!overTask) {
return;
}
const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
if (!overGroup || overGroup.id !== activeGroup.id) {
return;
}
const isCrossGroup = activeGroup.id !== targetGroup.id;
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
const overIndex = activeGroup.taskIds.indexOf(overTask.id);
if (isCrossGroup) {
// Moving task between groups
console.log('Moving task between groups:', {
task: activeTask.name || activeTask.title,
from: activeGroup.title,
to: targetGroup.title,
newPosition: insertIndex,
});
// reorderTasksInGroup handles both same-group and cross-group moves
// No need for separate moveTaskBetweenGroups call
if (activeIndex !== overIndex) {
// Reorder task within same group
dispatch(
reorderTasksInGroup({
sourceTaskId: activeId as string,
destinationTaskId: over.id as string,
destinationTaskId: overId as string,
sourceGroupId: activeGroup.id,
destinationGroupId: targetGroup.id,
destinationGroupId: activeGroup.id,
})
);
// Emit socket event for persistence
emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex);
} else {
if (activeIndex !== insertIndex) {
// Reorder task within same group at drop position
dispatch(
reorderTasksInGroup({
sourceTaskId: activeId as string,
destinationTaskId: over.id as string,
sourceGroupId: activeGroup.id,
destinationGroupId: activeGroup.id,
})
);
// Emit socket event for persistence
emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex);
// Calculate the final index after reordering for socket emission
let finalIndex = overIndex;
if (activeIndex < overIndex) {
// When dragging down, the task ends up just after the destination
finalIndex = overIndex;
} else {
// When dragging up, the task ends up at the destination position
finalIndex = overIndex;
}
// Emit socket event for persistence
emitTaskSortChange(activeId as string, activeGroup, finalIndex);
}
},
[allTasks, groups, dispatch, emitTaskSortChange]
@@ -286,6 +208,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
return {
activeId,
overId,
dropPosition,
handleDragStart,
handleDragOver,
handleDragEnd,

View File

@@ -90,17 +90,39 @@ export const useTaskRowColumns = ({
depth = 0,
}: UseTaskRowColumnsProps) => {
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
switch (columnId) {
case 'dragHandle':
return (
<DragHandleColumn
width={width}
isSubtask={isSubtask}
attributes={attributes}
listeners={listeners}
/>
);
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number, currentBg?: string, rowBackgrounds?: any) => {
// Calculate left position for sticky columns
let leftPosition = 0;
if (isSticky && typeof index === 'number') {
for (let i = 0; i < index; i++) {
const prevColumn = visibleColumns[i];
if (prevColumn.isSticky) {
leftPosition += parseInt(prevColumn.width.replace('px', ''));
}
}
}
// Create wrapper style for sticky positioning
const wrapperStyle = isSticky ? {
position: 'sticky' as const,
left: leftPosition,
zIndex: 5, // Lower than header but above regular content
backgroundColor: currentBg || (isDarkMode ? '#1e1e1e' : '#ffffff'), // Use dynamic background or fallback
overflow: 'hidden', // Prevent content from spilling over
width: width, // Ensure the wrapper respects column width
} : {};
const renderColumnContent = () => {
switch (columnId) {
case 'dragHandle':
return (
<DragHandleColumn
width={width}
isSubtask={isSubtask}
attributes={attributes}
listeners={listeners}
/>
);
case 'checkbox':
return (
@@ -294,7 +316,27 @@ export const useTaskRowColumns = ({
);
}
return null;
}
};
// Wrap content with sticky positioning if needed
const content = renderColumnContent();
if (isSticky) {
const hoverBg = rowBackgrounds?.hover || (isDarkMode ? '#2a2a2a' : '#f9fafb');
return (
<div
style={{
...wrapperStyle,
'--hover-bg': hoverBg,
} as React.CSSProperties}
className="border-r border-gray-200 dark:border-gray-700 overflow-hidden sticky-column-hover hover:bg-[var(--hover-bg)]"
>
{content}
</div>
);
}
return content;
}, [
task,
projectId,
@@ -319,6 +361,7 @@ export const useTaskRowColumns = ({
handleTaskNameEdit,
attributes,
listeners,
depth,
]);
return { renderColumn };

View File

@@ -8,6 +8,8 @@ import { Task } from '@/types/task-management.types';
import {
updateTask,
selectCurrentGroupingV3,
selectGroups,
moveTaskBetweenGroups,
} from '@/features/task-management/task-management.slice';
interface TaskStatusDropdownProps {
@@ -30,6 +32,7 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
const statusList = useAppSelector(state => state.taskStatusReducer.status);
const currentGroupingV3 = useAppSelector(selectCurrentGroupingV3);
const groups = useAppSelector(selectGroups);
// Find current status details
const currentStatus = useMemo(() => {
@@ -44,21 +47,53 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
(statusId: string, statusName: string) => {
if (!task.id || !statusId || !connected) return;
console.log('🎯 Status change initiated:', { taskId: task.id, statusId, statusName });
// Optimistic update: immediately update the task status in Redux for instant feedback
const updatedTask = {
...task,
status: statusId,
updatedAt: new Date().toISOString(),
};
dispatch(updateTask(updatedTask));
// Handle group movement if grouping by status
if (currentGroupingV3 === 'status' && groups && groups.length > 0) {
// Find current group containing the task
const currentGroup = groups.find(group => group.taskIds.includes(task.id));
// Find target group based on the new status ID
let targetGroup = groups.find(group => group.id === statusId);
// If not found by status ID, try matching with group value
if (!targetGroup) {
targetGroup = groups.find(group => group.groupValue === statusId);
}
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
// Move task between groups immediately for instant feedback
dispatch(
moveTaskBetweenGroups({
taskId: task.id,
sourceGroupId: currentGroup.id,
targetGroupId: targetGroup.id,
})
);
}
}
// Emit socket event for server-side update and real-time sync
socket?.emit(
SocketEvents.TASK_STATUS_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
status_id: statusId,
parent_task: null, // Assuming top-level tasks for now
team_id: projectId, // Using projectId as teamId
parent_task: task.parent_task_id || null,
team_id: projectId,
})
);
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
setIsOpen(false);
},
[task.id, connected, socket, projectId]
[task, connected, socket, projectId, dispatch, currentGroupingV3, groups]
);
// Calculate dropdown position and handle outside clicks

View File

@@ -0,0 +1,121 @@
// Update Notification Component
// Shows a notification when new build is available and provides update options
import React from 'react';
import { Modal, Button, Space, Typography } from '@/shared/antd-imports';
import { ReloadOutlined, CloseOutlined, DownloadOutlined } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { useServiceWorker } from '../../utils/serviceWorkerRegistration';
const { Text, Title } = Typography;
interface UpdateNotificationProps {
visible: boolean;
onClose: () => void;
onUpdate: () => void;
}
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
visible,
onClose,
onUpdate
}) => {
const { t } = useTranslation('common');
const [isUpdating, setIsUpdating] = React.useState(false);
const { hardReload } = useServiceWorker();
const handleUpdate = async () => {
setIsUpdating(true);
try {
if (hardReload) {
await hardReload();
} else {
// Fallback to regular reload
window.location.reload();
}
onUpdate();
} catch (error) {
console.error('Error during update:', error);
// Fallback to regular reload
window.location.reload();
}
};
const handleLater = () => {
onClose();
};
return (
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<DownloadOutlined style={{ color: '#1890ff' }} />
<Title level={4} style={{ margin: 0, color: '#1890ff' }}>
{t('update-available')}
</Title>
</div>
}
open={visible}
onCancel={handleLater}
footer={null}
centered
closable={false}
maskClosable={false}
width={460}
styles={{
body: { padding: '20px 24px' }
}}
>
<div style={{ marginBottom: '20px' }}>
<Text style={{ fontSize: '16px', lineHeight: '1.6' }}>
{t('update-description')}
</Text>
<br />
<br />
<Text style={{ fontSize: '14px', color: '#8c8c8c' }}>
{t('update-instruction')}
</Text>
</div>
<div style={{
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '6px',
padding: '12px',
marginBottom: '20px'
}}>
<Text style={{ fontSize: '13px', color: '#389e0d' }}>
{t('update-whats-new', {
interpolation: { escapeValue: false }
})}
</Text>
</div>
<Space
style={{
width: '100%',
justifyContent: 'flex-end'
}}
size="middle"
>
<Button
icon={<CloseOutlined />}
onClick={handleLater}
disabled={isUpdating}
>
{t('update-later')}
</Button>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={isUpdating}
onClick={handleUpdate}
>
{isUpdating ? t('updating') : t('update-now')}
</Button>
</Space>
</Modal>
);
};
export default UpdateNotification;

View File

@@ -0,0 +1,50 @@
// Update Notification Provider
// Provides global update notification management
import React from 'react';
import { useUpdateChecker } from '../../hooks/useUpdateChecker';
import UpdateNotification from './UpdateNotification';
interface UpdateNotificationProviderProps {
children: React.ReactNode;
checkInterval?: number;
enableAutoCheck?: boolean;
}
const UpdateNotificationProvider: React.FC<UpdateNotificationProviderProps> = ({
children,
checkInterval = 5 * 60 * 1000, // 5 minutes
enableAutoCheck = true
}) => {
const {
showUpdateNotification,
setShowUpdateNotification,
dismissUpdate
} = useUpdateChecker({
checkInterval,
enableAutoCheck,
showNotificationOnUpdate: true
});
const handleClose = () => {
dismissUpdate();
};
const handleUpdate = () => {
// The hardReload function in UpdateNotification will handle the actual update
setShowUpdateNotification(false);
};
return (
<>
{children}
<UpdateNotification
visible={showUpdateNotification}
onClose={handleClose}
onUpdate={handleUpdate}
/>
</>
);
};
export default UpdateNotificationProvider;

View File

@@ -0,0 +1,2 @@
export { default as UpdateNotification } from './UpdateNotification';
export { default as UpdateNotificationProvider } from './UpdateNotificationProvider';

View File

@@ -0,0 +1,59 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { ActivityFeedType } from '@/types/home/user-activity.types';
interface ActivityItem {
id: string;
type: string;
description: string;
timestamp: string;
}
interface UserActivityState {
activeTab: ActivityFeedType;
activities: ActivityItem[];
loading: boolean;
error: string | null;
}
const initialState: UserActivityState = {
activeTab: ActivityFeedType.TIME_LOGGED_TASKS,
activities: [],
loading: false,
error: null,
};
const userActivitySlice = createSlice({
name: 'userActivity',
initialState,
reducers: {
setActiveTab(state, action: PayloadAction<ActivityFeedType>) {
state.activeTab = action.payload;
},
fetchActivitiesStart(state) {
state.loading = true;
state.error = null;
},
fetchActivitiesSuccess(state, action: PayloadAction<ActivityItem[]>) {
state.activities = action.payload;
state.loading = false;
state.error = null;
},
fetchActivitiesFailure(state, action: PayloadAction<string>) {
state.loading = false;
state.error = action.payload;
},
clearActivities(state) {
state.activities = [];
},
},
});
export const {
setActiveTab,
fetchActivitiesStart,
fetchActivitiesSuccess,
fetchActivitiesFailure,
clearActivities,
} = userActivitySlice.actions;
export default userActivitySlice.reducer;

View File

@@ -153,8 +153,8 @@ const Navbar = () => {
<Flex align="center">
<SwitchTeamButton />
<NotificationButton />
{/* <TimerButton /> */}
<HelpButton />
<TimerButton />
{/* <HelpButton /> */}
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
</Flex>
</Flex>

View File

@@ -7,7 +7,7 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice';
import moment from 'moment';
import { format, differenceInSeconds, isValid, parseISO } from 'date-fns';
const { Text } = Typography;
const { useToken } = theme;
@@ -60,17 +60,17 @@ const TimerButton = () => {
try {
if (!timer || !timer.task_id || !timer.start_time) return;
const startTime = moment(timer.start_time);
if (!startTime.isValid()) {
const startTime = parseISO(timer.start_time);
if (!isValid(startTime)) {
logError(`Invalid start time for timer ${timer.task_id}: ${timer.start_time}`);
return;
}
const now = moment();
const duration = moment.duration(now.diff(startTime));
const hours = Math.floor(duration.asHours());
const minutes = duration.minutes();
const seconds = duration.seconds();
const now = new Date();
const totalSeconds = differenceInSeconds(now, startTime);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
newTimes[timer.task_id] =
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
} catch (error) {
@@ -86,12 +86,7 @@ const TimerButton = () => {
useEffect(() => {
fetchRunningTimers();
// Set up polling to refresh timers every 30 seconds
const pollInterval = setInterval(() => {
fetchRunningTimers();
}, 30000);
return () => clearInterval(pollInterval);
// Removed periodic polling - rely on socket events for real-time updates
}, [fetchRunningTimers]);
useEffect(() => {
@@ -273,7 +268,7 @@ const TimerButton = () => {
<Text type="secondary" style={{ fontSize: 11 }}>
Started:{' '}
{timer.start_time
? moment(timer.start_time).format('HH:mm')
? format(parseISO(timer.start_time), 'HH:mm')
: '--:--'}
</Text>
<Text

View File

@@ -1,5 +1,5 @@
import { Drawer, Typography, Flex, Button, Dropdown } from '@/shared/antd-imports';
import React, { useState } from 'react';
import React, { useState, useCallback } from 'react';
import { useAppSelector } from '../../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
import { setSelectedProject, toggleProjectReportsDrawer } from '../project-reports-slice';
@@ -8,6 +8,8 @@ import ProjectReportsDrawerTabs from './ProjectReportsDrawerTabs';
import { colors } from '../../../../styles/colors';
import { useTranslation } from 'react-i18next';
import { IRPTProject } from '@/types/reporting/reporting.types';
import { useAuthService } from '../../../../hooks/useAuth';
import { reportingExportApiService } from '@/api/reporting/reporting-export.api.service';
type ProjectReportsDrawerProps = {
selectedProject: IRPTProject | null;
@@ -17,6 +19,8 @@ const ProjectReportsDrawer = ({ selectedProject }: ProjectReportsDrawerProps) =>
const { t } = useTranslation('reporting-projects-drawer');
const dispatch = useAppDispatch();
const currentSession = useAuthService().getCurrentSession();
const [exporting, setExporting] = useState<boolean>(false);
// get drawer open state and project list from the reducer
const isDrawerOpen = useAppSelector(
@@ -35,6 +39,54 @@ const ProjectReportsDrawer = ({ selectedProject }: ProjectReportsDrawerProps) =>
}
};
// Export handlers
const handleExportMembers = useCallback(() => {
if (!selectedProject?.id) return;
try {
setExporting(true);
const teamName = currentSession?.team_name || 'Team';
reportingExportApiService.exportProjectMembers(
selectedProject.id,
selectedProject.name,
teamName
);
} catch (error) {
console.error('Error exporting project members:', error);
} finally {
setExporting(false);
}
}, [selectedProject, currentSession?.team_name]);
const handleExportTasks = useCallback(() => {
if (!selectedProject?.id) return;
try {
setExporting(true);
const teamName = currentSession?.team_name || 'Team';
reportingExportApiService.exportProjectTasks(
selectedProject.id,
selectedProject.name,
teamName
);
} catch (error) {
console.error('Error exporting project tasks:', error);
} finally {
setExporting(false);
}
}, [selectedProject, currentSession?.team_name]);
const handleExportClick = useCallback((key: string) => {
switch (key) {
case '1':
handleExportMembers();
break;
case '2':
handleExportTasks();
break;
default:
break;
}
}, [handleExportMembers, handleExportTasks]);
return (
<Drawer
open={isDrawerOpen}
@@ -56,9 +108,15 @@ const ProjectReportsDrawer = ({ selectedProject }: ProjectReportsDrawerProps) =>
{ key: '1', label: t('membersButton') },
{ key: '2', label: t('tasksButton') },
],
onClick: ({ key }) => handleExportClick(key),
}}
>
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
<Button
type="primary"
loading={exporting}
icon={<DownOutlined />}
iconPosition="end"
>
{t('exportButton')}
</Button>
</Dropdown>

View File

@@ -680,8 +680,23 @@ const taskManagementSlice = createSlice({
const group = state.groups.find(g => g.id === sourceGroupId);
if (group) {
const newTasks = Array.from(group.taskIds);
const [removed] = newTasks.splice(newTasks.indexOf(sourceTaskId), 1);
newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed);
const sourceIndex = newTasks.indexOf(sourceTaskId);
const destinationIndex = newTasks.indexOf(destinationTaskId);
// Remove the task from its current position
const [removed] = newTasks.splice(sourceIndex, 1);
// Calculate the insertion index
let insertIndex = destinationIndex;
if (sourceIndex < destinationIndex) {
// When dragging down, we need to insert after the destination
insertIndex = destinationIndex;
} else {
// When dragging up, we insert before the destination
insertIndex = destinationIndex;
}
newTasks.splice(insertIndex, 0, removed);
group.taskIds = newTasks;
// Update order for affected tasks using the appropriate sort field

View File

@@ -244,44 +244,18 @@ export const useTaskSocketHandlers = () => {
// Find current group containing the task
const currentGroup = groups.find(group => group.taskIds.includes(response.id));
// Find target group based on new status value with multiple matching strategies
let targetGroup = groups.find(group => group.groupValue === newStatusValue);
// Find target group based on the actual status ID from response
let targetGroup = groups.find(group => group.id === response.status_id);
// If not found, try case-insensitive matching
// If not found by status ID, try matching with group value
if (!targetGroup) {
targetGroup = groups.find(group =>
group.groupValue?.toLowerCase() === newStatusValue.toLowerCase()
);
targetGroup = groups.find(group => group.groupValue === response.status_id);
}
// If still not found, try matching with title
if (!targetGroup) {
// If still not found, try matching by status name (fallback)
if (!targetGroup && response.status) {
targetGroup = groups.find(group =>
group.title?.toLowerCase() === newStatusValue.toLowerCase()
);
}
// If still not found, try matching common status patterns
if (!targetGroup && newStatusValue === 'todo') {
targetGroup = groups.find(group =>
group.title?.toLowerCase().includes('todo') ||
group.title?.toLowerCase().includes('to do') ||
group.title?.toLowerCase().includes('pending') ||
group.groupValue?.toLowerCase().includes('todo')
);
} else if (!targetGroup && newStatusValue === 'doing') {
targetGroup = groups.find(group =>
group.title?.toLowerCase().includes('doing') ||
group.title?.toLowerCase().includes('progress') ||
group.title?.toLowerCase().includes('active') ||
group.groupValue?.toLowerCase().includes('doing')
);
} else if (!targetGroup && newStatusValue === 'done') {
targetGroup = groups.find(group =>
group.title?.toLowerCase().includes('done') ||
group.title?.toLowerCase().includes('complete') ||
group.title?.toLowerCase().includes('finish') ||
group.groupValue?.toLowerCase().includes('done')
group.title?.toLowerCase() === response.status.toLowerCase()
);
}
@@ -295,14 +269,11 @@ export const useTaskSocketHandlers = () => {
})
);
} else if (!targetGroup) {
console.log('❌ Target group not found for status:', newStatusValue);
} else if (!currentGroup) {
console.log('❌ Current group not found for task:', response.id);
} else {
console.log('🔧 No group movement needed - task already in correct group');
// Fallback: refetch tasks to ensure consistency
if (projectId) {
dispatch(fetchTasksV3(projectId));
}
}
} else {
console.log('🔧 Not grouped by status, skipping group movement');
}
}
},

View File

@@ -0,0 +1,94 @@
import { useState, useEffect, useCallback } from 'react';
import { Modal } from 'antd';
import { useTranslation } from 'react-i18next';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
import { useTaskTimer } from './useTaskTimer';
interface ConflictingTimer {
task_id: string;
task_name: string;
project_name: string;
}
export const useTaskTimerWithConflictCheck = (taskId: string, timerStartTime: string | null) => {
const { socket } = useSocket();
const { t: tTable } = useTranslation('task-list-table');
const { t: tCommon } = useTranslation('common');
// Ensure timerStartTime is a number or null, as required by useTaskTimer
const parsedTimerStartTime = typeof timerStartTime === 'string' && timerStartTime !== null
? Number(timerStartTime)
: timerStartTime;
const originalHook = useTaskTimer(taskId, parsedTimerStartTime as number | null);
const [isCheckingConflict, setIsCheckingConflict] = useState(false);
const checkForConflictingTimers = useCallback(async () => {
try {
const response = await taskTimeLogsApiService.getRunningTimers();
const runningTimers = response.body || [];
// Find conflicting timer (running timer for a different task)
const conflictingTimer = runningTimers.find((timer: ConflictingTimer) =>
timer.task_id !== taskId
);
return conflictingTimer;
} catch (error) {
console.error('Error checking for conflicting timers:', error);
return null;
}
}, [taskId]);
const handleStartTimerWithConflictCheck = useCallback(async () => {
if (isCheckingConflict) return;
setIsCheckingConflict(true);
try {
const conflictingTimer = await checkForConflictingTimers();
if (conflictingTimer) {
Modal.confirm({
title: tTable('timer.conflictTitle'),
content: tTable('timer.conflictMessage', {
taskName: conflictingTimer.task_name,
projectName: conflictingTimer.project_name
}),
okText: tTable('timer.stopAndStart'),
cancelText: tCommon('cancel'),
onOk: () => {
// Stop the conflicting timer
if (socket) {
socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({
task_id: conflictingTimer.task_id
}));
}
// Start the new timer after a short delay
setTimeout(() => {
originalHook.handleStartTimer();
}, 100);
},
});
} else {
// No conflict, start timer directly
originalHook.handleStartTimer();
}
} catch (error) {
console.error('Error handling timer start with conflict check:', error);
// Fallback to original behavior
originalHook.handleStartTimer();
} finally {
setIsCheckingConflict(false);
}
}, [isCheckingConflict, checkForConflictingTimers, tTable, tCommon, socket, originalHook]);
return {
...originalHook,
handleStartTimer: handleStartTimerWithConflictCheck,
isCheckingConflict,
};
};

View File

@@ -0,0 +1,141 @@
// Update Checker Hook
// Periodically checks for app updates and manages update notifications
import React from 'react';
import { useServiceWorker } from '../utils/serviceWorkerRegistration';
interface UseUpdateCheckerOptions {
checkInterval?: number; // Check interval in milliseconds (default: 5 minutes)
enableAutoCheck?: boolean; // Enable automatic checking (default: true)
showNotificationOnUpdate?: boolean; // Show notification when update is found (default: true)
}
interface UseUpdateCheckerReturn {
hasUpdate: boolean;
isChecking: boolean;
lastChecked: Date | null;
checkForUpdates: () => Promise<void>;
dismissUpdate: () => void;
showUpdateNotification: boolean;
setShowUpdateNotification: (show: boolean) => void;
}
export function useUpdateChecker(options: UseUpdateCheckerOptions = {}): UseUpdateCheckerReturn {
const {
checkInterval = 5 * 60 * 1000, // 5 minutes
enableAutoCheck = true,
showNotificationOnUpdate = true
} = options;
const { checkForUpdates: serviceWorkerCheckUpdates, swManager } = useServiceWorker();
const [hasUpdate, setHasUpdate] = React.useState(false);
const [isChecking, setIsChecking] = React.useState(false);
const [lastChecked, setLastChecked] = React.useState<Date | null>(null);
const [showUpdateNotification, setShowUpdateNotification] = React.useState(false);
const [updateDismissed, setUpdateDismissed] = React.useState(false);
// Check for updates function
const checkForUpdates = React.useCallback(async () => {
if (!serviceWorkerCheckUpdates || isChecking) return;
setIsChecking(true);
try {
const hasUpdates = await serviceWorkerCheckUpdates();
setHasUpdate(hasUpdates);
setLastChecked(new Date());
// Show notification if update found and user hasn't dismissed it
if (hasUpdates && showNotificationOnUpdate && !updateDismissed) {
setShowUpdateNotification(true);
}
console.log('Update check completed:', { hasUpdates });
} catch (error) {
console.error('Error checking for updates:', error);
} finally {
setIsChecking(false);
}
}, [serviceWorkerCheckUpdates, isChecking, showNotificationOnUpdate, updateDismissed]);
// Dismiss update notification
const dismissUpdate = React.useCallback(() => {
setUpdateDismissed(true);
setShowUpdateNotification(false);
}, []);
// Set up automatic checking interval
React.useEffect(() => {
if (!enableAutoCheck || !swManager) return;
// Initial check after a short delay
const initialTimeout = setTimeout(() => {
checkForUpdates();
}, 10000); // 10 seconds after component mount
// Set up interval for periodic checks
const intervalId = setInterval(() => {
checkForUpdates();
}, checkInterval);
return () => {
clearTimeout(initialTimeout);
clearInterval(intervalId);
};
}, [enableAutoCheck, swManager, checkInterval, checkForUpdates]);
// Listen for visibility change to check for updates when user returns to tab
React.useEffect(() => {
if (!enableAutoCheck) return;
const handleVisibilityChange = () => {
if (!document.hidden && swManager) {
// Check for updates when user returns to the tab
setTimeout(() => {
checkForUpdates();
}, 2000); // 2 second delay
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [enableAutoCheck, swManager, checkForUpdates]);
// Listen for focus events to check for updates
React.useEffect(() => {
if (!enableAutoCheck) return;
const handleFocus = () => {
if (swManager && !isChecking) {
// Check for updates when window regains focus
setTimeout(() => {
checkForUpdates();
}, 1000); // 1 second delay
}
};
window.addEventListener('focus', handleFocus);
return () => {
window.removeEventListener('focus', handleFocus);
};
}, [enableAutoCheck, swManager, isChecking, checkForUpdates]);
// Reset dismissed state when new update is found
React.useEffect(() => {
if (hasUpdate && updateDismissed) {
setUpdateDismissed(false);
}
}, [hasUpdate, updateDismissed]);
return {
hasUpdate,
isChecking,
lastChecked,
checkForUpdates,
dismissUpdate,
showUpdateNotification,
setShowUpdateNotification
};
}

View File

@@ -4,40 +4,25 @@ import { Outlet } from 'react-router-dom';
import { useMediaQuery } from 'react-responsive';
import AdminCenterSidebar from '@/pages/admin-center/sidebar/sidebar';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
const AdminCenterLayout: React.FC = () => {
const dispatch = useAppDispatch();
const isTablet = useMediaQuery({ query: '(min-width:768px)' });
const isMarginAvailable = useMediaQuery({ query: '(min-width: 1000px)' });
const { t } = useTranslation('admin-center/sidebar');
return (
<div
style={{
marginBlock: 96,
minHeight: '90vh',
marginLeft: `${isMarginAvailable ? '5%' : ''}`,
marginRight: `${isMarginAvailable ? '5%' : ''}`,
}}
>
<div className="my-6">
<Typography.Title level={4}>{t('adminCenter')}</Typography.Title>
{isTablet ? (
<Flex
gap={24}
align="flex-start"
style={{
width: '100%',
marginBlockStart: 24,
}}
className="w-full mt-6"
>
<Flex style={{ width: '100%', maxWidth: 240 }}>
<Flex className="w-full max-w-60">
<AdminCenterSidebar />
</Flex>
<Flex style={{ width: '100%' }}>
<Flex className="w-full">
<Outlet />
</Flex>
</Flex>
@@ -45,9 +30,7 @@ const AdminCenterLayout: React.FC = () => {
<Flex
vertical
gap={24}
style={{
marginBlockStart: 24,
}}
className="mt-6"
>
<AdminCenterSidebar />
<Outlet />

View File

@@ -1,62 +1,18 @@
import { Col, ConfigProvider, Layout } from '@/shared/antd-imports';
import { Outlet } from 'react-router-dom';
import { memo, useMemo, useEffect, useRef } from 'react';
import { useMediaQuery } from 'react-responsive';
import { ConfigProvider, Layout } from '@/shared/antd-imports';
import { Outlet, useLocation } from 'react-router-dom';
import { memo, useMemo } from 'react';
import Navbar from '../features/navbar/navbar';
import { useAppSelector } from '../hooks/useAppSelector';
import { colors } from '../styles/colors';
import { useRenderPerformance } from '@/utils/performance';
import { DynamicCSSLoader, LayoutStabilizer } from '@/utils/css-optimizations';
const MainLayout = memo(() => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' });
const layoutRef = useRef<HTMLDivElement>(null);
// Performance monitoring in development
useRenderPerformance('MainLayout');
// Apply layout optimizations
useEffect(() => {
if (layoutRef.current) {
// Prevent layout shifts in main content area
LayoutStabilizer.applyContainment(layoutRef.current, 'layout');
// Load non-critical CSS dynamically
DynamicCSSLoader.loadCSS('/styles/non-critical.css', {
priority: 'low',
media: 'all'
});
}
}, []);
const location = useLocation();
const isProjectView = location.pathname.includes('/projects/') &&
!location.pathname.endsWith('/projects');
// Memoize styles to prevent object recreation on every render
const headerStyles = useMemo(
() => ({
zIndex: 999,
position: 'fixed' as const,
width: '100%',
display: 'flex',
alignItems: 'center',
padding: 0,
borderBottom: themeMode === 'dark' ? '1px solid #303030' : 'none',
}),
[themeMode]
);
const contentStyles = useMemo(
() => ({
paddingInline: isDesktop ? 64 : 24,
overflowX: 'hidden' as const,
}),
[isDesktop]
);
// Memoize theme configuration
const themeConfig = useMemo(
() => ({
components: {
@@ -69,23 +25,19 @@ const MainLayout = memo(() => {
[themeMode]
);
// Memoize header className
const headerClassName = useMemo(
() => `shadow-md ${themeMode === 'dark' ? '' : 'shadow-[#18181811]'}`,
[themeMode]
);
return (
<ConfigProvider theme={themeConfig}>
<Layout ref={layoutRef} style={{ minHeight: '100vh' }} className="prevent-layout-shift">
<Layout.Header className={`${headerClassName} gpu-accelerated`} style={headerStyles}>
<Layout className="min-h-screen">
<Layout.Header
className={`sticky top-0 z-[999] flex items-center p-0 shadow-md ${
themeMode === 'dark' ? 'border-b border-[#303030]' : 'shadow-[#18181811]'
}`}
>
<Navbar />
</Layout.Header>
<Layout.Content className="layout-contained">
<Col xxl={{ span: 18, offset: 3, flex: '100%' }} style={contentStyles} className="task-content-container">
<Outlet />
</Col>
<Layout.Content className={`px-4 sm:px-8 lg:px-12 xl:px-16 ${!isProjectView ? 'overflow-x-hidden max-w-[1400px]' : ''} mx-auto w-full`}>
<Outlet />
</Layout.Content>
</Layout>
</ConfigProvider>

View File

@@ -1,35 +1,25 @@
import { Flex, Typography } from '@/shared/antd-imports';
import SettingsSidebar from '../pages/settings/sidebar/settings-sidebar';
import { Outlet, useNavigate } from 'react-router-dom';
import { Outlet } from 'react-router-dom';
import { useMediaQuery } from 'react-responsive';
import { useEffect } from 'react';
import { useAuthService } from '@/hooks/useAuth';
const SettingsLayout = () => {
const isTablet = useMediaQuery({ query: '(min-width: 768px)' });
const { getCurrentSession } = useAuthService();
const currentSession = getCurrentSession();
const navigate = useNavigate();
return (
<div style={{ marginBlock: 96, minHeight: '90vh' }}>
<div className="my-6 min-h-[90vh]">
<Typography.Title level={4}>Settings</Typography.Title>
{isTablet ? (
<Flex
gap={24}
align="flex-start"
style={{
width: '100%',
marginBlockStart: 24,
}}
className="w-full mt-6"
>
<Flex style={{ width: '100%', maxWidth: 240 }}>
<Flex className="w-full max-w-60">
<SettingsSidebar />
</Flex>
<Flex style={{ width: '100%' }}>
<Flex className="w-full">
<Outlet />
</Flex>
</Flex>
@@ -37,9 +27,7 @@ const SettingsLayout = () => {
<Flex
vertical
gap={24}
style={{
marginBlockStart: 24,
}}
className="mt-6"
>
<SettingsSidebar />
<Outlet />

View File

@@ -18,7 +18,7 @@ const ProfileSettings = lazy(() => import('../../pages/settings/profile/profile-
const NotificationsSettings = lazy(() => import('../../pages/settings/notifications/notifications-settings'));
const ClientsSettings = lazy(() => import('../../pages/settings/clients/clients-settings'));
const JobTitlesSettings = lazy(() => import('@/pages/settings/job-titles/job-titles-settings'));
const LabelsSettings = lazy(() => import('../../pages/settings/labels/labels-settings'));
const LabelsSettings = lazy(() => import('../../pages/settings/labels/LabelsSettings'));
const CategoriesSettings = lazy(() => import('../../pages/settings/categories/categories-settings'));
const ProjectTemplatesSettings = lazy(() => import('@/pages/settings/project-templates/project-templates-settings'));
const TaskTemplatesSettings = lazy(() => import('@/pages/settings/task-templates/task-templates-settings'));

View File

@@ -0,0 +1,228 @@
import { render, screen, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { I18nextProvider } from 'react-i18next';
import i18n from 'i18next';
import AuthenticatingPage from '../AuthenticatingPage';
import { verifyAuthentication } from '@/features/auth/authSlice';
import { setUser } from '@/features/user/userSlice';
import { setSession } from '@/utils/session-helper';
// Mock dependencies
vi.mock('@/features/auth/authSlice', () => ({
verifyAuthentication: vi.fn(),
}));
vi.mock('@/features/user/userSlice', () => ({
setUser: vi.fn(),
}));
vi.mock('@/utils/session-helper', () => ({
setSession: vi.fn(),
}));
vi.mock('@/utils/errorLogger', () => ({
default: {
error: vi.fn(),
},
}));
vi.mock('@/shared/constants', () => ({
WORKLENZ_REDIRECT_PROJ_KEY: 'worklenz_redirect_proj',
}));
// Mock navigation
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Mock dispatch
const mockDispatch = vi.fn();
vi.mock('@/hooks/useAppDispatch', () => ({
useAppDispatch: () => mockDispatch,
}));
// Setup i18n for testing
i18n.init({
lng: 'en',
resources: {
en: {
'auth/auth-common': {
authenticating: 'Authenticating...',
gettingThingsReady: 'Getting things ready for you...',
},
},
},
});
// Create test store
const createTestStore = () => {
return configureStore({
reducer: {
auth: (state = {}) => state,
user: (state = {}) => state,
},
});
};
const renderWithProviders = (component: React.ReactElement) => {
const store = createTestStore();
return render(
<Provider store={store}>
<BrowserRouter>
<I18nextProvider i18n={i18n}>
{component}
</I18nextProvider>
</BrowserRouter>
</Provider>
);
};
describe('AuthenticatingPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
localStorage.clear();
// Mock window.location
Object.defineProperty(window, 'location', {
value: { href: '' },
writable: true,
});
});
afterEach(() => {
vi.useRealTimers();
});
it('renders loading state correctly', () => {
renderWithProviders(<AuthenticatingPage />);
expect(screen.getByText('Authenticating...')).toBeInTheDocument();
expect(screen.getByText('Getting things ready for you...')).toBeInTheDocument();
expect(screen.getByRole('generic', { busy: true })).toBeInTheDocument();
});
it('redirects to login when authentication fails', async () => {
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockResolvedValue({ authenticated: false }),
});
renderWithProviders(<AuthenticatingPage />);
// Run all pending timers
await vi.runAllTimersAsync();
expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
});
it('redirects to setup when user setup is not completed', async () => {
const mockUser = {
id: '1',
email: 'test@example.com',
setup_completed: false,
};
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockResolvedValue({
authenticated: true,
user: mockUser,
}),
});
renderWithProviders(<AuthenticatingPage />);
// Run all pending timers
await vi.runAllTimersAsync();
expect(setSession).toHaveBeenCalledWith(mockUser);
expect(setUser).toHaveBeenCalledWith(mockUser);
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/setup');
});
it('redirects to home after successful authentication', async () => {
const mockUser = {
id: '1',
email: 'test@example.com',
setup_completed: true,
};
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockResolvedValue({
authenticated: true,
user: mockUser,
}),
});
renderWithProviders(<AuthenticatingPage />);
// Run all pending timers
await vi.runAllTimersAsync();
expect(setSession).toHaveBeenCalledWith(mockUser);
expect(setUser).toHaveBeenCalledWith(mockUser);
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
});
it('redirects to project when redirect key is present in localStorage', async () => {
const projectId = 'test-project-123';
localStorage.setItem('worklenz_redirect_proj', projectId);
const mockUser = {
id: '1',
email: 'test@example.com',
setup_completed: true,
};
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockResolvedValue({
authenticated: true,
user: mockUser,
}),
});
// Mock window.location with a proper setter
let hrefValue = '';
Object.defineProperty(window, 'location', {
value: {
get href() {
return hrefValue;
},
set href(value) {
hrefValue = value;
},
},
writable: true,
});
renderWithProviders(<AuthenticatingPage />);
// Run all pending timers
await vi.runAllTimersAsync();
expect(setSession).toHaveBeenCalledWith(mockUser);
expect(setUser).toHaveBeenCalledWith(mockUser);
expect(hrefValue).toBe(`/worklenz/projects/${projectId}?tab=tasks-list`);
expect(localStorage.getItem('worklenz_redirect_proj')).toBeNull();
});
it('handles authentication errors and redirects to login', async () => {
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockRejectedValue(new Error('Authentication failed')),
});
renderWithProviders(<AuthenticatingPage />);
// Run all pending timers
await vi.runAllTimersAsync();
expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
});
});

View File

@@ -0,0 +1,286 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { I18nextProvider } from 'react-i18next';
import i18n from 'i18next';
import ForgotPasswordPage from '../ForgotPasswordPage';
import { resetPassword, verifyAuthentication } from '@/features/auth/authSlice';
// Mock dependencies
vi.mock('@/features/auth/authSlice', () => ({
resetPassword: vi.fn(),
verifyAuthentication: vi.fn(),
}));
vi.mock('@/features/user/userSlice', () => ({
setUser: vi.fn(),
}));
vi.mock('@/utils/session-helper', () => ({
setSession: vi.fn(),
}));
vi.mock('@/utils/errorLogger', () => ({
default: {
error: vi.fn(),
},
}));
vi.mock('@/hooks/useMixpanelTracking', () => ({
useMixpanelTracking: () => ({
trackMixpanelEvent: vi.fn(),
}),
}));
vi.mock('@/hooks/useDoumentTItle', () => ({
useDocumentTitle: vi.fn(),
}));
vi.mock('react-responsive', () => ({
useMediaQuery: () => false,
}));
// Mock navigation
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Mock dispatch
const mockDispatch = vi.fn();
vi.mock('@/hooks/useAppDispatch', () => ({
useAppDispatch: () => mockDispatch,
}));
// Setup i18n for testing
i18n.init({
lng: 'en',
resources: {
en: {
'auth/forgot-password': {
headerDescription: 'Enter your email to reset your password',
emailRequired: 'Please input your email!',
emailPlaceholder: 'Enter your email',
resetPasswordButton: 'Reset Password',
returnToLoginButton: 'Return to Login',
orText: 'or',
successTitle: 'Password Reset Email Sent',
successMessage: 'Please check your email for instructions to reset your password.',
},
},
},
});
// Create test store
const createTestStore = () => {
return configureStore({
reducer: {
auth: (state = {}) => state,
user: (state = {}) => state,
},
});
};
const renderWithProviders = (component: React.ReactElement) => {
const store = createTestStore();
return render(
<Provider store={store}>
<BrowserRouter>
<I18nextProvider i18n={i18n}>
{component}
</I18nextProvider>
</BrowserRouter>
</Provider>
);
};
describe('ForgotPasswordPage', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock URL search params
Object.defineProperty(window, 'location', {
value: { search: '' },
writable: true,
});
});
it('renders forgot password form correctly', () => {
renderWithProviders(<ForgotPasswordPage />);
expect(screen.getByText('Enter your email to reset your password')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reset Password' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Return to Login' })).toBeInTheDocument();
expect(screen.getByText('or')).toBeInTheDocument();
});
it('validates required email field', async () => {
const user = userEvent.setup();
renderWithProviders(<ForgotPasswordPage />);
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
});
});
it('validates email format', async () => {
const user = userEvent.setup();
renderWithProviders(<ForgotPasswordPage />);
const emailInput = screen.getByPlaceholderText('Enter your email');
await user.type(emailInput, 'invalid-email');
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
});
});
it('submits form with valid email', async () => {
const user = userEvent.setup();
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockResolvedValue({ done: true }),
});
renderWithProviders(<ForgotPasswordPage />);
const emailInput = screen.getByPlaceholderText('Enter your email');
await user.type(emailInput, 'test@example.com');
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
await user.click(submitButton);
await waitFor(() => {
expect(resetPassword).toHaveBeenCalledWith('test@example.com');
});
});
it('shows success message after successful submission', async () => {
const user = userEvent.setup();
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockResolvedValue({ done: true }),
});
renderWithProviders(<ForgotPasswordPage />);
const emailInput = screen.getByPlaceholderText('Enter your email');
await user.type(emailInput, 'test@example.com');
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Password Reset Email Sent')).toBeInTheDocument();
expect(screen.getByText('Please check your email for instructions to reset your password.')).toBeInTheDocument();
});
});
it('shows loading state during submission', async () => {
const user = userEvent.setup();
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
});
renderWithProviders(<ForgotPasswordPage />);
const emailInput = screen.getByPlaceholderText('Enter your email');
await user.type(emailInput, 'test@example.com');
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
});
});
it('handles submission errors gracefully', async () => {
const user = userEvent.setup();
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockRejectedValue(new Error('Reset failed')),
});
renderWithProviders(<ForgotPasswordPage />);
const emailInput = screen.getByPlaceholderText('Enter your email');
await user.type(emailInput, 'test@example.com');
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
await user.click(submitButton);
await waitFor(() => {
// Should not show success message
expect(screen.queryByText('Password Reset Email Sent')).not.toBeInTheDocument();
// Should still show the form
expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
});
});
it('navigates to login page when return button is clicked', async () => {
const user = userEvent.setup();
renderWithProviders(<ForgotPasswordPage />);
const returnButton = screen.getByRole('button', { name: 'Return to Login' });
await user.click(returnButton);
expect(returnButton.closest('a')).toHaveAttribute('href', '/auth/login');
});
it('handles team parameter from URL', () => {
Object.defineProperty(window, 'location', {
value: { search: '?team=test-team-id' },
writable: true,
});
renderWithProviders(<ForgotPasswordPage />);
// Component should render normally even with team parameter
expect(screen.getByText('Enter your email to reset your password')).toBeInTheDocument();
});
it('redirects authenticated users to home', async () => {
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockResolvedValue({
authenticated: true,
user: { id: '1', email: 'test@example.com' },
}),
});
renderWithProviders(<ForgotPasswordPage />);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
});
});
it('does not submit with empty email after trimming', async () => {
const user = userEvent.setup();
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockResolvedValue({ done: true }),
});
renderWithProviders(<ForgotPasswordPage />);
const emailInput = screen.getByPlaceholderText('Enter your email');
await user.type(emailInput, ' '); // Only whitespace
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
await user.click(submitButton);
// Should not call resetPassword with empty string
expect(resetPassword).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,241 @@
import { render, screen, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { I18nextProvider } from 'react-i18next';
import i18n from 'i18next';
import LoggingOutPage from '../LoggingOutPage';
import { authApiService } from '@/api/auth/auth.api.service';
import CacheCleanup from '@/utils/cache-cleanup';
// Mock dependencies
const mockAuthService = {
signOut: vi.fn(),
};
vi.mock('@/hooks/useAuth', () => ({
useAuthService: () => mockAuthService,
}));
vi.mock('@/api/auth/auth.api.service', () => ({
authApiService: {
logout: vi.fn(),
},
}));
vi.mock('@/utils/cache-cleanup', () => ({
default: {
clearAllCaches: vi.fn(),
forceReload: vi.fn(),
},
}));
vi.mock('react-responsive', () => ({
useMediaQuery: () => false,
}));
// Mock navigation
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Setup i18n for testing
i18n.init({
lng: 'en',
resources: {
en: {
'auth/auth-common': {
loggingOut: 'Logging Out...',
},
},
},
});
const renderWithProviders = (component: React.ReactElement) => {
return render(
<BrowserRouter>
<I18nextProvider i18n={i18n}>
{component}
</I18nextProvider>
</BrowserRouter>
);
};
describe('LoggingOutPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// Mock console.error to avoid noise in tests
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mock('@/hooks/useAuth', () => ({
useAuthService: () => mockAuthService,
}));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('renders loading state correctly', () => {
renderWithProviders(<LoggingOutPage />);
expect(screen.getByText('Logging Out...')).toBeInTheDocument();
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
});
it('performs complete logout sequence successfully', async () => {
mockAuthService.signOut.mockResolvedValue(undefined);
(authApiService.logout as any).mockResolvedValue(undefined);
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
renderWithProviders(<LoggingOutPage />);
await waitFor(() => {
expect(mockAuthService.signOut).toHaveBeenCalled();
});
await waitFor(() => {
expect(authApiService.logout).toHaveBeenCalled();
});
await waitFor(() => {
expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
});
// Fast-forward time to trigger the setTimeout
vi.advanceTimersByTime(1000);
await waitFor(() => {
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
});
});
it('handles auth service signOut failure', async () => {
mockAuthService.signOut.mockRejectedValue(new Error('SignOut failed'));
(authApiService.logout as any).mockResolvedValue(undefined);
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
renderWithProviders(<LoggingOutPage />);
await waitFor(() => {
expect(mockAuthService.signOut).toHaveBeenCalled();
});
await waitFor(() => {
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
});
});
it('handles backend logout failure', async () => {
mockAuthService.signOut.mockResolvedValue(undefined);
(authApiService.logout as any).mockRejectedValue(new Error('Backend logout failed'));
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
renderWithProviders(<LoggingOutPage />);
await waitFor(() => {
expect(mockAuthService.signOut).toHaveBeenCalled();
});
await waitFor(() => {
expect(authApiService.logout).toHaveBeenCalled();
});
await waitFor(() => {
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
});
});
it('handles cache cleanup failure', async () => {
mockAuthService.signOut.mockResolvedValue(undefined);
(authApiService.logout as any).mockResolvedValue(undefined);
(CacheCleanup.clearAllCaches as any).mockRejectedValue(new Error('Cache cleanup failed'));
renderWithProviders(<LoggingOutPage />);
await waitFor(() => {
expect(mockAuthService.signOut).toHaveBeenCalled();
});
await waitFor(() => {
expect(authApiService.logout).toHaveBeenCalled();
});
await waitFor(() => {
expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
});
await waitFor(() => {
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
});
});
it('triggers logout sequence immediately on mount', () => {
renderWithProviders(<LoggingOutPage />);
expect(mockAuthService.signOut).toHaveBeenCalled();
});
it('shows consistent loading UI throughout logout process', async () => {
mockAuthService.signOut.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
(authApiService.logout as any).mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
(CacheCleanup.clearAllCaches as any).mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
renderWithProviders(<LoggingOutPage />);
// Should show loading state immediately
expect(screen.getByText('Logging Out...')).toBeInTheDocument();
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
// Should continue showing loading state during the process
vi.advanceTimersByTime(50);
expect(screen.getByText('Logging Out...')).toBeInTheDocument();
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
});
it('calls forceReload with correct path after timeout', async () => {
mockAuthService.signOut.mockResolvedValue(undefined);
(authApiService.logout as any).mockResolvedValue(undefined);
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
renderWithProviders(<LoggingOutPage />);
// Wait for all async operations to complete
await waitFor(() => {
expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
});
// Fast-forward exactly 1000ms
vi.advanceTimersByTime(1000);
await waitFor(() => {
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
expect(CacheCleanup.forceReload).toHaveBeenCalledTimes(1);
});
});
it('handles complete failure of all logout steps', async () => {
mockAuthService.signOut.mockRejectedValue(new Error('SignOut failed'));
(authApiService.logout as any).mockRejectedValue(new Error('Backend logout failed'));
(CacheCleanup.clearAllCaches as any).mockRejectedValue(new Error('Cache cleanup failed'));
renderWithProviders(<LoggingOutPage />);
await waitFor(() => {
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
});
});
});

View File

@@ -0,0 +1,317 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { I18nextProvider } from 'react-i18next';
import i18n from 'i18next';
import LoginPage from '../LoginPage';
import { login, verifyAuthentication } from '@/features/auth/authSlice';
// Mock dependencies
vi.mock('@/features/auth/authSlice', () => ({
login: vi.fn(),
verifyAuthentication: vi.fn(),
}));
vi.mock('@/features/user/userSlice', () => ({
setUser: vi.fn(),
}));
vi.mock('@/utils/session-helper', () => ({
setSession: vi.fn(),
}));
vi.mock('@/utils/errorLogger', () => ({
default: {
error: vi.fn(),
},
}));
vi.mock('@/hooks/useMixpanelTracking', () => ({
useMixpanelTracking: () => ({
trackMixpanelEvent: vi.fn(),
}),
}));
vi.mock('@/hooks/useDoumentTItle', () => ({
useDocumentTitle: vi.fn(),
}));
vi.mock('@/hooks/useAuth', () => ({
useAuthService: () => ({
getCurrentSession: () => null,
}),
}));
vi.mock('@/services/alerts/alertService', () => ({
default: {
error: vi.fn(),
},
}));
vi.mock('react-responsive', () => ({
useMediaQuery: () => false,
}));
// Mock navigation
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Mock dispatch
const mockDispatch = vi.fn();
vi.mock('@/hooks/useAppDispatch', () => ({
useAppDispatch: () => mockDispatch,
}));
// Setup i18n for testing
i18n.init({
lng: 'en',
resources: {
en: {
'auth/login': {
headerDescription: 'Sign in to your account',
emailRequired: 'Please input your email!',
passwordRequired: 'Please input your password!',
emailPlaceholder: 'Email',
passwordPlaceholder: 'Password',
loginButton: 'Sign In',
rememberMe: 'Remember me',
forgotPasswordButton: 'Forgot password?',
signInWithGoogleButton: 'Sign in with Google',
orText: 'or',
dontHaveAccountText: "Don't have an account?",
signupButton: 'Sign up',
successMessage: 'Login successful!',
'validationMessages.email': 'Please enter a valid email!',
'validationMessages.password': 'Password must be at least 8 characters!',
'errorMessages.loginErrorTitle': 'Login Failed',
'errorMessages.loginErrorMessage': 'Invalid email or password',
},
},
},
});
// Create test store
const createTestStore = (initialState: any = {}) => {
return configureStore({
reducer: {
auth: (state = { isLoading: false, ...initialState.auth }) => state,
user: (state = {}) => state,
},
});
};
const renderWithProviders = (component: React.ReactElement, initialState: any = {}) => {
const store = createTestStore(initialState);
return render(
<Provider store={store}>
<BrowserRouter>
<I18nextProvider i18n={i18n}>
{component}
</I18nextProvider>
</BrowserRouter>
</Provider>
);
};
describe('LoginPage', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock environment variables
vi.stubEnv('VITE_ENABLE_GOOGLE_LOGIN', 'true');
vi.stubEnv('VITE_API_URL', 'http://localhost:3000');
});
it('renders login form correctly', () => {
renderWithProviders(<LoginPage />);
expect(screen.getByText('Sign in to your account')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument();
expect(screen.getByText('Remember me')).toBeInTheDocument();
expect(screen.getByText('Forgot password?')).toBeInTheDocument();
});
it('shows Google login button when enabled', () => {
renderWithProviders(<LoginPage />);
expect(screen.getByText('Sign in with Google')).toBeInTheDocument();
});
it('validates required fields', async () => {
const user = userEvent.setup();
renderWithProviders(<LoginPage />);
const submitButton = screen.getByRole('button', { name: 'Sign In' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
expect(screen.getByText('Please input your password!')).toBeInTheDocument();
});
});
it('validates email format', async () => {
const user = userEvent.setup();
renderWithProviders(<LoginPage />);
const emailInput = screen.getByPlaceholderText('Email');
await user.type(emailInput, 'invalid-email');
const submitButton = screen.getByRole('button', { name: 'Sign In' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Please enter a valid email!')).toBeInTheDocument();
});
});
it('validates password minimum length', async () => {
const user = userEvent.setup();
renderWithProviders(<LoginPage />);
const emailInput = screen.getByPlaceholderText('Email');
const passwordInput = screen.getByPlaceholderText('Password');
await user.type(emailInput, 'test@example.com');
await user.type(passwordInput, '123');
const submitButton = screen.getByRole('button', { name: 'Sign In' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Password must be at least 8 characters!')).toBeInTheDocument();
});
});
it('submits form with valid credentials', async () => {
const user = userEvent.setup();
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockResolvedValue({
authenticated: true,
user: { id: '1', email: 'test@example.com' },
}),
});
renderWithProviders(<LoginPage />);
const emailInput = screen.getByPlaceholderText('Email');
const passwordInput = screen.getByPlaceholderText('Password');
await user.type(emailInput, 'test@example.com');
await user.type(passwordInput, 'password123');
const submitButton = screen.getByRole('button', { name: 'Sign In' });
await user.click(submitButton);
await waitFor(() => {
expect(login).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
remember: true,
});
});
});
it('shows loading state during login', async () => {
const user = userEvent.setup();
renderWithProviders(<LoginPage />, { auth: { isLoading: true } });
const submitButton = screen.getByRole('button', { name: 'Sign In' });
expect(submitButton).toBeDisabled();
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
});
it('handles Google login click', async () => {
const user = userEvent.setup();
renderWithProviders(<LoginPage />);
// Mock window.location
Object.defineProperty(window, 'location', {
value: { href: '' },
writable: true,
});
const googleButton = screen.getByText('Sign in with Google');
await user.click(googleButton);
expect(window.location.href).toBe('http://localhost:3000/secure/google');
});
it('navigates to signup page', async () => {
const user = userEvent.setup();
renderWithProviders(<LoginPage />);
const signupLink = screen.getByText('Sign up');
await user.click(signupLink);
// Link navigation is handled by React Router, so we just check the element exists
expect(signupLink.closest('a')).toHaveAttribute('href', '/auth/signup');
});
it('navigates to forgot password page', async () => {
const user = userEvent.setup();
renderWithProviders(<LoginPage />);
const forgotPasswordLink = screen.getByText('Forgot password?');
await user.click(forgotPasswordLink);
expect(forgotPasswordLink.closest('a')).toHaveAttribute('href', '/auth/forgot-password');
});
it('toggles remember me checkbox', async () => {
const user = userEvent.setup();
renderWithProviders(<LoginPage />);
const rememberMeCheckbox = screen.getByRole('checkbox', { name: 'Remember me' });
expect(rememberMeCheckbox).toBeChecked(); // Default is true
await user.click(rememberMeCheckbox);
expect(rememberMeCheckbox).not.toBeChecked();
});
it('redirects already authenticated users', async () => {
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockResolvedValue({
authenticated: true,
user: { id: '1', email: 'test@example.com', setup_completed: true },
}),
});
renderWithProviders(<LoginPage />);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
});
});
it('redirects to setup for users with incomplete setup', async () => {
const mockCurrentSession = {
id: '1',
email: 'test@example.com',
setup_completed: false,
};
vi.mock('@/hooks/useAuth', () => ({
useAuthService: () => ({
getCurrentSession: () => mockCurrentSession,
}),
}));
renderWithProviders(<LoginPage />);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/setup');
});
});
});

View File

@@ -0,0 +1,359 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { I18nextProvider } from 'react-i18next';
import i18n from 'i18next';
import SignupPage from '../SignupPage';
import { signUp } from '@/features/auth/authSlice';
import { authApiService } from '@/api/auth/auth.api.service';
// Mock dependencies
vi.mock('@/features/auth/authSlice', () => ({
signUp: vi.fn(),
}));
vi.mock('@/api/auth/auth.api.service', () => ({
authApiService: {
signUpCheck: vi.fn(),
verifyRecaptchaToken: vi.fn(),
},
}));
vi.mock('@/hooks/useMixpanelTracking', () => ({
useMixpanelTracking: () => ({
trackMixpanelEvent: vi.fn(),
}),
}));
vi.mock('@/hooks/useDoumentTItle', () => ({
useDocumentTitle: vi.fn(),
}));
vi.mock('@/utils/errorLogger', () => ({
default: {
error: vi.fn(),
},
}));
vi.mock('@/services/alerts/alertService', () => ({
default: {
error: vi.fn(),
},
}));
vi.mock('react-responsive', () => ({
useMediaQuery: () => false,
}));
// Mock navigation
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Mock dispatch
const mockDispatch = vi.fn();
vi.mock('@/hooks/useAppDispatch', () => ({
useAppDispatch: () => mockDispatch,
}));
// Setup i18n for testing
i18n.init({
lng: 'en',
resources: {
en: {
'auth/signup': {
headerDescription: 'Sign up to get started',
nameLabel: 'Full Name',
emailLabel: 'Email',
passwordLabel: 'Password',
nameRequired: 'Please input your name!',
emailRequired: 'Please input your email!',
passwordRequired: 'Please input your password!',
nameMinCharacterRequired: 'Name must be at least 4 characters!',
passwordMinCharacterRequired: 'Password must be at least 8 characters!',
passwordMaxCharacterRequired: 'Password must be no more than 32 characters!',
passwordPatternRequired: 'Password must contain uppercase, lowercase, number and special character!',
namePlaceholder: 'Enter your full name',
emailPlaceholder: 'Enter your email',
strongPasswordPlaceholder: 'Enter a strong password',
passwordGuideline: 'Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.',
signupButton: 'Sign Up',
signInWithGoogleButton: 'Sign up with Google',
orText: 'or',
alreadyHaveAccountText: 'Already have an account?',
loginButton: 'Log in',
bySigningUpText: 'By signing up, you agree to our',
privacyPolicyLink: 'Privacy Policy',
andText: 'and',
termsOfUseLink: 'Terms of Use',
reCAPTCHAVerificationError: 'reCAPTCHA Verification Failed',
reCAPTCHAVerificationErrorMessage: 'Please try again',
'passwordChecklist.minLength': 'At least 8 characters',
'passwordChecklist.uppercase': 'One uppercase letter',
'passwordChecklist.lowercase': 'One lowercase letter',
'passwordChecklist.number': 'One number',
'passwordChecklist.special': 'One special character',
},
},
},
});
// Create test store
const createTestStore = () => {
return configureStore({
reducer: {
themeReducer: (state = { mode: 'light' }) => state,
},
});
};
const renderWithProviders = (component: React.ReactElement) => {
const store = createTestStore();
return render(
<Provider store={store}>
<BrowserRouter>
<I18nextProvider i18n={i18n}>
{component}
</I18nextProvider>
</BrowserRouter>
</Provider>
);
};
describe('SignupPage', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
// Mock environment variables
vi.stubEnv('VITE_ENABLE_GOOGLE_LOGIN', 'true');
vi.stubEnv('VITE_ENABLE_RECAPTCHA', 'false');
vi.stubEnv('VITE_API_URL', 'http://localhost:3000');
// Mock URL search params
Object.defineProperty(window, 'location', {
value: { search: '' },
writable: true,
});
});
it('renders signup form correctly', () => {
renderWithProviders(<SignupPage />);
expect(screen.getByText('Sign up to get started')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter your full name')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter a strong password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sign Up' })).toBeInTheDocument();
});
it('shows Google signup button when enabled', () => {
renderWithProviders(<SignupPage />);
expect(screen.getByText('Sign up with Google')).toBeInTheDocument();
});
it('validates required fields', async () => {
const user = userEvent.setup();
renderWithProviders(<SignupPage />);
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Please input your name!')).toBeInTheDocument();
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
expect(screen.getByText('Please input your password!')).toBeInTheDocument();
});
});
it('validates name minimum length', async () => {
const user = userEvent.setup();
renderWithProviders(<SignupPage />);
const nameInput = screen.getByPlaceholderText('Enter your full name');
await user.type(nameInput, 'Jo');
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Name must be at least 4 characters!')).toBeInTheDocument();
});
});
it('validates email format', async () => {
const user = userEvent.setup();
renderWithProviders(<SignupPage />);
const emailInput = screen.getByPlaceholderText('Enter your email');
await user.type(emailInput, 'invalid-email');
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
});
});
it('validates password requirements', async () => {
const user = userEvent.setup();
renderWithProviders(<SignupPage />);
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
await user.type(passwordInput, 'weak');
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Password must be at least 8 characters!')).toBeInTheDocument();
});
});
it('shows password checklist when password field is focused', async () => {
const user = userEvent.setup();
renderWithProviders(<SignupPage />);
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
await user.click(passwordInput);
await waitFor(() => {
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
expect(screen.getByText('One number')).toBeInTheDocument();
expect(screen.getByText('One special character')).toBeInTheDocument();
});
});
it('updates password checklist based on input', async () => {
const user = userEvent.setup();
renderWithProviders(<SignupPage />);
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
await user.click(passwordInput);
await user.type(passwordInput, 'Password123!');
await waitFor(() => {
// All checklist items should be visible and some should be checked
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
expect(screen.getByText('One number')).toBeInTheDocument();
expect(screen.getByText('One special character')).toBeInTheDocument();
});
});
it('submits form with valid data', async () => {
const user = userEvent.setup();
(authApiService.signUpCheck as any).mockResolvedValue({ done: true });
renderWithProviders(<SignupPage />);
const nameInput = screen.getByPlaceholderText('Enter your full name');
const emailInput = screen.getByPlaceholderText('Enter your email');
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
await user.type(nameInput, 'John Doe');
await user.type(emailInput, 'john@example.com');
await user.type(passwordInput, 'Password123!');
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
await user.click(submitButton);
await waitFor(() => {
expect(authApiService.signUpCheck).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
password: 'Password123!',
});
});
});
it('handles Google signup click', async () => {
const user = userEvent.setup();
renderWithProviders(<SignupPage />);
// Mock window.location
Object.defineProperty(window, 'location', {
value: { href: '' },
writable: true,
});
const googleButton = screen.getByText('Sign up with Google');
await user.click(googleButton);
expect(window.location.href).toBe('http://localhost:3000/secure/google?');
});
it('pre-fills form from URL parameters', () => {
// Mock URLSearchParams
Object.defineProperty(window, 'location', {
value: { search: '?email=test@example.com&name=Test User' },
writable: true,
});
renderWithProviders(<SignupPage />);
const nameInput = screen.getByPlaceholderText('Enter your full name') as HTMLInputElement;
const emailInput = screen.getByPlaceholderText('Enter your email') as HTMLInputElement;
expect(nameInput.value).toBe('Test User');
expect(emailInput.value).toBe('test@example.com');
});
it('shows terms of use and privacy policy links', () => {
renderWithProviders(<SignupPage />);
expect(screen.getByText('Privacy Policy')).toBeInTheDocument();
expect(screen.getByText('Terms of Use')).toBeInTheDocument();
const privacyLink = screen.getByText('Privacy Policy').closest('a');
const termsLink = screen.getByText('Terms of Use').closest('a');
expect(privacyLink).toHaveAttribute('href', 'https://worklenz.com/privacy/');
expect(termsLink).toHaveAttribute('href', 'https://worklenz.com/terms/');
});
it('navigates to login page', async () => {
const user = userEvent.setup();
renderWithProviders(<SignupPage />);
const loginLink = screen.getByText('Log in');
await user.click(loginLink);
expect(loginLink.closest('a')).toHaveAttribute('href', '/auth/login');
});
it('shows loading state during signup', async () => {
const user = userEvent.setup();
(authApiService.signUpCheck as any).mockResolvedValue({ done: true });
renderWithProviders(<SignupPage />);
const nameInput = screen.getByPlaceholderText('Enter your full name');
const emailInput = screen.getByPlaceholderText('Enter your email');
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
await user.type(nameInput, 'John Doe');
await user.type(emailInput, 'john@example.com');
await user.type(passwordInput, 'Password123!');
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,337 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { I18nextProvider } from 'react-i18next';
import i18n from 'i18next';
import VerifyResetEmailPage from '../VerifyResetEmailPage';
import { updatePassword } from '@/features/auth/authSlice';
// Mock dependencies
vi.mock('@/features/auth/authSlice', () => ({
updatePassword: vi.fn(),
}));
vi.mock('@/utils/errorLogger', () => ({
default: {
error: vi.fn(),
},
}));
vi.mock('@/hooks/useMixpanelTracking', () => ({
useMixpanelTracking: () => ({
trackMixpanelEvent: vi.fn(),
}),
}));
vi.mock('@/hooks/useDoumentTItle', () => ({
useDocumentTitle: vi.fn(),
}));
vi.mock('react-responsive', () => ({
useMediaQuery: () => false,
}));
// Mock navigation
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Mock dispatch
const mockDispatch = vi.fn();
vi.mock('@/hooks/useAppDispatch', () => ({
useAppDispatch: () => mockDispatch,
}));
// Setup i18n for testing
i18n.init({
lng: 'en',
resources: {
en: {
'auth/verify-reset-email': {
description: 'Enter your new password',
passwordRequired: 'Please input your password!',
confirmPasswordRequired: 'Please confirm your password!',
placeholder: 'Enter new password',
confirmPasswordPlaceholder: 'Confirm new password',
resetPasswordButton: 'Reset Password',
resendResetEmail: 'Resend Reset Email',
orText: 'or',
passwordMismatch: 'The two passwords do not match!',
successTitle: 'Password Reset Successful',
successMessage: 'Your password has been reset successfully.',
'passwordChecklist.minLength': 'At least 8 characters',
'passwordChecklist.uppercase': 'One uppercase letter',
'passwordChecklist.lowercase': 'One lowercase letter',
'passwordChecklist.number': 'One number',
'passwordChecklist.special': 'One special character',
},
},
},
});
// Create test store
const createTestStore = () => {
return configureStore({
reducer: {
themeReducer: (state = { mode: 'light' }) => state,
},
});
};
const renderWithProviders = (component: React.ReactElement, route = '/verify-reset/test-hash/test-user') => {
const store = createTestStore();
return render(
<Provider store={store}>
<MemoryRouter initialEntries={[route]}>
<I18nextProvider i18n={i18n}>
{component}
</I18nextProvider>
</MemoryRouter>
</Provider>
);
};
describe('VerifyResetEmailPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('renders password reset form correctly', () => {
renderWithProviders(<VerifyResetEmailPage />);
expect(screen.getByText('Enter your new password')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter new password')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Confirm new password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reset Password' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Resend Reset Email' })).toBeInTheDocument();
});
it('shows password checklist immediately', () => {
renderWithProviders(<VerifyResetEmailPage />);
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
expect(screen.getByText('One number')).toBeInTheDocument();
expect(screen.getByText('One special character')).toBeInTheDocument();
});
it('validates required password fields', async () => {
const user = userEvent.setup();
renderWithProviders(<VerifyResetEmailPage />);
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Please input your password!')).toBeInTheDocument();
expect(screen.getByText('Please confirm your password!')).toBeInTheDocument();
});
});
it('validates password confirmation match', async () => {
const user = userEvent.setup();
renderWithProviders(<VerifyResetEmailPage />);
const passwordInput = screen.getByPlaceholderText('Enter new password');
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
await user.type(passwordInput, 'Password123!');
await user.type(confirmPasswordInput, 'DifferentPassword123!');
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('The two passwords do not match!')).toBeInTheDocument();
});
});
it('updates password checklist based on input', async () => {
const user = userEvent.setup();
renderWithProviders(<VerifyResetEmailPage />);
const passwordInput = screen.getByPlaceholderText('Enter new password');
await user.type(passwordInput, 'Password123!');
// All checklist items should be visible (this component shows them by default)
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
expect(screen.getByText('One number')).toBeInTheDocument();
expect(screen.getByText('One special character')).toBeInTheDocument();
});
it('submits form with valid matching passwords', async () => {
const user = userEvent.setup();
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockResolvedValue({ done: true }),
});
renderWithProviders(<VerifyResetEmailPage />);
const passwordInput = screen.getByPlaceholderText('Enter new password');
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
await user.type(passwordInput, 'Password123!');
await user.type(confirmPasswordInput, 'Password123!');
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
await user.click(submitButton);
await waitFor(() => {
expect(updatePassword).toHaveBeenCalledWith({
hash: 'test-hash',
user: 'test-user',
password: 'Password123!',
confirmPassword: 'Password123!',
});
});
});
it('shows success message after successful password reset', async () => {
const user = userEvent.setup();
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockResolvedValue({ done: true }),
});
renderWithProviders(<VerifyResetEmailPage />);
const passwordInput = screen.getByPlaceholderText('Enter new password');
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
await user.type(passwordInput, 'Password123!');
await user.type(confirmPasswordInput, 'Password123!');
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Password Reset Successful')).toBeInTheDocument();
expect(screen.getByText('Your password has been reset successfully.')).toBeInTheDocument();
});
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
});
});
it('shows loading state during submission', async () => {
const user = userEvent.setup();
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
});
renderWithProviders(<VerifyResetEmailPage />);
const passwordInput = screen.getByPlaceholderText('Enter new password');
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
await user.type(passwordInput, 'Password123!');
await user.type(confirmPasswordInput, 'Password123!');
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
});
});
it('handles submission errors gracefully', async () => {
const user = userEvent.setup();
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockRejectedValue(new Error('Reset failed')),
});
renderWithProviders(<VerifyResetEmailPage />);
const passwordInput = screen.getByPlaceholderText('Enter new password');
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
await user.type(passwordInput, 'Password123!');
await user.type(confirmPasswordInput, 'Password123!');
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
await user.click(submitButton);
await waitFor(() => {
// Should not show success message
expect(screen.queryByText('Password Reset Successful')).not.toBeInTheDocument();
// Should still show the form
expect(screen.getByPlaceholderText('Enter new password')).toBeInTheDocument();
});
});
it('navigates to forgot password page when resend button is clicked', async () => {
const user = userEvent.setup();
renderWithProviders(<VerifyResetEmailPage />);
const resendButton = screen.getByRole('button', { name: 'Resend Reset Email' });
await user.click(resendButton);
expect(resendButton.closest('a')).toHaveAttribute('href', '/auth/forgot-password');
});
it('prevents pasting in confirm password field', async () => {
const user = userEvent.setup();
renderWithProviders(<VerifyResetEmailPage />);
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
await user.click(confirmPasswordInput);
// Try to paste - should be prevented
const pasteEvent = new ClipboardEvent('paste', {
clipboardData: new DataTransfer(),
});
fireEvent(confirmPasswordInput, pasteEvent);
// The preventDefault should be called (we can't easily test this directly,
// but we can ensure the input behavior remains consistent)
expect(confirmPasswordInput).toBeInTheDocument();
});
it('does not submit with empty passwords after trimming', async () => {
const user = userEvent.setup();
mockDispatch.mockReturnValue({
unwrap: vi.fn().mockResolvedValue({ done: true }),
});
renderWithProviders(<VerifyResetEmailPage />);
const passwordInput = screen.getByPlaceholderText('Enter new password');
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
await user.type(passwordInput, ' '); // Only whitespace
await user.type(confirmPasswordInput, ' '); // Only whitespace
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
await user.click(submitButton);
// Should not call updatePassword with empty strings
expect(updatePassword).not.toHaveBeenCalled();
});
it('extracts hash and user from URL params', () => {
renderWithProviders(<VerifyResetEmailPage />, '/verify-reset/my-hash/my-user');
// Component should render normally, indicating it received the params
expect(screen.getByText('Enter your new password')).toBeInTheDocument();
});
});

View File

@@ -1,14 +1,16 @@
import { useEffect, memo, useMemo, useCallback } from 'react';
import React, { useEffect, memo, useMemo, useCallback } from 'react';
import { useMediaQuery } from 'react-responsive';
import Col from 'antd/es/col';
import Flex from 'antd/es/flex';
import Row from 'antd/es/row';
import Card from 'antd/es/card';
import GreetingWithTime from './greeting-with-time';
import TasksList from '@/pages/home/task-list/tasks-list';
import TodoList from '@/pages/home/todo-list/todo-list';
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
import RecentAndFavouriteProjectList from '@/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list';
import TodoList from './todo-list/todo-list';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import { useAppDispatch } from '@/hooks/useAppDispatch';
@@ -19,7 +21,7 @@ import { fetchProjectCategories } from '@/features/projects/lookups/projectCateg
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
import { fetchProjects } from '@/features/home-page/home-page.slice';
import { createPortal } from 'react-dom';
import React, { Suspense } from 'react';
import UserActivityFeed from './user-activity-feed/user-activity-feed';
const DESKTOP_MIN_WIDTH = 1024;
const TASK_LIST_MIN_WIDTH = 500;
@@ -27,7 +29,9 @@ const SIDEBAR_MAX_WIDTH = 400;
// Lazy load heavy components
const TaskDrawer = React.lazy(() => import('@/components/task-drawer/task-drawer'));
const SurveyPromptModal = React.lazy(() => import('@/components/survey/SurveyPromptModal').then(m => ({ default: m.SurveyPromptModal })));
const SurveyPromptModal = React.lazy(() =>
import('@/components/survey/SurveyPromptModal').then(m => ({ default: m.SurveyPromptModal }))
);
const HomePage = memo(() => {
const dispatch = useAppDispatch();
@@ -45,7 +49,7 @@ const HomePage = memo(() => {
console.warn('Failed to preload TaskDrawer:', error);
}
};
preloadTaskDrawer();
}, []);
@@ -98,56 +102,33 @@ const HomePage = memo(() => {
);
}, [isDesktop, isOwnerOrAdmin]);
const MainContent = useMemo(() => {
return isDesktop ? (
<Flex gap={24} align="flex-start" className="w-full mt-12">
<Flex style={desktopFlexStyle}>
<TasksList />
</Flex>
<Flex vertical gap={24} style={sidebarFlexStyle}>
<TodoList />
<RecentAndFavouriteProjectList />
</Flex>
</Flex>
) : (
<Flex vertical gap={24} className="mt-6">
<TasksList />
<TodoList />
<RecentAndFavouriteProjectList />
</Flex>
);
}, [isDesktop, desktopFlexStyle, sidebarFlexStyle]);
return (
<div className="my-24 min-h-[90vh]">
<div className="my-8 min-h-[90vh]">
<Col className="flex flex-col gap-6">
<GreetingWithTime />
{CreateProjectButtonComponent}
</Col>
{MainContent}
<Row gutter={[24, 24]} className="mt-12">
<Col xs={24} lg={16}>
<Flex vertical gap={24}>
<TasksList />
</Flex>
</Col>
{/* Use Suspense for lazy-loaded components with error boundary */}
<Suspense fallback={<div>Loading...</div>}>
{createPortal(
<React.Suspense fallback={null}>
<TaskDrawer />
</React.Suspense>,
document.body,
'home-task-drawer'
)}
</Suspense>
<Col xs={24} lg={8}>
<Flex vertical gap={24}>
<TodoList />
<UserActivityFeed />
{createPortal(
<ProjectDrawer onClose={handleProjectDrawerClose} />,
document.body,
'project-drawer'
)}
<RecentAndFavouriteProjectList />
</Flex>
</Col>
</Row>
{/* Survey Modal - only shown to users who haven't completed it */}
<Suspense fallback={null}>
<SurveyPromptModal />
</Suspense>
{createPortal(<TaskDrawer />, document.body, 'home-task-drawer')}
{createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
</div>
);
});

View File

@@ -1,9 +1,10 @@
import { CheckCircleOutlined, SyncOutlined } from '@/shared/antd-imports';
import { CheckCircleOutlined, SyncOutlined, DownOutlined, RightOutlined } from '@/shared/antd-imports';
import { useRef, useState } from 'react';
import Form from 'antd/es/form';
import Input, { InputRef } from 'antd/es/input';
import Flex from 'antd/es/flex';
import Card from 'antd/es/card';
import Collapse from 'antd/es/collapse';
import ConfigProvider from 'antd/es/config-provider';
import Table, { TableProps } from 'antd/es/table';
import Tooltip from 'antd/es/tooltip';
@@ -23,6 +24,7 @@ import { useCreatePersonalTaskMutation } from '@/api/home-page/home-page.api.ser
const TodoList = () => {
const [isAlertShowing, setIsAlertShowing] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true);
const [form] = Form.useForm();
const { t } = useTranslation('home');
@@ -97,73 +99,109 @@ const TodoList = () => {
];
return (
<Card
title={
<Typography.Title level={5} style={{ marginBlockEnd: 0 }}>
{t('home:todoList.title')} ({data?.body.length})
</Typography.Title>
}
extra={
<Tooltip title={t('home:todoList.refreshTasks')}>
<Button shape="circle" icon={<SyncOutlined spin={isFetching} />} onClick={refetch} />
</Tooltip>
}
style={{ width: '100%' }}
>
<div>
<Form form={form} onFinish={handleTodoSubmit}>
<Form.Item name="name">
<Flex vertical>
<Input
ref={todoInputRef}
placeholder={t('home:todoList.addTask')}
onChange={e => {
const inputValue = e.currentTarget.value;
<Card style={{ width: '100%' }} bodyStyle={{ padding: 0 }}>
<style>{`
.todo-collapse .ant-collapse-header {
display: flex !important;
align-items: center !important;
padding: 12px 16px !important;
}
.todo-collapse .ant-collapse-expand-icon {
margin-right: 8px !important;
display: flex !important;
align-items: center !important;
}
`}</style>
<Collapse
defaultActiveKey={[]}
ghost
size="small"
className="todo-collapse"
expandIcon={({ isActive }) =>
isActive ? <DownOutlined /> : <RightOutlined />
}
onChange={(keys) => {
setIsCollapsed(keys.length === 0);
}}
items={[
{
key: '1',
label: (
<Flex style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<Typography.Title level={5} style={{ margin: 0 }}>
{t('home:todoList.title')} ({data?.body.length})
</Typography.Title>
<Tooltip title={t('home:todoList.refreshTasks')}>
<Button
shape="circle"
icon={<SyncOutlined spin={isFetching} />}
onClick={(e) => {
e.stopPropagation();
refetch();
}}
/>
</Tooltip>
</Flex>
),
children: (
<div style={{ padding: '0 16px 16px 16px' }}>
<Form form={form} onFinish={handleTodoSubmit}>
<Form.Item name="name">
<Flex vertical>
<Input
ref={todoInputRef}
placeholder={t('home:todoList.addTask')}
onChange={e => {
const inputValue = e.currentTarget.value;
if (inputValue.length >= 1) setIsAlertShowing(true);
else if (inputValue === '') setIsAlertShowing(false);
}}
/>
{isAlertShowing && (
<Alert
message={
<Typography.Text style={{ fontSize: 11 }}>
{t('home:todoList.pressEnter')} <strong>Enter</strong>{' '}
{t('home:todoList.toCreate')}
</Typography.Text>
}
type="info"
style={{
width: 'fit-content',
borderRadius: 2,
padding: '0 6px',
}}
/>
)}
</Flex>
</Form.Item>
</Form>
if (inputValue.length >= 1) setIsAlertShowing(true);
else if (inputValue === '') setIsAlertShowing(false);
}}
/>
{isAlertShowing && (
<Alert
message={
<Typography.Text style={{ fontSize: 11 }}>
{t('home:todoList.pressEnter')} <strong>Enter</strong>{' '}
{t('home:todoList.toCreate')}
</Typography.Text>
}
type="info"
style={{
width: 'fit-content',
borderRadius: 2,
padding: '0 6px',
}}
/>
)}
</Flex>
</Form.Item>
</Form>
<div style={{ maxHeight: 420, overflow: 'auto' }}>
{data?.body.length === 0 ? (
<EmptyListPlaceholder
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
text={t('home:todoList.noTasks')}
/>
) : (
<Table
className="custom-two-colors-row-table"
rowKey={record => record.id || ''}
dataSource={data?.body}
columns={columns}
showHeader={false}
pagination={false}
size="small"
loading={isFetching}
/>
)}
</div>
</div>
<div style={{ maxHeight: 300, overflow: 'auto' }}>
{data?.body.length === 0 ? (
<EmptyListPlaceholder
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
text={t('home:todoList.noTasks')}
/>
) : (
<Table
className="custom-two-colors-row-table"
rowKey={record => record.id || ''}
dataSource={data?.body}
columns={columns}
showHeader={false}
pagination={false}
size="small"
loading={isFetching}
/>
)}
</div>
</div>
),
},
]}
/>
</Card>
);
};

View File

@@ -0,0 +1,95 @@
import React, { useCallback } from 'react';
import { Table, Typography, Tooltip, theme } from 'antd';
import { FileTextOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fromNow, formatDate } from '@/utils/dateUtils';
import {
setSelectedTaskId,
setShowTaskDrawer,
fetchTask,
} from '@/features/task-drawer/task-drawer.slice';
import { IUserRecentTask } from '@/types/home/user-activity.types';
const { Text } = Typography;
interface TaskActivityListProps {
tasks: IUserRecentTask[];
}
const TaskActivityList: React.FC<TaskActivityListProps> = React.memo(({ tasks }) => {
const { t } = useTranslation('home');
const dispatch = useAppDispatch();
const { token } = theme.useToken();
const handleTaskClick = useCallback(
(taskId: string, projectId: string) => {
dispatch(setSelectedTaskId(taskId));
dispatch(setShowTaskDrawer(true));
dispatch(fetchTask({ taskId, projectId }));
},
[dispatch]
);
const columns = [
{
key: 'task',
render: (record: IUserRecentTask) => (
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 12,
width: '100%',
cursor: 'pointer',
padding: '8px 0'
}}
onClick={() => handleTaskClick(record.task_id, record.project_id)}
aria-label={`${t('tasks.recentTaskAriaLabel')} ${record.task_name}`}
>
<div style={{
marginTop: 2,
color: token.colorPrimary,
fontSize: 16
}}>
<FileTextOutlined />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ marginBottom: 4 }}>
<Text strong style={{ fontSize: 14 }}>
{record.task_name}
</Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{record.project_name}
</Text>
<Tooltip
title={formatDate(record.last_activity_at, 'MMMM Do YYYY, h:mm:ss a')}
placement="topRight"
>
<Text type="secondary" style={{ fontSize: 12 }}>
{fromNow(record.last_activity_at)}
</Text>
</Tooltip>
</div>
</div>
</div>
),
},
];
return (
<Table
className="custom-two-colors-row-table"
dataSource={tasks}
columns={columns}
rowKey="task_id"
showHeader={false}
pagination={false}
size="small"
/>
);
});
export default TaskActivityList;

View File

@@ -0,0 +1,166 @@
import React, { useCallback } from 'react';
import { Table, Typography, Tag, Tooltip, Space, theme } from '@/shared/antd-imports';
import { ClockCircleOutlined } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fromNow, formatDate } from '@/utils/dateUtils';
import {
setSelectedTaskId,
setShowTaskDrawer,
fetchTask,
} from '@/features/task-drawer/task-drawer.slice';
import { IUserTimeLoggedTask } from '@/types/home/user-activity.types';
const { Text } = Typography;
interface TimeLoggedTaskListProps {
tasks: IUserTimeLoggedTask[];
}
const TimeLoggedTaskList: React.FC<TimeLoggedTaskListProps> = React.memo(({ tasks }) => {
const { t } = useTranslation('home');
const dispatch = useAppDispatch();
const { token } = theme.useToken();
const handleTaskClick = useCallback(
(taskId: string, projectId: string) => {
dispatch(setSelectedTaskId(taskId));
dispatch(setShowTaskDrawer(true));
dispatch(fetchTask({ taskId, projectId }));
},
[dispatch]
);
const columns = [
{
key: 'task',
render: (record: IUserTimeLoggedTask) => (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
cursor: 'pointer',
padding: '8px 0'
}}
onClick={() => handleTaskClick(record.task_id, record.project_id)}
aria-label={`${t('tasks.timeLoggedTaskAriaLabel')} ${record.task_name}`}
>
{/* Clock Icon */}
<div style={{
color: token.colorSuccess,
fontSize: 14,
flexShrink: 0
}}>
<ClockCircleOutlined />
</div>
{/* Main Content */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* Task Name */}
<div style={{ marginBottom: 2 }}>
<Text
strong
style={{
fontSize: 13,
lineHeight: 1.4,
color: token.colorText
}}
ellipsis={{ tooltip: record.task_name }}
>
{record.task_name}
</Text>
</div>
{/* Project Name */}
<Text
type="secondary"
style={{
fontSize: 11,
lineHeight: 1.2,
display: 'block',
marginBottom: 4
}}
ellipsis={{ tooltip: record.project_name }}
>
{record.project_name}
</Text>
</div>
{/* Right Side - Time and Status */}
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 3,
flexShrink: 0
}}>
{/* Time Logged */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Tag
color="success"
style={{
margin: 0,
fontSize: 11,
padding: '0 6px',
height: 18,
lineHeight: '16px',
borderRadius: 3
}}
>
{record.total_time_logged_string}
</Tag>
{record.logged_by_timer && (
<Tag
color="processing"
style={{
margin: 0,
fontSize: 10,
padding: '0 4px',
height: 16,
lineHeight: '14px',
borderRadius: 2
}}
>
{t('tasks.timerTag')}
</Tag>
)}
</div>
{/* Time Ago */}
<Tooltip
title={formatDate(record.last_logged_at, 'MMMM Do YYYY, h:mm:ss a')}
placement="topRight"
>
<Text
type="secondary"
style={{
fontSize: 10,
lineHeight: 1,
color: token.colorTextTertiary
}}
>
{fromNow(record.last_logged_at)}
</Text>
</Tooltip>
</div>
</div>
),
},
];
return (
<Table
className="custom-two-colors-row-table"
dataSource={tasks}
columns={columns}
rowKey="task_id"
showHeader={false}
pagination={false}
size="small"
/>
);
});
export default TimeLoggedTaskList;

View File

@@ -0,0 +1,19 @@
.activity-feed-item:hover {
background-color: var(--activity-hover, #fafafa);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.activity-feed-item:active {
transform: translateY(0);
background-color: var(--activity-active, #f0f0f0);
}
/* Dark theme support */
[data-theme="dark"] .activity-feed-item:hover {
background-color: var(--activity-hover, #262626);
}
[data-theme="dark"] .activity-feed-item:active {
background-color: var(--activity-active, #1f1f1f);
}

View File

@@ -0,0 +1,203 @@
import React, { useMemo, useCallback, useEffect } from 'react';
import { Card, Segmented, Skeleton, Empty, Typography, Alert, Button, Tooltip } from '@/shared/antd-imports';
import { ClockCircleOutlined, UnorderedListOutlined, SyncOutlined } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { ActivityFeedType } from '@/types/home/user-activity.types';
import { setActiveTab } from '@/features/home-page/user-activity.slice';
import {
useGetUserRecentTasksQuery,
useGetUserTimeLoggedTasksQuery,
} from '@/api/home-page/user-activity.api.service';
import TaskActivityList from './task-activity-list';
import TimeLoggedTaskList from './time-logged-task-list';
const { Title } = Typography;
const UserActivityFeed: React.FC = () => {
const { t } = useTranslation('home');
const dispatch = useAppDispatch();
const { activeTab } = useAppSelector(state => state.userActivityReducer);
const {
data: recentTasksData,
isLoading: loadingRecentTasks,
error: recentTasksError,
refetch: refetchRecentTasks,
} = useGetUserRecentTasksQuery(
{ limit: 10 },
{
skip: false,
refetchOnMountOrArgChange: true
}
);
const {
data: timeLoggedTasksData,
isLoading: loadingTimeLoggedTasks,
error: timeLoggedTasksError,
refetch: refetchTimeLoggedTasks,
} = useGetUserTimeLoggedTasksQuery(
{ limit: 10 },
{
skip: false,
refetchOnMountOrArgChange: true
}
);
const recentTasks = useMemo(() => {
if (!recentTasksData) return [];
// Handle both array and object responses from the API
if (Array.isArray(recentTasksData)) {
return recentTasksData;
}
// If it's an object with a data property (common API pattern)
if (recentTasksData && typeof recentTasksData === 'object' && 'data' in recentTasksData) {
const data = (recentTasksData as any).data;
return Array.isArray(data) ? data : [];
}
// If it's a different object structure, try to extract tasks
if (recentTasksData && typeof recentTasksData === 'object') {
const possibleArrays = Object.values(recentTasksData as any).filter(Array.isArray);
return possibleArrays.length > 0 ? possibleArrays[0] : [];
}
return [];
}, [recentTasksData]);
const timeLoggedTasks = useMemo(() => {
if (!timeLoggedTasksData) return [];
// Handle both array and object responses from the API
if (Array.isArray(timeLoggedTasksData)) {
return timeLoggedTasksData;
}
// If it's an object with a data property (common API pattern)
if (timeLoggedTasksData && typeof timeLoggedTasksData === 'object' && 'data' in timeLoggedTasksData) {
const data = (timeLoggedTasksData as any).data;
return Array.isArray(data) ? data : [];
}
// If it's a different object structure, try to extract tasks
if (timeLoggedTasksData && typeof timeLoggedTasksData === 'object') {
const possibleArrays = Object.values(timeLoggedTasksData as any).filter(Array.isArray);
return possibleArrays.length > 0 ? possibleArrays[0] : [];
}
return [];
}, [timeLoggedTasksData]);
const segmentOptions = useMemo(
() => [
{
value: ActivityFeedType.TIME_LOGGED_TASKS,
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<ClockCircleOutlined style={{ fontSize: 14 }} />
{t('tasks.timeLoggedSegment')}
</span>
),
},
{
value: ActivityFeedType.RECENT_TASKS,
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<UnorderedListOutlined style={{ fontSize: 14 }} />
{t('tasks.recentTasksSegment')}
</span>
),
},
],
[t]
);
const handleTabChange = useCallback(
(value: ActivityFeedType) => {
dispatch(setActiveTab(value));
},
[dispatch]
);
// Refetch data when the active tab changes
useEffect(() => {
if (activeTab === ActivityFeedType.RECENT_TASKS) {
refetchRecentTasks();
} else if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
refetchTimeLoggedTasks();
}
}, [activeTab, refetchRecentTasks, refetchTimeLoggedTasks]);
const handleRefresh = useCallback(() => {
if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
refetchTimeLoggedTasks();
} else {
refetchRecentTasks();
}
}, [activeTab, refetchRecentTasks, refetchTimeLoggedTasks]);
const isLoading = activeTab === ActivityFeedType.TIME_LOGGED_TASKS ? loadingTimeLoggedTasks : loadingRecentTasks;
const currentCount = activeTab === ActivityFeedType.TIME_LOGGED_TASKS ? timeLoggedTasks.length : recentTasks.length;
const renderContent = () => {
if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
if (loadingTimeLoggedTasks) {
return <Skeleton active />;
}
if (timeLoggedTasksError) {
return <Alert message={t('tasks.errorLoadingTimeLoggedTasks')} type="error" showIcon />;
}
if (timeLoggedTasks.length === 0) {
return <Empty description={t('tasks.noTimeLoggedTasks')} />;
}
return (
<div style={{ maxHeight: 450, overflow: 'auto' }}>
<TimeLoggedTaskList tasks={timeLoggedTasks} />
</div>
);
} else if (activeTab === ActivityFeedType.RECENT_TASKS) {
if (loadingRecentTasks) {
return <Skeleton active />;
}
if (recentTasksError) {
return <Alert message={t('tasks.errorLoadingRecentTasks')} type="error" showIcon />;
}
if (recentTasks.length === 0) {
return <Empty description={t('tasks.noRecentTasks')} />;
}
return (
<div style={{ maxHeight: 450, overflow: 'auto' }}>
<TaskActivityList tasks={recentTasks} />
</div>
);
}
return null;
};
return (
<Card
title={
<Typography.Title level={5} style={{ marginBlockEnd: 0 }}>
{t('tasks.recentActivity')} ({currentCount})
</Typography.Title>
}
extra={
<Tooltip title={t('tasks.refresh')}>
<Button
shape="circle"
icon={<SyncOutlined spin={isLoading} />}
onClick={handleRefresh}
/>
</Tooltip>
}
style={{ width: '100%' }}
>
<Segmented
options={segmentOptions}
value={activeTab}
onChange={handleTabChange}
style={{ marginBottom: 16, width: '100%' }}
block
/>
{renderContent()}
</Card>
);
};
export default React.memo(UserActivityFeed);

View File

@@ -802,7 +802,7 @@ const ProjectList: React.FC = () => {
}, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading, isLoading]);
return (
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
<div style={{ minHeight: '90vh' }}>
<PageHeader
className="site-page-header"
title={`${projectCount} ${t('projects')}`}

View File

@@ -360,14 +360,14 @@ const ProjectView = React.memo(() => {
// Show loading state while project is being fetched or translations are loading
if (projectLoading || !isInitialized || !translationsReady) {
return (
<div style={{ marginBlockStart: 70, marginBlockEnd: 12, minHeight: '80vh' }}>
<div style={{ marginBlockEnd: 12, minHeight: '80vh' }}>
<SuspenseFallback />
</div>
);
}
return (
<div style={{ marginBlockStart: 70, marginBlockEnd: 12, minHeight: '80vh' }}>
<div style={{ marginBlockEnd: 12, minHeight: '80vh' }}>
<ProjectViewHeader />
<Tabs

View File

@@ -28,9 +28,8 @@ const ProjectsReports = () => {
// Memoize the Excel export handler to prevent recreation on every render
const handleExcelExport = useCallback(() => {
if (currentSession?.team_name) {
reportingExportApiService.exportProjects(currentSession.team_name);
}
const teamName = currentSession?.team_name || 'Team';
reportingExportApiService.exportProjects(teamName);
}, [currentSession?.team_name]);
// Memoize the archived checkbox handler to prevent recreation on every render

View File

@@ -54,7 +54,7 @@ const Schedule: React.FC = () => {
};
return (
<div style={{ marginBlockStart: 65, minHeight: '90vh' }}>
<div style={{ minHeight: '90vh' }}>
<Flex align="center" justify="space-between">
<Flex
gap={16}

View File

@@ -8,22 +8,28 @@ import {
TableProps,
Tooltip,
Typography,
DeleteOutlined,
ExclamationCircleFilled,
SearchOutlined,
EditOutlined,
} from '@/shared/antd-imports';
import { useEffect, useMemo, useState } from 'react';
import PinRouteToNavbarButton from '../../../components/PinRouteToNavbarButton';
import PinRouteToNavbarButton from '@/components/PinRouteToNavbarButton';
import { useTranslation } from 'react-i18next';
import { DeleteOutlined, ExclamationCircleFilled, SearchOutlined } from '@/shared/antd-imports';
import { ITaskLabel } from '@/types/label.type';
import { labelsApiService } from '@/api/taskAttributes/labels/labels.api.service';
import CustomColorLabel from '@components/task-list-common/labelsSelector/custom-color-label';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import logger from '@/utils/errorLogger';
import LabelsDrawer from './labels-drawer';
const LabelsSettings = () => {
const { t } = useTranslation('settings/labels');
useDocumentTitle('Manage Labels');
useDocumentTitle(t('pageTitle', 'Manage Labels'));
const [selectedLabelId, setSelectedLabelId] = useState<string | null>(null);
const [showDrawer, setShowDrawer] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [labels, setLabels] = useState<ITaskLabel[]>([]);
const [loading, setLoading] = useState(false);
@@ -64,32 +70,62 @@ const LabelsSettings = () => {
}
};
const handleEditClick = (id: string) => {
setSelectedLabelId(id);
setShowDrawer(true);
};
const handleDrawerClose = () => {
setSelectedLabelId(null);
setShowDrawer(false);
getLabels();
};
// table columns
const columns: TableProps['columns'] = [
{
key: 'label',
title: t('labelColumn'),
title: t('labelColumn', 'Label'),
onCell: record => ({
onClick: () => handleEditClick(record.id!),
}),
render: (record: ITaskLabel) => <CustomColorLabel label={record} />,
},
{
key: 'associatedTask',
title: t('associatedTaskColumn'),
title: t('associatedTaskColumn', 'Associated Task Count'),
render: (record: ITaskLabel) => <Typography.Text>{record.usage}</Typography.Text>,
},
{
key: 'actionBtns',
width: 60,
width: 100,
render: (record: ITaskLabel) => (
<div className="action-button opacity-0 transition-opacity duration-200">
<Popconfirm
title="Are you sure you want to delete this?"
icon={<ExclamationCircleFilled style={{ color: '#ff9800' }} />}
okText="Delete"
cancelText="Cancel"
onConfirm={() => deleteLabel(record.id!)}
>
<Button shape="default" icon={<DeleteOutlined />} size="small" />
</Popconfirm>
<Flex gap={4}>
<Tooltip title={t('editTooltip', 'Edit')}>
<Button
shape="default"
icon={<EditOutlined />}
size="small"
onClick={(e) => {
e.stopPropagation();
handleEditClick(record.id!);
}}
/>
</Tooltip>
<Popconfirm
title={t('deleteConfirmTitle', 'Are you sure you want to delete this?')}
icon={<ExclamationCircleFilled style={{ color: '#ff9800' }} />}
okText={t('deleteButton', 'Delete')}
cancelText={t('cancelButton', 'Cancel')}
onConfirm={() => deleteLabel(record.id!)}
>
<Tooltip title={t('deleteTooltip', 'Delete')}>
<Button shape="default" icon={<DeleteOutlined />} size="small" />
</Tooltip>
</Popconfirm>
</Flex>
</div>
),
},
@@ -104,12 +140,12 @@ const LabelsSettings = () => {
<Input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
placeholder={t('searchPlaceholder', 'Search by name')}
style={{ maxWidth: 232 }}
suffix={<SearchOutlined />}
/>
<Tooltip title={t('pinTooltip')} trigger={'hover'}>
<Tooltip title={t('pinTooltip', 'Click to pin this into the main menu')} trigger={'hover'}>
{/* this button pin this route to navbar */}
<PinRouteToNavbarButton name="labels" path="/worklenz/settings/labels" />
</Tooltip>
@@ -119,13 +155,17 @@ const LabelsSettings = () => {
>
<Table
locale={{
emptyText: <Typography.Text>{t('emptyText')}</Typography.Text>,
emptyText: <Typography.Text>{t('emptyText', 'Labels can be created while updating or creating tasks.')}</Typography.Text>,
}}
loading={loading}
className="custom-two-colors-row-table"
dataSource={filteredData}
columns={columns}
rowKey={record => record.id!}
onRow={(record) => ({
style: { cursor: 'pointer' },
onClick: () => handleEditClick(record.id!),
})}
pagination={{
showSizeChanger: true,
defaultPageSize: 20,
@@ -133,6 +173,12 @@ const LabelsSettings = () => {
size: 'small',
}}
/>
<LabelsDrawer
drawerOpen={showDrawer}
labelId={selectedLabelId}
drawerClosed={handleDrawerClose}
/>
</Card>
);
};

View File

@@ -0,0 +1,228 @@
import { Button, Drawer, Form, Input, message, Typography, Flex, Dropdown } from '@/shared/antd-imports';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { theme } from 'antd';
import { labelsApiService } from '@/api/taskAttributes/labels/labels.api.service';
const WorklenzColorShades = {
"#154c9b": ["#0D2A50", "#112E54", "#153258", "#19365C", "#1D3A60", "#213E64", "#254268", "#29466C", "#2D4A70", "#314E74"],
"#3b7ad4": ["#224884", "#26528A", "#2A5C90", "#2E6696", "#32709C", "#367AA2", "#3A84A8", "#3E8EAE", "#4298B4", "#46A2BA"],
"#70a6f3": ["#3D5D8A", "#46679E", "#5071B2", "#597BC6", "#6385DA", "#6C8FEE", "#7699F2", "#7FA3F6", "#89ADFA", "#92B7FE"],
"#7781ca": ["#42486F", "#4C5283", "#565C97", "#6066AB", "#6A70BF", "#747AD3", "#7E84E7", "#888EFB", "#9298FF", "#9CA2FF"],
"#9877ca": ["#542D70", "#6E3A8A", "#8847A4", "#A254BE", "#BC61D8", "#D66EF2", "#E07BFC", "#EA88FF", "#F495FF", "#FEA2FF"],
"#c178c9": ["#6A2E6F", "#843B89", "#9E48A3", "#B855BD", "#D262D7", "#EC6FF1", "#F67CFB", "#FF89FF", "#FF96FF", "#FFA3FF"],
"#ee87c5": ["#832C6A", "#9D3984", "#B7469E", "#D153B8", "#EB60D2", "#FF6DEC", "#FF7AF6", "#FF87FF", "#FF94FF", "#FFA1FF"],
"#ca7881": ["#6F2C3E", "#893958", "#A34672", "#BD538C", "#D760A6", "#F16DC0", "#FB7ADA", "#FF87F4", "#FF94FF", "#FFA1FF"],
"#75c9c0": ["#3F6B66", "#497E7A", "#53918E", "#5DA4A2", "#67B7B6", "#71CBCA", "#7BDEDE", "#85F2F2", "#8FFFFF", "#99FFFF"],
"#75c997": ["#3F6B54", "#497E6A", "#53917F", "#5DA495", "#67B7AA", "#71CBBF", "#7BDED4", "#85F2E9", "#8FFFFF", "#99FFFF"],
"#80ca79": ["#456F3E", "#5A804D", "#6F935C", "#84A66B", "#99B97A", "#AECC89", "#C3DF98", "#D8F2A7", "#EDFFB6", "#FFFFC5"],
"#aacb78": ["#5F6F3E", "#7A804D", "#94935C", "#AFA66B", "#CAB97A", "#E5CC89", "#FFDF98", "#FFF2A7", "#FFFFB6", "#FFFFC5"],
"#cbbc78": ["#6F5D3E", "#8A704D", "#A4835C", "#BF966B", "#DAA97A", "#F5BC89", "#FFCF98", "#FFE2A7", "#FFF5B6", "#FFFFC5"],
"#cb9878": ["#704D3E", "#8B604D", "#A6735C", "#C1866B", "#DC997A", "#F7AC89", "#FFBF98", "#FFD2A7", "#FFE5B6", "#FFF8C5"],
"#bb774c": ["#653D27", "#80502C", "#9B6331", "#B67636", "#D1893B", "#EC9C40", "#FFAF45", "#FFC24A", "#FFD54F", "#FFE854"],
"#905b39": ["#4D2F1A", "#623C23", "#774A2C", "#8C5735", "#A1643E", "#B67147", "#CB7E50", "#E08B59", "#F59862", "#FFA56B"],
"#903737": ["#4D1A1A", "#622323", "#772C2C", "#8C3535", "#A13E3E", "#B64747", "#CB5050", "#E05959", "#F56262", "#FF6B6B"],
"#bf4949": ["#661212", "#801B1B", "#992424", "#B32D2D", "#CC3636", "#E63F3F", "#FF4848", "#FF5151", "#FF5A5A", "#FF6363"],
"#f37070": ["#853A3A", "#A04D4D", "#BA6060", "#D47373", "#EF8686", "#FF9999", "#FFA3A3", "#FFACAC", "#FFB6B6", "#FFBFBF"],
"#ff9c3c": ["#8F5614", "#AA6F1F", "#C48829", "#DFA233", "#F9BB3D", "#FFC04E", "#FFC75F", "#FFCE70", "#FFD581", "#FFDB92"],
"#fbc84c": ["#8F6D14", "#AA862F", "#C4A029", "#DFB933", "#F9D23D", "#FFD74E", "#FFDC5F", "#FFE170", "#FFE681", "#FFEB92"],
"#cbc8a1": ["#6F6D58", "#8A886F", "#A4A286", "#BFBC9D", "#DAD6B4", "#F5F0CB", "#FFFEDE", "#FFFFF2", "#FFFFCD", "#FFFFCD"],
"#a9a9a9": ["#5D5D5D", "#757575", "#8D8D8D", "#A5A5A5", "#BDBDBD", "#D5D5D5", "#EDEDED", "#F5F5F5", "#FFFFFF", "#FFFFFF"],
"#767676": ["#404040", "#4D4D4D", "#5A5A5A", "#676767", "#747474", "#818181", "#8E8E8E", "#9B9B9B", "#A8A8A8", "#B5B5B5"]
} as const;
// Flatten the color shades into a single array for the color picker
const WorklenzColorCodes = Object.values(WorklenzColorShades).flat();
type LabelsDrawerProps = {
drawerOpen: boolean;
labelId: string | null;
drawerClosed: () => void;
};
const LabelsDrawer = ({
drawerOpen = false,
labelId = null,
drawerClosed,
}: LabelsDrawerProps) => {
const { t } = useTranslation('settings/labels');
const { token } = theme.useToken();
const [form] = Form.useForm();
useEffect(() => {
if (labelId) {
getLabelById(labelId);
} else {
form.resetFields();
form.setFieldsValue({ color_code: Object.keys(WorklenzColorShades)[0] }); // Set default color
}
}, [labelId, form]);
const getLabelById = async (id: string) => {
try {
const response = await labelsApiService.getLabels();
if (response.done) {
const label = response.body.find((l: any) => l.id === id);
if (label) {
form.setFieldsValue({
name: label.name,
color_code: label.color_code
});
}
}
} catch (error) {
message.error(t('fetchLabelErrorMessage', 'Failed to fetch label'));
}
};
const handleFormSubmit = async (values: { name: string; color_code: string }) => {
try {
if (labelId) {
const response = await labelsApiService.updateLabel(labelId, {
name: values.name,
color: values.color_code,
});
if (response.done) {
message.success(t('updateLabelSuccessMessage', 'Label updated successfully'));
drawerClosed();
}
} else {
// For creating new labels, we'd need a create API endpoint
message.info(t('createNotSupported', 'Creating new labels is done through tasks'));
drawerClosed();
}
} catch (error) {
message.error(labelId ? t('updateLabelErrorMessage', 'Failed to update label') : t('createLabelErrorMessage', 'Failed to create label'));
}
};
const handleClose = () => {
form.resetFields();
drawerClosed();
};
const ColorPicker = ({ value, onChange }: { value?: string; onChange?: (color: string) => void }) => (
<Dropdown
dropdownRender={() => (
<div style={{
padding: 16,
backgroundColor: token.colorBgElevated,
borderRadius: token.borderRadius,
boxShadow: token.boxShadowSecondary,
border: `1px solid ${token.colorBorder}`,
width: 400,
maxHeight: 500,
overflowY: 'auto'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(10, 1fr)',
gap: 6,
justifyItems: 'center'
}}>
{WorklenzColorCodes.map((color) => (
<div
key={color}
style={{
width: 18,
height: 18,
backgroundColor: color,
borderRadius: 2,
border: value === color ? `2px solid ${token.colorPrimary}` : `1px solid ${token.colorBorder}`,
cursor: 'pointer',
transition: 'all 0.2s ease',
flexShrink: 0
}}
onClick={() => onChange?.(color)}
onMouseEnter={(e) => {
if (value !== color) {
e.currentTarget.style.transform = 'scale(1.2)';
e.currentTarget.style.boxShadow = token.boxShadow;
e.currentTarget.style.zIndex = '10';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.zIndex = '1';
}}
/>
))}
</div>
</div>
)}
trigger={['click']}
>
<div
style={{
width: 40,
height: 40,
backgroundColor: value || Object.keys(WorklenzColorShades)[0],
borderRadius: 4,
border: `1px solid ${token.colorBorder}`,
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = token.boxShadow;
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = 'none';
}}
/>
</Dropdown>
);
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{labelId ? t('updateLabelDrawerTitle', 'Edit Label') : t('createLabelDrawerTitle', 'Create Label')}
</Typography.Text>
}
open={drawerOpen}
onClose={handleClose}
destroyOnClose
width={400}
>
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
<Form.Item
name="name"
label={t('nameLabel', 'Name')}
rules={[
{
required: true,
message: t('nameRequiredMessage', 'Please enter a label name'),
},
]}
>
<Input placeholder={t('namePlaceholder', 'Enter label name')} />
</Form.Item>
<Form.Item
name="color_code"
label={t('colorLabel', 'Color')}
rules={[
{
required: true,
message: t('colorRequiredMessage', 'Please select a color'),
},
]}
>
<ColorPicker />
</Form.Item>
<Flex justify="end" gap={8}>
<Button onClick={handleClose}>
{t('cancelButton', 'Cancel')}
</Button>
<Button type="primary" htmlType="submit">
{labelId ? t('updateButton', 'Update') : t('createButton', 'Create')}
</Button>
</Flex>
</Form>
</Drawer>
);
};
export default LabelsDrawer;

View File

@@ -182,7 +182,8 @@ export {
InfoCircleOutlined,
WarningTwoTone,
ShareAltOutlined,
CloudDownloadOutlined
CloudDownloadOutlined,
CopyOutlined
} from '@ant-design/icons';
// Re-export all components with React

View File

@@ -313,3 +313,293 @@ export const durations: IRPTDuration[] = [
dates: '',
},
];
export const WorklenzColorCodes = [
// Row 1: Slate/Gray spectrum
'#0f172a',
'#1e293b',
'#334155',
'#475569',
'#64748b',
'#94a3b8',
'#cbd5e1',
'#e2e8f0',
'#f1f5f9',
'#f8fafc',
'#ffffff',
'#000000',
'#1a1a1a',
'#2d2d30',
'#3e3e42',
'#525252',
// Row 2: Blue spectrum - dark to light
'#0c4a6e',
'#075985',
'#0369a1',
'#0284c7',
'#0ea5e9',
'#38bdf8',
'#7dd3fc',
'#bae6fd',
'#e0f2fe',
'#f0f9ff',
'#1e3a8a',
'#1d4ed8',
'#2563eb',
'#3b82f6',
'#60a5fa',
'#93c5fd',
// Row 3: Indigo/Violet spectrum
'#312e81',
'#3730a3',
'#4338ca',
'#4f46e5',
'#6366f1',
'#818cf8',
'#a5b4fc',
'#c7d2fe',
'#e0e7ff',
'#eef2ff',
'#581c87',
'#6b21a8',
'#7c3aed',
'#8b5cf6',
'#a78bfa',
'#c4b5fd',
// Row 4: Purple/Fuchsia spectrum
'#701a75',
'#86198f',
'#a21caf',
'#c026d3',
'#d946ef',
'#e879f9',
'#f0abfc',
'#f3e8ff',
'#faf5ff',
'#fdf4ff',
'#831843',
'#be185d',
'#e11d48',
'#f43f5e',
'#fb7185',
'#fda4af',
// Row 5: Pink/Rose spectrum
'#9f1239',
'#be123c',
'#e11d48',
'#f43f5e',
'#fb7185',
'#fda4af',
'#fecdd3',
'#fed7d7',
'#fef2f2',
'#fff1f2',
'#450a0a',
'#7f1d1d',
'#991b1b',
'#dc2626',
'#ef4444',
'#f87171',
// Row 6: Red spectrum
'#7f1d1d',
'#991b1b',
'#dc2626',
'#ef4444',
'#f87171',
'#fca5a5',
'#fecaca',
'#fef2f2',
'#fffbeb',
'#fefce8',
'#92400e',
'#a16207',
'#ca8a04',
'#eab308',
'#facc15',
'#fef08a',
// Row 7: Orange spectrum
'#9a3412',
'#c2410c',
'#ea580c',
'#f97316',
'#fb923c',
'#fdba74',
'#fed7aa',
'#ffedd5',
'#fff7ed',
'#fffbeb',
'#78350f',
'#92400e',
'#c2410c',
'#ea580c',
'#f97316',
'#fb923c',
// Row 8: Amber/Yellow spectrum
'#451a03',
'#78350f',
'#92400e',
'#a16207',
'#ca8a04',
'#eab308',
'#facc15',
'#fef08a',
'#fefce8',
'#fffbeb',
'#365314',
'#4d7c0f',
'#65a30d',
'#84cc16',
'#a3e635',
'#bef264',
// Row 9: Lime/Green spectrum
'#1a2e05',
'#365314',
'#4d7c0f',
'#65a30d',
'#84cc16',
'#a3e635',
'#bef264',
'#d9f99d',
'#ecfccb',
'#f7fee7',
'#14532d',
'#166534',
'#15803d',
'#16a34a',
'#22c55e',
'#4ade80',
// Row 10: Emerald spectrum
'#064e3b',
'#065f46',
'#047857',
'#059669',
'#10b981',
'#34d399',
'#6ee7b7',
'#a7f3d0',
'#d1fae5',
'#ecfdf5',
'#0f766e',
'#0d9488',
'#14b8a6',
'#2dd4bf',
'#5eead4',
'#99f6e4',
// Row 11: Teal/Cyan spectrum
'#134e4a',
'#155e75',
'#0891b2',
'#0e7490',
'#0284c7',
'#0ea5e9',
'#22d3ee',
'#67e8f9',
'#a5f3fc',
'#cffafe',
'#164e63',
'#0c4a6e',
'#075985',
'#0369a1',
'#0284c7',
'#0ea5e9',
// Row 12: Sky spectrum
'#0c4a6e',
'#075985',
'#0369a1',
'#0284c7',
'#0ea5e9',
'#38bdf8',
'#7dd3fc',
'#bae6fd',
'#e0f2fe',
'#f0f9ff',
'#1e40af',
'#1d4ed8',
'#2563eb',
'#3b82f6',
'#60a5fa',
'#93c5fd',
// Row 13: Warm grays and browns
'#292524',
'#44403c',
'#57534e',
'#78716c',
'#a8a29e',
'#d6d3d1',
'#e7e5e4',
'#f5f5f4',
'#fafaf9',
'#ffffff',
'#7c2d12',
'#9a3412',
'#c2410c',
'#ea580c',
'#f97316',
'#fb923c',
// Row 14: Cool grays
'#111827',
'#1f2937',
'#374151',
'#4b5563',
'#6b7280',
'#9ca3af',
'#d1d5db',
'#e5e7eb',
'#f3f4f6',
'#f9fafb',
'#030712',
'#0c0a09',
'#1c1917',
'#292524',
'#44403c',
'#57534e',
// Row 15: Neutral spectrum
'#171717',
'#262626',
'#404040',
'#525252',
'#737373',
'#a3a3a3',
'#d4d4d4',
'#e5e5e5',
'#f5f5f5',
'#fafafa',
'#09090b',
'#18181b',
'#27272a',
'#3f3f46',
'#52525b',
'#71717a',
// Row 16: Extended colors
'#a1a1aa',
'#d4d4d8',
'#e4e4e7',
'#f4f4f5',
'#fafafa',
'#27272a',
'#3f3f46',
'#52525b',
'#71717a',
'#a1a1aa',
'#d4d4d8',
'#e4e4e7',
'#f4f4f5',
'#fafafa',
'#ffffff',
'#000000',
];

View File

@@ -0,0 +1,83 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Mock environment variables
Object.defineProperty(process, 'env', {
value: {
NODE_ENV: 'test',
VITE_API_URL: 'http://localhost:3000',
VITE_ENABLE_GOOGLE_LOGIN: 'true',
VITE_ENABLE_RECAPTCHA: 'false',
VITE_RECAPTCHA_SITE_KEY: 'test-site-key',
},
});
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock window.getSelection
Object.defineProperty(window, 'getSelection', {
writable: true,
value: vi.fn().mockImplementation(() => ({
rangeCount: 0,
getRangeAt: vi.fn(),
removeAllRanges: vi.fn(),
})),
});
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
vi.stubGlobal('localStorage', localStorageMock);
// Mock sessionStorage
const sessionStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
vi.stubGlobal('sessionStorage', sessionStorageMock);
// Suppress console warnings during tests
const originalConsoleWarn = console.warn;
console.warn = (...args) => {
// Suppress specific warnings that are not relevant for tests
if (
args[0]?.includes?.('React Router Future Flag Warning') ||
args[0]?.includes?.('validateDOMNesting')
) {
return;
}
originalConsoleWarn(...args);
};

View File

@@ -0,0 +1,32 @@
export interface IUserRecentTask {
task_id: string;
task_name: string;
project_id: string;
project_name: string;
last_activity_at: string;
activity_count: number;
project_color?: string;
task_status?: string;
status_color?: string;
}
export interface IUserTimeLoggedTask {
task_id: string;
task_name: string;
project_id: string;
project_name: string;
total_time_logged: number;
total_time_logged_string: string;
last_logged_at: string;
logged_by_timer: boolean;
project_color?: string;
task_status?: string;
status_color?: string;
log_entries_count?: number;
estimated_time?: number;
}
export enum ActivityFeedType {
RECENT_TASKS = 'recent-tasks',
TIME_LOGGED_TASKS = 'time-logged-tasks'
}

View File

@@ -1,32 +1,59 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import 'dayjs/locale/de';
import 'dayjs/locale/es';
import 'dayjs/locale/pt';
import 'dayjs/locale/zh-cn';
import { getLanguageFromLocalStorage } from './language-utils';
// Initialize the relativeTime plugin
// Initialize plugins
dayjs.extend(relativeTime);
dayjs.extend(localizedFormat);
/**
* Formats a date to a relative time string (e.g., "2 hours ago", "a day ago")
* This mimics the Angular fromNow pipe functionality
*
* @param date - The date to format (string, Date, or dayjs object)
* @returns A string representing the relative time
*/
export const fromNow = (date: string | Date | dayjs.Dayjs): string => {
if (!date) return '';
return dayjs(date).fromNow();
// Map application languages to dayjs locales
const getLocaleFromLanguage = (language: string): string => {
const localeMap: Record<string, string> = {
'en': 'en',
'de': 'de',
'es': 'es',
'pt': 'pt',
'alb': 'en', // Albanian not supported by dayjs, fallback to English
'zh': 'zh-cn'
};
return localeMap[language] || 'en';
};
/**
* Formats a date to a specific format
* Formats a date to a relative time string (e.g., "2 hours ago", "a day ago")
* This mimics the Angular fromNow pipe functionality with locale support
*
* @param date - The date to format (string, Date, or dayjs object)
* @param language - Optional language override (defaults to stored language)
* @returns A string representing the relative time
*/
export const fromNow = (date: string | Date | dayjs.Dayjs, language?: string): string => {
if (!date) return '';
const currentLanguage = language || getLanguageFromLocalStorage();
const locale = getLocaleFromLanguage(currentLanguage);
return dayjs(date).locale(locale).fromNow();
};
/**
* Formats a date to a specific format with locale support
*
* @param date - The date to format (string, Date, or dayjs object)
* @param format - The format string (default: 'YYYY-MM-DD')
* @param language - Optional language override (defaults to stored language)
* @returns A formatted date string
*/
export const formatDate = (
date: string | Date | dayjs.Dayjs,
format: string = 'YYYY-MM-DD'
format: string = 'YYYY-MM-DD',
language?: string
): string => {
if (!date) return '';
return dayjs(date).format(format);
const currentLanguage = language || getLanguageFromLocalStorage();
const locale = getLocaleFromLanguage(currentLanguage);
return dayjs(date).locale(locale).format(format);
};

View File

@@ -198,6 +198,17 @@ export class ServiceWorkerManager {
}
}
// Check for updates
async checkForUpdates(): Promise<boolean> {
try {
const response = await this.sendMessage('CHECK_FOR_UPDATES');
return response.hasUpdates;
} catch (error) {
console.error('Failed to check for updates:', error);
return false;
}
}
// Force update service worker
async forceUpdate(): Promise<void> {
if (!this.registration) return;
@@ -212,6 +223,27 @@ export class ServiceWorkerManager {
}
}
// Perform hard reload (clear cache and reload)
async hardReload(): Promise<void> {
try {
// Clear all caches first
await this.clearCache();
// Force update the service worker
if (this.registration) {
await this.registration.update();
await this.sendMessage('SKIP_WAITING');
}
// Perform hard reload by clearing browser cache
window.location.reload();
} catch (error) {
console.error('Failed to perform hard reload:', error);
// Fallback to regular reload
window.location.reload();
}
}
// Check if app is running offline
isOffline(): boolean {
return !navigator.onLine;
@@ -268,6 +300,8 @@ export function useServiceWorker() {
swManager,
clearCache: () => swManager?.clearCache(),
forceUpdate: () => swManager?.forceUpdate(),
hardReload: () => swManager?.hardReload(),
checkForUpdates: () => swManager?.checkForUpdates(),
getVersion: () => swManager?.getVersion(),
};
}

Some files were not shown because too many files have changed in this diff Show More