diff --git a/worklenz-backend/database/migrations/20250427000000-fix-progress-mode-type.sql b/worklenz-backend/database/migrations/20250427000000-fix-progress-mode-type.sql index 557b1bc5..6e5efc9d 100644 --- a/worklenz-backend/database/migrations/20250427000000-fix-progress-mode-type.sql +++ b/worklenz-backend/database/migrations/20250427000000-fix-progress-mode-type.sql @@ -145,7 +145,7 @@ BEGIN SET progress_value = NULL, progress_mode = NULL WHERE project_id = _project_id - AND progress_mode = _old_mode; + AND progress_mode::text::progress_mode_type = _old_mode; END IF; RETURN NEW; diff --git a/worklenz-backend/src/controllers/tasks-controller-base.ts b/worklenz-backend/src/controllers/tasks-controller-base.ts index d2524bad..58558c1e 100644 --- a/worklenz-backend/src/controllers/tasks-controller-base.ts +++ b/worklenz-backend/src/controllers/tasks-controller-base.ts @@ -50,11 +50,16 @@ export default class TasksControllerBase extends WorklenzControllerBase { task.progress = parseInt(task.progress_value); task.complete_ratio = parseInt(task.progress_value); } - // For tasks with no subtasks and no manual progress, calculate based on time + // For tasks with no subtasks and no manual progress else { - task.progress = task.total_minutes_spent && task.total_minutes - ? ~~(task.total_minutes_spent / task.total_minutes * 100) - : 0; + // Only calculate progress based on time if time-based progress is enabled for the project + if (task.project_use_time_progress && task.total_minutes_spent && task.total_minutes) { + // Cap the progress at 100% to prevent showing more than 100% progress + task.progress = Math.min(~~(task.total_minutes_spent / task.total_minutes * 100), 100); + } else { + // Default to 0% progress when time-based calculation is not enabled + task.progress = 0; + } // Set complete_ratio to match progress task.complete_ratio = task.progress; diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 6e01c686..10c556d3 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -610,6 +610,21 @@ export default class TasksControllerV2 extends TasksControllerBase { return this.createTagList(result.rows); } + public static async getProjectSubscribers(projectId: string) { + const q = ` + SELECT u.name, u.avatar_url, ps.user_id, ps.team_member_id, ps.project_id + FROM project_subscribers ps + LEFT JOIN users u ON ps.user_id = u.id + WHERE ps.project_id = $1; + `; + const result = await db.query(q, [projectId]); + + for (const member of result.rows) + member.color_code = getColor(member.name); + + return this.createTagList(result.rows); + } + public static async checkUserAssignedToTask(taskId: string, userId: string, teamId: string) { const q = ` SELECT EXISTS( diff --git a/worklenz-backend/src/middlewares/session-middleware.ts b/worklenz-backend/src/middlewares/session-middleware.ts index fea60018..cb6cd624 100644 --- a/worklenz-backend/src/middlewares/session-middleware.ts +++ b/worklenz-backend/src/middlewares/session-middleware.ts @@ -6,11 +6,11 @@ import { isProduction } from "../shared/utils"; const pgSession = require("connect-pg-simple")(session); export default session({ - name: process.env.SESSION_NAME || "worklenz.sid", + name: process.env.SESSION_NAME, secret: process.env.SESSION_SECRET || "development-secret-key", - proxy: true, + proxy: false, resave: false, - saveUninitialized: false, + saveUninitialized: true, rolling: true, store: new pgSession({ pool: db.pool, @@ -18,9 +18,10 @@ export default session({ }), cookie: { path: "/", - secure: isProduction(), // Use secure cookies in production - httpOnly: true, - sameSite: "lax", // Standard setting for same-origin requests + // secure: isProduction(), + // httpOnly: isProduction(), + // sameSite: "none", + // domain: isProduction() ? ".worklenz.com" : undefined, maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days } }); \ No newline at end of file diff --git a/worklenz-backend/src/socket.io/commands/on-project-subscriber-change.ts b/worklenz-backend/src/socket.io/commands/on-project-subscriber-change.ts index 6057e88f..bbe90425 100644 --- a/worklenz-backend/src/socket.io/commands/on-project-subscriber-change.ts +++ b/worklenz-backend/src/socket.io/commands/on-project-subscriber-change.ts @@ -19,7 +19,8 @@ export async function on_project_subscriber_change(_io: Server, socket: Socket, const isSubscribe = data.mode == 0; const q = isSubscribe ? `INSERT INTO project_subscribers (user_id, project_id, team_member_id) - VALUES ($1, $2, $3);` + VALUES ($1, $2, $3) + ON CONFLICT (user_id, project_id, team_member_id) DO NOTHING;` : `DELETE FROM project_subscribers WHERE user_id = $1 @@ -27,7 +28,7 @@ export async function on_project_subscriber_change(_io: Server, socket: Socket, AND team_member_id = $3;`; await db.query(q, [data.user_id, data.project_id, data.team_member_id]); - const subscribers = await TasksControllerV2.getTaskSubscribers(data.project_id); + const subscribers = await TasksControllerV2.getProjectSubscribers(data.project_id); socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), subscribers); return; diff --git a/worklenz-frontend/public/locales/en/project-drawer.json b/worklenz-frontend/public/locales/en/project-drawer.json index c9d89238..be553a01 100644 --- a/worklenz-frontend/public/locales/en/project-drawer.json +++ b/worklenz-frontend/public/locales/en/project-drawer.json @@ -47,5 +47,6 @@ "weightedProgress": "Weighted Progress", "weightedProgressTooltip": "Calculate progress based on subtask weights", "timeProgress": "Time-based Progress", - "timeProgressTooltip": "Calculate progress based on estimated time" + "timeProgressTooltip": "Calculate progress based on estimated time", + "enterProjectKey": "Enter project key" } diff --git a/worklenz-frontend/public/locales/es/project-drawer.json b/worklenz-frontend/public/locales/es/project-drawer.json index abe5a856..447ad4f1 100644 --- a/worklenz-frontend/public/locales/es/project-drawer.json +++ b/worklenz-frontend/public/locales/es/project-drawer.json @@ -47,5 +47,6 @@ "weightedProgress": "Progreso Ponderado", "weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas", "timeProgress": "Progreso Basado en Tiempo", - "timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado" + "timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado", + "enterProjectKey": "Ingresa la clave del proyecto" } diff --git a/worklenz-frontend/public/locales/pt/project-drawer.json b/worklenz-frontend/public/locales/pt/project-drawer.json index b7ff40be..92e11964 100644 --- a/worklenz-frontend/public/locales/pt/project-drawer.json +++ b/worklenz-frontend/public/locales/pt/project-drawer.json @@ -47,5 +47,6 @@ "weightedProgress": "Progresso Ponderado", "weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas", "timeProgress": "Progresso Baseado em Tempo", - "timeProgressTooltip": "Calcular o progresso com base no tempo estimado" + "timeProgressTooltip": "Calcular o progresso com base no tempo estimado", + "enterProjectKey": "Insira a chave do projeto" } diff --git a/worklenz-frontend/src/components/admin-center/billing/account-storage/account-storage.tsx b/worklenz-frontend/src/components/admin-center/billing/account-storage/account-storage.tsx index facd237d..6af50210 100644 --- a/worklenz-frontend/src/components/admin-center/billing/account-storage/account-storage.tsx +++ b/worklenz-frontend/src/components/admin-center/billing/account-storage/account-storage.tsx @@ -68,7 +68,7 @@ const AccountStorage = ({ themeMode }: IAccountStorageProps) => {
{percent}% Used} /> diff --git a/worklenz-frontend/src/components/admin-center/billing/current-bill.tsx b/worklenz-frontend/src/components/admin-center/billing/current-bill.tsx index 7eacbae2..72188293 100644 --- a/worklenz-frontend/src/components/admin-center/billing/current-bill.tsx +++ b/worklenz-frontend/src/components/admin-center/billing/current-bill.tsx @@ -1,21 +1,17 @@ -import { Button, Card, Col, Modal, Row, Tooltip, Typography } from 'antd'; +import { Card, Col, Row, Tooltip, Typography } from 'antd'; import React, { useEffect } from 'react'; import './current-bill.css'; import { InfoCircleTwoTone } from '@ant-design/icons'; import ChargesTable from './billing-tables/charges-table'; import InvoicesTable from './billing-tables/invoices-table'; -import UpgradePlansLKR from './drawers/upgrade-plans-lkr/upgrade-plans-lkr'; -import UpgradePlans from './drawers/upgrade-plans/upgrade-plans'; + import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useMediaQuery } from 'react-responsive'; import { useTranslation } from 'react-i18next'; -import { - toggleDrawer, - toggleUpgradeModal, -} from '@/features/admin-center/billing/billing.slice'; + import { fetchBillingInfo, fetchFreePlanSettings } from '@/features/admin-center/admin-center.slice'; -import RedeemCodeDrawer from './drawers/redeem-code-drawer/redeem-code-drawer'; + import CurrentPlanDetails from './current-plan-details/current-plan-details'; import AccountStorage from './account-storage/account-storage'; import { useAuthService } from '@/hooks/useAuth'; @@ -25,9 +21,7 @@ const CurrentBill: React.FC = () => { const dispatch = useAppDispatch(); const { t } = useTranslation('admin-center/current-bill'); const themeMode = useAppSelector(state => state.themeReducer.mode); - const { isUpgradeModalOpen } = useAppSelector(state => state.adminCenterReducer); const isTablet = useMediaQuery({ query: '(min-width: 1025px)' }); - const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; const currentSession = useAuthService().getCurrentSession(); useEffect(() => { @@ -46,42 +40,7 @@ const CurrentBill: React.FC = () => { const renderMobileView = () => (
- {t('currentPlanDetails')}} - extra={ -
- - dispatch(toggleUpgradeModal())} - width={1000} - centered - okButtonProps={{ hidden: true }} - cancelButtonProps={{ hidden: true }} - > - {browserTimeZone === 'Asia/Colombo' ? : } - -
- } - > -
-
- {t('cardBodyText01')} - {t('cardBodyText02')} -
- - -
-
+ diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx index 2e79c42c..fbd3ba43 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx @@ -97,30 +97,28 @@ const InfoTabFooter = () => { // mentions options const mentionsOptions = members?.map(member => ({ - value: member.id, + value: member.name, label: member.name, + key: member.id, })) ?? []; const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => { console.log('member', member); if (!member?.value || !member?.label) return; + + // Find the member ID from the members list using the name + const selectedMember = members.find(m => m.name === member.value); + if (!selectedMember) return; + + // Add to selected members if not already present setSelectedMembers(prev => - prev.some(mention => mention.team_member_id === member.value) + prev.some(mention => mention.team_member_id === selectedMember.id) ? prev - : [...prev, { team_member_id: member.value, name: member.label }] + : [...prev, { team_member_id: selectedMember.id!, name: selectedMember.name! }] ); - - setCommentValue(prev => { - const parts = prev.split('@'); - const lastPart = parts[parts.length - 1]; - const mentionText = member.label; - // Keep only the part before the @ and add the new mention - return prev.slice(0, prev.length - lastPart.length) + mentionText; - }); - }, []); + }, [members]); const handleCommentChange = useCallback((value: string) => { - // Only update the value without trying to replace mentions setCommentValue(value); setCharacterLength(value.trim().length); }, []); @@ -275,6 +273,12 @@ const InfoTabFooter = () => { maxLength={5000} onClick={() => setIsCommentBoxExpand(true)} onChange={e => setCharacterLength(e.length)} + prefix="@" + filterOption={(input, option) => { + if (!input) return true; + const optionLabel = (option as any)?.label || ''; + return optionLabel.toLowerCase().includes(input.toLowerCase()); + }} style={{ minHeight: 60, resize: 'none', @@ -371,7 +375,11 @@ const InfoTabFooter = () => { onSelect={option => memberSelectHandler(option as IMentionMemberSelectOption)} onChange={handleCommentChange} prefix="@" - split="" + filterOption={(input, option) => { + if (!input) return true; + const optionLabel = (option as any)?.label || ''; + return optionLabel.toLowerCase().includes(input.toLowerCase()); + }} style={{ minHeight: 100, maxHeight: 200, diff --git a/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx b/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx index 0099ae11..5ac8b726 100644 --- a/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx +++ b/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx @@ -58,7 +58,7 @@ import alertService from '@/services/alerts/alertService'; interface ITaskAssignee { id: string; - name: string; + name?: string; email?: string; avatar_url?: string; team_member_id: string; diff --git a/worklenz-frontend/src/features/navbar/navbar.tsx b/worklenz-frontend/src/features/navbar/navbar.tsx index 430318d3..0331b718 100644 --- a/worklenz-frontend/src/features/navbar/navbar.tsx +++ b/worklenz-frontend/src/features/navbar/navbar.tsx @@ -22,6 +22,7 @@ import { authApiService } from '@/api/auth/auth.api.service'; import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; import logger from '@/utils/errorLogger'; import TimerButton from './timers/timer-button'; +import HelpButton from './help/HelpButton'; const Navbar = () => { const [current, setCurrent] = useState('home'); @@ -145,7 +146,8 @@ const Navbar = () => { - + {/* */} + diff --git a/worklenz-frontend/src/features/navbar/timers/timer-button.tsx b/worklenz-frontend/src/features/navbar/timers/timer-button.tsx index b9e050f0..847e63e7 100644 --- a/worklenz-frontend/src/features/navbar/timers/timer-button.tsx +++ b/worklenz-frontend/src/features/navbar/timers/timer-button.tsx @@ -17,38 +17,70 @@ const TimerButton = () => { const [loading, setLoading] = useState(false); const [currentTimes, setCurrentTimes] = useState>({}); const [dropdownOpen, setDropdownOpen] = useState(false); + const [error, setError] = useState(null); const { t } = useTranslation('navbar'); const { token } = useToken(); const dispatch = useAppDispatch(); const { socket } = useSocket(); + const logError = (message: string, error?: any) => { + // Production-safe error logging + console.error(`[TimerButton] ${message}`, error); + setError(message); + }; + const fetchRunningTimers = useCallback(async () => { try { setLoading(true); + setError(null); + const response = await taskTimeLogsApiService.getRunningTimers(); - if (response.done) { - setRunningTimers(response.body || []); + + if (response && response.done) { + const timers = Array.isArray(response.body) ? response.body : []; + setRunningTimers(timers); + } else { + logError('Invalid response from getRunningTimers API'); + setRunningTimers([]); } } catch (error) { - console.error('Error fetching running timers:', error); + logError('Error fetching running timers', error); + setRunningTimers([]); } finally { setLoading(false); } }, []); - const updateCurrentTimes = () => { - const newTimes: Record = {}; - runningTimers.forEach(timer => { - const startTime = moment(timer.start_time); - const now = moment(); - const duration = moment.duration(now.diff(startTime)); - const hours = Math.floor(duration.asHours()); - const minutes = duration.minutes(); - const seconds = duration.seconds(); - newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - }); - setCurrentTimes(newTimes); - }; + const updateCurrentTimes = useCallback(() => { + try { + if (!Array.isArray(runningTimers) || runningTimers.length === 0) return; + + const newTimes: Record = {}; + runningTimers.forEach(timer => { + try { + if (!timer || !timer.task_id || !timer.start_time) return; + + const startTime = moment(timer.start_time); + if (!startTime.isValid()) { + 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(); + newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } catch (error) { + logError(`Error updating time for timer ${timer?.task_id}`, error); + } + }); + setCurrentTimes(newTimes); + } catch (error) { + logError('Error in updateCurrentTimes', error); + } + }, [runningTimers]); useEffect(() => { fetchRunningTimers(); @@ -67,209 +99,281 @@ const TimerButton = () => { const interval = setInterval(updateCurrentTimes, 1000); return () => clearInterval(interval); } - }, [runningTimers]); + }, [runningTimers, updateCurrentTimes]); // Listen for timer start/stop events and project updates to refresh the count useEffect(() => { - if (!socket) return; + if (!socket) { + logError('Socket not available'); + return; + } const handleTimerStart = (data: string) => { try { - const { id } = typeof data === 'string' ? JSON.parse(data) : data; + const parsed = typeof data === 'string' ? JSON.parse(data) : data; + const { id } = parsed || {}; if (id) { // Refresh the running timers list when a new timer is started fetchRunningTimers(); } } catch (error) { - console.error('Error parsing timer start event:', error); + logError('Error parsing timer start event', error); } }; const handleTimerStop = (data: string) => { try { - const { id } = typeof data === 'string' ? JSON.parse(data) : data; + const parsed = typeof data === 'string' ? JSON.parse(data) : data; + const { id } = parsed || {}; if (id) { // Refresh the running timers list when a timer is stopped fetchRunningTimers(); } } catch (error) { - console.error('Error parsing timer stop event:', error); + logError('Error parsing timer stop event', error); } }; const handleProjectUpdates = () => { - // Refresh timers when project updates are available - fetchRunningTimers(); + try { + // Refresh timers when project updates are available + fetchRunningTimers(); + } catch (error) { + logError('Error handling project updates', error); + } }; - socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); - socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); - socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); + try { + socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); + socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); + socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); - return () => { - socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); - socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); - socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); - }; + return () => { + try { + socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); + socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); + socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); + } catch (error) { + logError('Error cleaning up socket listeners', error); + } + }; + } catch (error) { + logError('Error setting up socket listeners', error); + } }, [socket, fetchRunningTimers]); const hasRunningTimers = () => { - return runningTimers.length > 0; + return Array.isArray(runningTimers) && runningTimers.length > 0; }; const timerCount = () => { - return runningTimers.length; + return Array.isArray(runningTimers) ? runningTimers.length : 0; }; const handleStopTimer = (taskId: string) => { - if (!socket) return; + if (!socket) { + logError('Socket not available for stopping timer'); + return; + } - socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId })); - dispatch(updateTaskTimeTracking({ taskId, timeTracking: null })); + if (!taskId) { + logError('Invalid task ID for stopping timer'); + return; + } + + try { + socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId })); + dispatch(updateTaskTimeTracking({ taskId, timeTracking: null })); + } catch (error) { + logError(`Error stopping timer for task ${taskId}`, error); + } }; - const dropdownContent = ( -
- {runningTimers.length === 0 ? ( -
- No running timers -
- ) : ( - ( - -
- - - {timer.task_name} - -
- {timer.project_name} -
- {timer.parent_task_name && ( - - Parent: {timer.parent_task_name} - - )} -
-
-
- - Started: {moment(timer.start_time).format('HH:mm')} - - - {currentTimes[timer.task_id] || '00:00:00'} - -
-
- -
-
-
-
- )} - /> - )} - {runningTimers.length > 0 && ( - <> - -
- - {runningTimers.length} timer{runningTimers.length !== 1 ? 's' : ''} running - + const renderDropdownContent = () => { + try { + if (error) { + return ( +
+ Error loading timers
- - )} -
- ); + ); + } - return ( - dropdownContent} - trigger={['click']} - placement="bottomRight" - open={dropdownOpen} - onOpenChange={(open) => { - setDropdownOpen(open); - if (open) { - fetchRunningTimers(); - } - }} - > - + return ( +
+ {!Array.isArray(runningTimers) || runningTimers.length === 0 ? ( +
+ No running timers +
+ ) : ( + { + if (!timer || !timer.task_id) return null; + + return ( + +
+ + + {timer.task_name || 'Unnamed Task'} + +
+ {timer.project_name || 'Unnamed Project'} +
+ {timer.parent_task_name && ( + + Parent: {timer.parent_task_name} + + )} +
+
+
+ + Started: {timer.start_time ? moment(timer.start_time).format('HH:mm') : '--:--'} + + + {currentTimes[timer.task_id] || '00:00:00'} + +
+
+ +
+
+
+
+ ); + }} + /> + )} + {hasRunningTimers() && ( + <> + +
+ + {timerCount()} timer{timerCount() !== 1 ? 's' : ''} running + +
+ + )} +
+ ); + } catch (error) { + logError('Error rendering dropdown content', error); + return ( +
+ Error rendering timers +
+ ); + } + }; + + const handleDropdownOpenChange = (open: boolean) => { + try { + setDropdownOpen(open); + if (open) { + fetchRunningTimers(); + } + } catch (error) { + logError('Error handling dropdown open change', error); + } + }; + + try { + return ( + renderDropdownContent()} + trigger={['click']} + placement="bottomRight" + open={dropdownOpen} + onOpenChange={handleDropdownOpenChange} + > + +
- {paddingTop > 0 &&
} - {virtualRows.map(virtualRow => { - const row = rows[virtualRow.index]; - return ( - -
- {row.getVisibleCells().map((cell, index) => ( -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- ))} -
- {expandedRows[row.id] && - row.original.sub_tasks?.map(subTask => ( -
- {columns.map((col, index) => ( -
- {flexRender(col.cell, { - getValue: () => subTask[col.id as keyof typeof subTask] ?? null, - row: { original: subTask } as Row, - column: col as Column, - table, - })} -
- ))} -
- ))} -
- ); - })} - {paddingBottom > 0 &&
} + {rows.map(row => ( + +
+ {row.getVisibleCells().map((cell, index) => ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ))} +
+ {expandedRows[row.id] && + row.original.sub_tasks?.map(subTask => ( +
+ {columns.map((col, index) => ( +
+ {flexRender(col.cell, { + getValue: () => subTask[col.id as keyof typeof subTask] ?? null, + row: { original: subTask } as Row, + column: col as Column, + table, + })} +
+ ))} +
+ ))} +
+ ))}
diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx index f3149767..26f01be7 100644 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx @@ -4,12 +4,12 @@ import { TaskType } from '@/types/task.types'; import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons'; import { colors } from '@/styles/colors'; import './task-list-table-wrapper.css'; -import TaskListTable from '../task-list-table-old/task-list-table-old'; +import TaskListTable from '../table-v2'; import { MenuProps } from 'antd/lib'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useTranslation } from 'react-i18next'; import { ITaskListGroup } from '@/types/tasks/taskList.types'; -import TaskListCustom from '../task-list-custom'; +import { columnList as defaultColumnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList'; type TaskListTableWrapperProps = { taskList: ITaskListGroup; @@ -37,6 +37,22 @@ const TaskListTableWrapper = ({ // localization const { t } = useTranslation('task-list-table'); + // Get column visibility from Redux + const columnVisibilityList = useAppSelector( + state => state.projectViewTaskListColumnsReducer.columnList + ); + + // Filter visible columns and format them for table-v2 + const visibleColumns = defaultColumnList + .filter(column => { + const visibilityConfig = columnVisibilityList.find(col => col.key === column.key); + return visibilityConfig?.isVisible ?? false; + }) + .map(column => ({ + key: column.key, + width: column.width, + })); + // function to handle toggle expand const handlToggleExpand = () => { setIsExpanded(!isExpanded); @@ -98,6 +114,14 @@ const TaskListTableWrapper = ({ }, ]; + const handleTaskSelect = (taskId: string) => { + console.log('Task selected:', taskId); + }; + + const handleTaskExpand = (taskId: string) => { + console.log('Task expanded:', taskId); + }; + return ( ), }, diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list.tsx index 727a510b..a5ad9f85 100644 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list.tsx +++ b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list.tsx @@ -6,9 +6,7 @@ import { ITaskListConfigV2, ITaskListGroup } from '@/types/tasks/taskList.types' import { useAppDispatch } from '@/hooks/useAppDispatch'; import { fetchTaskGroups } from '@/features/tasks/taskSlice'; import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; - -import { columnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList'; -import StatusGroupTables from '../taskList/statusTables/StatusGroupTables'; +import TaskListTableWrapper from './task-list-table-wrapper/task-list-table-wrapper'; const TaskList = () => { const dispatch = useAppDispatch(); @@ -31,6 +29,7 @@ const TaskList = () => { const onTaskExpand = (taskId: string) => { console.log('taskId:', taskId); }; + useEffect(() => { if (projectId) { const config: ITaskListConfigV2 = { @@ -54,9 +53,15 @@ const TaskList = () => { - {/* {taskGroups.map((group: ITaskListGroup) => ( - - ))} */} + {taskGroups.map((group: ITaskListGroup) => ( + + ))} ); diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx index 5b5d32ff..b2a17504 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx @@ -67,6 +67,7 @@ const ProjectViewHeader = () => { const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer); const [creatingTask, setCreatingTask] = useState(false); + const [subscriptionLoading, setSubscriptionLoading] = useState(false); const handleRefresh = () => { if (!projectId) return; @@ -98,17 +99,51 @@ const ProjectViewHeader = () => { }; const handleSubscribe = () => { - if (selectedProject?.id) { + if (!selectedProject?.id || !socket || subscriptionLoading) return; + + try { + setSubscriptionLoading(true); const newSubscriptionState = !selectedProject.subscribed; - dispatch(setProject({ ...selectedProject, subscribed: newSubscriptionState })); - - socket?.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), { + // Emit socket event first, then update state based on response + socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), { project_id: selectedProject.id, user_id: currentSession?.id, team_member_id: currentSession?.team_member_id, - mode: newSubscriptionState ? 1 : 0, + mode: newSubscriptionState ? 0 : 1, // Fixed: 0 for subscribe, 1 for unsubscribe }); + + // Listen for the response to confirm the operation + socket.once(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), (response) => { + try { + // Update the project state with the confirmed subscription status + dispatch(setProject({ + ...selectedProject, + subscribed: newSubscriptionState + })); + } catch (error) { + logger.error('Error handling project subscription response:', error); + // Revert optimistic update on error + dispatch(setProject({ + ...selectedProject, + subscribed: selectedProject.subscribed + })); + } finally { + setSubscriptionLoading(false); + } + }); + + // Add timeout in case socket response never comes + setTimeout(() => { + if (subscriptionLoading) { + setSubscriptionLoading(false); + logger.error('Project subscription timeout - no response from server'); + } + }, 5000); + + } catch (error) { + logger.error('Error updating project subscription:', error); + setSubscriptionLoading(false); } }; @@ -239,6 +274,7 @@ const ProjectViewHeader = () => {