Merge branch 'release/v2.0.3' of https://github.com/Worklenz/worklenz into feature/project-list-grouping

This commit is contained in:
chamikaJ
2025-06-13 13:02:57 +05:30
25 changed files with 1039 additions and 683 deletions

View File

@@ -145,7 +145,7 @@ BEGIN
SET progress_value = NULL, SET progress_value = NULL,
progress_mode = NULL progress_mode = NULL
WHERE project_id = _project_id WHERE project_id = _project_id
AND progress_mode = _old_mode; AND progress_mode::text::progress_mode_type = _old_mode;
END IF; END IF;
RETURN NEW; RETURN NEW;

View File

@@ -50,11 +50,16 @@ export default class TasksControllerBase extends WorklenzControllerBase {
task.progress = parseInt(task.progress_value); task.progress = parseInt(task.progress_value);
task.complete_ratio = 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 { else {
task.progress = task.total_minutes_spent && task.total_minutes // Only calculate progress based on time if time-based progress is enabled for the project
? ~~(task.total_minutes_spent / task.total_minutes * 100) if (task.project_use_time_progress && task.total_minutes_spent && task.total_minutes) {
: 0; // 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 // Set complete_ratio to match progress
task.complete_ratio = task.progress; task.complete_ratio = task.progress;

View File

@@ -610,6 +610,21 @@ export default class TasksControllerV2 extends TasksControllerBase {
return this.createTagList(result.rows); 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) { public static async checkUserAssignedToTask(taskId: string, userId: string, teamId: string) {
const q = ` const q = `
SELECT EXISTS( SELECT EXISTS(

View File

@@ -6,11 +6,11 @@ import { isProduction } from "../shared/utils";
const pgSession = require("connect-pg-simple")(session); const pgSession = require("connect-pg-simple")(session);
export default session({ export default session({
name: process.env.SESSION_NAME || "worklenz.sid", name: process.env.SESSION_NAME,
secret: process.env.SESSION_SECRET || "development-secret-key", secret: process.env.SESSION_SECRET || "development-secret-key",
proxy: true, proxy: false,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: true,
rolling: true, rolling: true,
store: new pgSession({ store: new pgSession({
pool: db.pool, pool: db.pool,
@@ -18,9 +18,10 @@ export default session({
}), }),
cookie: { cookie: {
path: "/", path: "/",
secure: isProduction(), // Use secure cookies in production // secure: isProduction(),
httpOnly: true, // httpOnly: isProduction(),
sameSite: "lax", // Standard setting for same-origin requests // sameSite: "none",
// domain: isProduction() ? ".worklenz.com" : undefined,
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
} }
}); });

View File

@@ -19,7 +19,8 @@ export async function on_project_subscriber_change(_io: Server, socket: Socket,
const isSubscribe = data.mode == 0; const isSubscribe = data.mode == 0;
const q = isSubscribe const q = isSubscribe
? `INSERT INTO project_subscribers (user_id, project_id, team_member_id) ? `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 : `DELETE
FROM project_subscribers FROM project_subscribers
WHERE user_id = $1 WHERE user_id = $1
@@ -27,7 +28,7 @@ export async function on_project_subscriber_change(_io: Server, socket: Socket,
AND team_member_id = $3;`; AND team_member_id = $3;`;
await db.query(q, [data.user_id, data.project_id, data.team_member_id]); 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); socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), subscribers);
return; return;

View File

@@ -47,5 +47,6 @@
"weightedProgress": "Weighted Progress", "weightedProgress": "Weighted Progress",
"weightedProgressTooltip": "Calculate progress based on subtask weights", "weightedProgressTooltip": "Calculate progress based on subtask weights",
"timeProgress": "Time-based Progress", "timeProgress": "Time-based Progress",
"timeProgressTooltip": "Calculate progress based on estimated time" "timeProgressTooltip": "Calculate progress based on estimated time",
"enterProjectKey": "Enter project key"
} }

View File

@@ -47,5 +47,6 @@
"weightedProgress": "Progreso Ponderado", "weightedProgress": "Progreso Ponderado",
"weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas", "weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas",
"timeProgress": "Progreso Basado en Tiempo", "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"
} }

View File

@@ -47,5 +47,6 @@
"weightedProgress": "Progresso Ponderado", "weightedProgress": "Progresso Ponderado",
"weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas", "weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas",
"timeProgress": "Progresso Baseado em Tempo", "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"
} }

View File

@@ -68,7 +68,7 @@ const AccountStorage = ({ themeMode }: IAccountStorageProps) => {
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ padding: '0 16px' }}> <div style={{ padding: '0 16px' }}>
<Progress <Progress
percent={billingInfo?.usedPercentage ?? 0} percent={billingInfo?.used_percent ?? 0}
type="circle" type="circle"
format={percent => <span style={{ fontSize: '13px' }}>{percent}% Used</span>} format={percent => <span style={{ fontSize: '13px' }}>{percent}% Used</span>}
/> />

View File

@@ -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 React, { useEffect } from 'react';
import './current-bill.css'; import './current-bill.css';
import { InfoCircleTwoTone } from '@ant-design/icons'; import { InfoCircleTwoTone } from '@ant-design/icons';
import ChargesTable from './billing-tables/charges-table'; import ChargesTable from './billing-tables/charges-table';
import InvoicesTable from './billing-tables/invoices-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 { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useMediaQuery } from 'react-responsive'; import { useMediaQuery } from 'react-responsive';
import { useTranslation } from 'react-i18next'; 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 { 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 CurrentPlanDetails from './current-plan-details/current-plan-details';
import AccountStorage from './account-storage/account-storage'; import AccountStorage from './account-storage/account-storage';
import { useAuthService } from '@/hooks/useAuth'; import { useAuthService } from '@/hooks/useAuth';
@@ -25,9 +21,7 @@ const CurrentBill: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useTranslation('admin-center/current-bill'); const { t } = useTranslation('admin-center/current-bill');
const themeMode = useAppSelector(state => state.themeReducer.mode); const themeMode = useAppSelector(state => state.themeReducer.mode);
const { isUpgradeModalOpen } = useAppSelector(state => state.adminCenterReducer);
const isTablet = useMediaQuery({ query: '(min-width: 1025px)' }); const isTablet = useMediaQuery({ query: '(min-width: 1025px)' });
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const currentSession = useAuthService().getCurrentSession(); const currentSession = useAuthService().getCurrentSession();
useEffect(() => { useEffect(() => {
@@ -46,42 +40,7 @@ const CurrentBill: React.FC = () => {
const renderMobileView = () => ( const renderMobileView = () => (
<div> <div>
<Col span={24}> <Col span={24}>
<Card <CurrentPlanDetails />
style={{ height: '100%' }}
title={<span style={titleStyle}>{t('currentPlanDetails')}</span>}
extra={
<div style={{ marginTop: '8px', marginRight: '8px' }}>
<Button type="primary" onClick={() => dispatch(toggleUpgradeModal())}>
{t('upgradePlan')}
</Button>
<Modal
open={isUpgradeModalOpen}
onCancel={() => dispatch(toggleUpgradeModal())}
width={1000}
centered
okButtonProps={{ hidden: true }}
cancelButtonProps={{ hidden: true }}
>
{browserTimeZone === 'Asia/Colombo' ? <UpgradePlansLKR /> : <UpgradePlans />}
</Modal>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', width: '50%', padding: '0 12px' }}>
<div style={{ marginBottom: '14px' }}>
<Typography.Text style={{ fontWeight: 700 }}>{t('cardBodyText01')}</Typography.Text>
<Typography.Text>{t('cardBodyText02')}</Typography.Text>
</div>
<Button
type="link"
style={{ margin: 0, padding: 0, width: '90px' }}
onClick={() => dispatch(toggleDrawer())}
>
{t('redeemCode')}
</Button>
<RedeemCodeDrawer />
</div>
</Card>
</Col> </Col>
<Col span={24} style={{ marginTop: '1.5rem' }}> <Col span={24} style={{ marginTop: '1.5rem' }}>

View File

@@ -97,30 +97,28 @@ const InfoTabFooter = () => {
// mentions options // mentions options
const mentionsOptions = const mentionsOptions =
members?.map(member => ({ members?.map(member => ({
value: member.id, value: member.name,
label: member.name, label: member.name,
key: member.id,
})) ?? []; })) ?? [];
const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => { const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => {
console.log('member', member); console.log('member', member);
if (!member?.value || !member?.label) return; 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 => setSelectedMembers(prev =>
prev.some(mention => mention.team_member_id === member.value) prev.some(mention => mention.team_member_id === selectedMember.id)
? prev ? prev
: [...prev, { team_member_id: member.value, name: member.label }] : [...prev, { team_member_id: selectedMember.id!, name: selectedMember.name! }]
); );
}, [members]);
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;
});
}, []);
const handleCommentChange = useCallback((value: string) => { const handleCommentChange = useCallback((value: string) => {
// Only update the value without trying to replace mentions
setCommentValue(value); setCommentValue(value);
setCharacterLength(value.trim().length); setCharacterLength(value.trim().length);
}, []); }, []);
@@ -275,6 +273,12 @@ const InfoTabFooter = () => {
maxLength={5000} maxLength={5000}
onClick={() => setIsCommentBoxExpand(true)} onClick={() => setIsCommentBoxExpand(true)}
onChange={e => setCharacterLength(e.length)} 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={{ style={{
minHeight: 60, minHeight: 60,
resize: 'none', resize: 'none',
@@ -371,7 +375,11 @@ const InfoTabFooter = () => {
onSelect={option => memberSelectHandler(option as IMentionMemberSelectOption)} onSelect={option => memberSelectHandler(option as IMentionMemberSelectOption)}
onChange={handleCommentChange} onChange={handleCommentChange}
prefix="@" prefix="@"
split="" filterOption={(input, option) => {
if (!input) return true;
const optionLabel = (option as any)?.label || '';
return optionLabel.toLowerCase().includes(input.toLowerCase());
}}
style={{ style={{
minHeight: 100, minHeight: 100,
maxHeight: 200, maxHeight: 200,

View File

@@ -58,7 +58,7 @@ import alertService from '@/services/alerts/alertService';
interface ITaskAssignee { interface ITaskAssignee {
id: string; id: string;
name: string; name?: string;
email?: string; email?: string;
avatar_url?: string; avatar_url?: string;
team_member_id: string; team_member_id: string;

View File

@@ -22,6 +22,7 @@ import { authApiService } from '@/api/auth/auth.api.service';
import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
import logger from '@/utils/errorLogger'; import logger from '@/utils/errorLogger';
import TimerButton from './timers/timer-button'; import TimerButton from './timers/timer-button';
import HelpButton from './help/HelpButton';
const Navbar = () => { const Navbar = () => {
const [current, setCurrent] = useState<string>('home'); const [current, setCurrent] = useState<string>('home');
@@ -145,7 +146,8 @@ const Navbar = () => {
<Flex align="center"> <Flex align="center">
<SwitchTeamButton /> <SwitchTeamButton />
<NotificationButton /> <NotificationButton />
<TimerButton /> {/* <TimerButton /> */}
<HelpButton />
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} /> <ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
</Flex> </Flex>
</Flex> </Flex>

View File

@@ -17,38 +17,70 @@ const TimerButton = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [currentTimes, setCurrentTimes] = useState<Record<string, string>>({}); const [currentTimes, setCurrentTimes] = useState<Record<string, string>>({});
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const { t } = useTranslation('navbar'); const { t } = useTranslation('navbar');
const { token } = useToken(); const { token } = useToken();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { socket } = useSocket(); const { socket } = useSocket();
const logError = (message: string, error?: any) => {
// Production-safe error logging
console.error(`[TimerButton] ${message}`, error);
setError(message);
};
const fetchRunningTimers = useCallback(async () => { const fetchRunningTimers = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
setError(null);
const response = await taskTimeLogsApiService.getRunningTimers(); 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) { } catch (error) {
console.error('Error fetching running timers:', error); logError('Error fetching running timers', error);
setRunningTimers([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, []);
const updateCurrentTimes = () => { const updateCurrentTimes = useCallback(() => {
const newTimes: Record<string, string> = {}; try {
runningTimers.forEach(timer => { if (!Array.isArray(runningTimers) || runningTimers.length === 0) return;
const startTime = moment(timer.start_time);
const now = moment(); const newTimes: Record<string, string> = {};
const duration = moment.duration(now.diff(startTime)); runningTimers.forEach(timer => {
const hours = Math.floor(duration.asHours()); try {
const minutes = duration.minutes(); if (!timer || !timer.task_id || !timer.start_time) return;
const seconds = duration.seconds();
newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; const startTime = moment(timer.start_time);
}); if (!startTime.isValid()) {
setCurrentTimes(newTimes); 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(() => { useEffect(() => {
fetchRunningTimers(); fetchRunningTimers();
@@ -67,209 +99,281 @@ const TimerButton = () => {
const interval = setInterval(updateCurrentTimes, 1000); const interval = setInterval(updateCurrentTimes, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
} }
}, [runningTimers]); }, [runningTimers, updateCurrentTimes]);
// Listen for timer start/stop events and project updates to refresh the count // Listen for timer start/stop events and project updates to refresh the count
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) {
logError('Socket not available');
return;
}
const handleTimerStart = (data: string) => { const handleTimerStart = (data: string) => {
try { try {
const { id } = typeof data === 'string' ? JSON.parse(data) : data; const parsed = typeof data === 'string' ? JSON.parse(data) : data;
const { id } = parsed || {};
if (id) { if (id) {
// Refresh the running timers list when a new timer is started // Refresh the running timers list when a new timer is started
fetchRunningTimers(); fetchRunningTimers();
} }
} catch (error) { } catch (error) {
console.error('Error parsing timer start event:', error); logError('Error parsing timer start event', error);
} }
}; };
const handleTimerStop = (data: string) => { const handleTimerStop = (data: string) => {
try { try {
const { id } = typeof data === 'string' ? JSON.parse(data) : data; const parsed = typeof data === 'string' ? JSON.parse(data) : data;
const { id } = parsed || {};
if (id) { if (id) {
// Refresh the running timers list when a timer is stopped // Refresh the running timers list when a timer is stopped
fetchRunningTimers(); fetchRunningTimers();
} }
} catch (error) { } catch (error) {
console.error('Error parsing timer stop event:', error); logError('Error parsing timer stop event', error);
} }
}; };
const handleProjectUpdates = () => { const handleProjectUpdates = () => {
// Refresh timers when project updates are available try {
fetchRunningTimers(); // Refresh timers when project updates are available
fetchRunningTimers();
} catch (error) {
logError('Error handling project updates', error);
}
}; };
socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); try {
socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
return () => { return () => {
socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); try {
socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); 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]); }, [socket, fetchRunningTimers]);
const hasRunningTimers = () => { const hasRunningTimers = () => {
return runningTimers.length > 0; return Array.isArray(runningTimers) && runningTimers.length > 0;
}; };
const timerCount = () => { const timerCount = () => {
return runningTimers.length; return Array.isArray(runningTimers) ? runningTimers.length : 0;
}; };
const handleStopTimer = (taskId: string) => { 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 })); if (!taskId) {
dispatch(updateTaskTimeTracking({ taskId, timeTracking: null })); 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 = ( const renderDropdownContent = () => {
<div try {
style={{ if (error) {
width: 350, return (
maxHeight: 400, <div style={{ padding: 16, textAlign: 'center', width: 350 }}>
overflow: 'auto', <Text type="danger">Error loading timers</Text>
backgroundColor: token.colorBgElevated,
borderRadius: token.borderRadius,
boxShadow: token.boxShadowSecondary,
border: `1px solid ${token.colorBorderSecondary}`
}}
>
{runningTimers.length === 0 ? (
<div style={{ padding: 16, textAlign: 'center' }}>
<Text type="secondary">No running timers</Text>
</div>
) : (
<List
dataSource={runningTimers}
renderItem={(timer) => (
<List.Item
style={{
padding: '12px 16px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
backgroundColor: 'transparent'
}}
>
<div style={{ width: '100%' }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text strong style={{ fontSize: 14, color: token.colorText }}>
{timer.task_name}
</Text>
<div style={{
display: 'inline-block',
backgroundColor: token.colorPrimaryBg,
color: token.colorPrimary,
padding: '2px 8px',
borderRadius: token.borderRadiusSM,
fontSize: 11,
fontWeight: 500,
marginTop: 2
}}>
{timer.project_name}
</div>
{timer.parent_task_name && (
<Text type="secondary" style={{ fontSize: 11 }}>
Parent: {timer.parent_task_name}
</Text>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 11 }}>
Started: {moment(timer.start_time).format('HH:mm')}
</Text>
<Text
strong
style={{
fontSize: 14,
color: token.colorPrimary,
fontFamily: 'monospace'
}}
>
{currentTimes[timer.task_id] || '00:00:00'}
</Text>
</div>
</div>
<Button
size="small"
icon={<StopOutlined />}
onClick={(e) => {
e.stopPropagation();
handleStopTimer(timer.task_id);
}}
style={{
backgroundColor: token.colorErrorBg,
borderColor: token.colorError,
color: token.colorError,
fontWeight: 500
}}
>
Stop
</Button>
</div>
</Space>
</div>
</List.Item>
)}
/>
)}
{runningTimers.length > 0 && (
<>
<Divider style={{ margin: 0, borderColor: token.colorBorderSecondary }} />
<div
style={{
padding: '8px 16px',
textAlign: 'center',
backgroundColor: token.colorFillQuaternary,
borderBottomLeftRadius: token.borderRadius,
borderBottomRightRadius: token.borderRadius
}}
>
<Text type="secondary" style={{ fontSize: 11 }}>
{runningTimers.length} timer{runningTimers.length !== 1 ? 's' : ''} running
</Text>
</div> </div>
</> );
)} }
</div>
);
return ( return (
<Dropdown <div
popupRender={() => dropdownContent} style={{
trigger={['click']} width: 350,
placement="bottomRight" maxHeight: 400,
open={dropdownOpen} overflow: 'auto',
onOpenChange={(open) => { backgroundColor: token.colorBgElevated,
setDropdownOpen(open); borderRadius: token.borderRadius,
if (open) { boxShadow: token.boxShadowSecondary,
fetchRunningTimers(); border: `1px solid ${token.colorBorderSecondary}`
} }}
}} >
> {!Array.isArray(runningTimers) || runningTimers.length === 0 ? (
<Tooltip title="Running Timers"> <div style={{ padding: 16, textAlign: 'center' }}>
<Text type="secondary">No running timers</Text>
</div>
) : (
<List
dataSource={runningTimers}
renderItem={(timer) => {
if (!timer || !timer.task_id) return null;
return (
<List.Item
style={{
padding: '12px 16px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
backgroundColor: 'transparent'
}}
>
<div style={{ width: '100%' }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text strong style={{ fontSize: 14, color: token.colorText }}>
{timer.task_name || 'Unnamed Task'}
</Text>
<div style={{
display: 'inline-block',
backgroundColor: token.colorPrimaryBg,
color: token.colorPrimary,
padding: '2px 8px',
borderRadius: token.borderRadiusSM,
fontSize: 11,
fontWeight: 500,
marginTop: 2
}}>
{timer.project_name || 'Unnamed Project'}
</div>
{timer.parent_task_name && (
<Text type="secondary" style={{ fontSize: 11 }}>
Parent: {timer.parent_task_name}
</Text>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 11 }}>
Started: {timer.start_time ? moment(timer.start_time).format('HH:mm') : '--:--'}
</Text>
<Text
strong
style={{
fontSize: 14,
color: token.colorPrimary,
fontFamily: 'monospace'
}}
>
{currentTimes[timer.task_id] || '00:00:00'}
</Text>
</div>
</div>
<Button
size="small"
icon={<StopOutlined />}
onClick={(e) => {
e.stopPropagation();
handleStopTimer(timer.task_id);
}}
style={{
backgroundColor: token.colorErrorBg,
borderColor: token.colorError,
color: token.colorError,
fontWeight: 500
}}
>
Stop
</Button>
</div>
</Space>
</div>
</List.Item>
);
}}
/>
)}
{hasRunningTimers() && (
<>
<Divider style={{ margin: 0, borderColor: token.colorBorderSecondary }} />
<div
style={{
padding: '8px 16px',
textAlign: 'center',
backgroundColor: token.colorFillQuaternary,
borderBottomLeftRadius: token.borderRadius,
borderBottomRightRadius: token.borderRadius
}}
>
<Text type="secondary" style={{ fontSize: 11 }}>
{timerCount()} timer{timerCount() !== 1 ? 's' : ''} running
</Text>
</div>
</>
)}
</div>
);
} catch (error) {
logError('Error rendering dropdown content', error);
return (
<div style={{ padding: 16, textAlign: 'center', width: 350 }}>
<Text type="danger">Error rendering timers</Text>
</div>
);
}
};
const handleDropdownOpenChange = (open: boolean) => {
try {
setDropdownOpen(open);
if (open) {
fetchRunningTimers();
}
} catch (error) {
logError('Error handling dropdown open change', error);
}
};
try {
return (
<Dropdown
popupRender={() => renderDropdownContent()}
trigger={['click']}
placement="bottomRight"
open={dropdownOpen}
onOpenChange={handleDropdownOpenChange}
>
<Tooltip title="Running Timers">
<Button
style={{ height: '62px', width: '60px' }}
type="text"
icon={
hasRunningTimers() ? (
<Badge count={timerCount()}>
<ClockCircleOutlined style={{ fontSize: 20 }} />
</Badge>
) : (
<ClockCircleOutlined style={{ fontSize: 20 }} />
)
}
loading={loading}
/>
</Tooltip>
</Dropdown>
);
} catch (error) {
logError('Error rendering TimerButton', error);
return (
<Tooltip title="Timer Error">
<Button <Button
style={{ height: '62px', width: '60px' }} style={{ height: '62px', width: '60px' }}
type="text" type="text"
icon={ icon={<ClockCircleOutlined style={{ fontSize: 20 }} />}
hasRunningTimers() ? ( disabled
<Badge count={timerCount()}>
<ClockCircleOutlined style={{ fontSize: 20 }} />
</Badge>
) : (
<ClockCircleOutlined style={{ fontSize: 20 }} />
)
}
loading={loading}
/> />
</Tooltip> </Tooltip>
</Dropdown> );
); }
}; };
export default TimerButton; export default TimerButton;

View File

@@ -1,146 +0,0 @@
import { useMemo, useCallback } from 'react';
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
KeyboardSensor,
TouchSensor,
} from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { updateTaskStatus } from '@/features/tasks/tasks.slice';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
export const useTaskDragAndDrop = () => {
const dispatch = useAppDispatch();
// Memoize the selector to prevent unnecessary rerenders
const taskGroups = useAppSelector(state => state.taskReducer.taskGroups);
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
// Memoize sensors configuration for better performance
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
const handleDragStart = useCallback((event: DragStartEvent) => {
// Add visual feedback for drag start
const { active } = event;
if (active) {
document.body.style.cursor = 'grabbing';
}
}, []);
const handleDragOver = useCallback((event: DragOverEvent) => {
// Handle drag over logic if needed
// This can be used for visual feedback during drag
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
// Reset cursor
document.body.style.cursor = '';
const { active, over } = event;
if (!active || !over || !taskGroups) {
return;
}
try {
const activeId = active.id as string;
const overId = over.id as string;
// Find the task being dragged
let draggedTask: IProjectTask | null = null;
let sourceGroupId: string | null = null;
for (const group of taskGroups) {
const task = group.tasks?.find((t: IProjectTask) => t.id === activeId);
if (task) {
draggedTask = task;
sourceGroupId = group.id;
break;
}
}
if (!draggedTask || !sourceGroupId) {
console.warn('Could not find dragged task');
return;
}
// Determine target group
let targetGroupId: string | null = null;
// Check if dropped on a group container
const targetGroup = taskGroups.find((group: ITaskListGroup) => group.id === overId);
if (targetGroup) {
targetGroupId = targetGroup.id;
} else {
// Check if dropped on another task
for (const group of taskGroups) {
const targetTask = group.tasks?.find((t: IProjectTask) => t.id === overId);
if (targetTask) {
targetGroupId = group.id;
break;
}
}
}
if (!targetGroupId || targetGroupId === sourceGroupId) {
return; // No change needed
}
// Update task status based on group change
const targetGroupData = taskGroups.find((group: ITaskListGroup) => group.id === targetGroupId);
if (targetGroupData && groupBy === 'status') {
const updatePayload: any = {
task_id: draggedTask.id,
status_id: targetGroupData.id,
};
if (draggedTask.parent_task_id) {
updatePayload.parent_task = draggedTask.parent_task_id;
}
dispatch(updateTaskStatus(updatePayload));
}
} catch (error) {
console.error('Error handling drag end:', error);
}
},
[taskGroups, groupBy, dispatch]
);
// Memoize the drag and drop configuration
const dragAndDropConfig = useMemo(
() => ({
sensors,
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragEnd: handleDragEnd,
}),
[sensors, handleDragStart, handleDragOver, handleDragEnd]
);
return dragAndDropConfig;
};

View File

@@ -57,20 +57,31 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
}, },
]; ];
const calculateEndDate = (dueDate: string): Date | undefined => { const calculateEndDate = (dueDate: string): string | undefined => {
const today = new Date(); const today = new Date();
let targetDate: Date;
switch (dueDate) { switch (dueDate) {
case 'Today': case 'Today':
return today; targetDate = new Date(today);
break;
case 'Tomorrow': case 'Tomorrow':
return new Date(today.setDate(today.getDate() + 1)); targetDate = new Date(today);
targetDate.setDate(today.getDate() + 1);
break;
case 'Next Week': case 'Next Week':
return new Date(today.setDate(today.getDate() + 7)); targetDate = new Date(today);
targetDate.setDate(today.getDate() + 7);
break;
case 'Next Month': case 'Next Month':
return new Date(today.setMonth(today.getMonth() + 1)); targetDate = new Date(today);
targetDate.setMonth(today.getMonth() + 1);
break;
default: default:
return undefined; return undefined;
} }
return targetDate.toISOString().split('T')[0]; // Returns YYYY-MM-DD format
}; };
const projectOptions = [ const projectOptions = [
@@ -82,12 +93,16 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
]; ];
const handleTaskSubmit = (values: { name: string; project: string; dueDate: string }) => { const handleTaskSubmit = (values: { name: string; project: string; dueDate: string }) => {
const newTask: IHomeTaskCreateRequest = { const endDate = calendarView
? homeTasksConfig.selected_date?.format('YYYY-MM-DD')
: calculateEndDate(values.dueDate);
const newTask = {
name: values.name, name: values.name,
project_id: values.project, project_id: values.project,
reporter_id: currentSession?.id, reporter_id: currentSession?.id,
team_id: currentSession?.team_id, team_id: currentSession?.team_id,
end_date: (calendarView ? homeTasksConfig.selected_date?.format('YYYY-MM-DD') : calculateEndDate(values.dueDate)), end_date: endDate || new Date().toISOString().split('T')[0], // Fallback to today if undefined
}; };
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(newTask)); socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(newTask));

View File

@@ -1,12 +1,110 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { Checkbox, Flex, Tag, Tooltip } from 'antd'; import { Checkbox, Flex, Tag, Tooltip } from 'antd';
import { useVirtualizer } from '@tanstack/react-virtual'; import { HolderOutlined } from '@ant-design/icons';
import {
DndContext,
DragEndEvent,
DragStartEvent,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
KeyboardSensor,
TouchSensor,
UniqueIdentifier,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { ITaskListGroup } from '@/types/tasks/taskList.types'; import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { SocketEvents } from '@/shared/socket-events';
import { reorderTasks } from '@/features/tasks/tasks.slice';
import { evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
// Draggable Row Component
interface DraggableRowProps {
task: IProjectTask;
visibleColumns: Array<{ key: string; width: number }>;
renderCell: (columnKey: string | number, task: IProjectTask, isSubtask?: boolean) => React.ReactNode;
hoverRow: string | null;
onRowHover: (taskId: string | null) => void;
isSubtask?: boolean;
}
const DraggableRow = ({
task,
visibleColumns,
renderCell,
hoverRow,
onRowHover,
isSubtask = false
}: DraggableRowProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: task.id as UniqueIdentifier,
data: {
type: 'task',
task,
},
disabled: isSubtask, // Disable drag for subtasks
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 1000 : 'auto',
};
return (
<div
ref={setNodeRef}
style={style}
className="flex w-full border-b hover:bg-gray-50 dark:hover:bg-gray-800"
onMouseEnter={() => onRowHover(task.id)}
onMouseLeave={() => onRowHover(null)}
>
<div className="sticky left-0 z-10 w-8 flex items-center justify-center">
{!isSubtask && (
<div {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing">
<HolderOutlined />
</div>
)}
</div>
{visibleColumns.map(column => (
<div
key={column.key}
className={`flex items-center px-3 border-r ${
hoverRow === task.id ? 'bg-gray-50 dark:bg-gray-800' : ''
}`}
style={{ width: column.width }}
>
{renderCell(column.key, task, isSubtask)}
</div>
))}
</div>
);
};
const TaskListTable = ({ const TaskListTable = ({
taskListGroup, taskListGroup,
tableId,
visibleColumns, visibleColumns,
onTaskSelect, onTaskSelect,
onTaskExpand, onTaskExpand,
@@ -18,11 +116,38 @@ const TaskListTable = ({
onTaskExpand?: (taskId: string) => void; onTaskExpand?: (taskId: string) => void;
}) => { }) => {
const [hoverRow, setHoverRow] = useState<string | null>(null); const [hoverRow, setHoverRow] = useState<string | null>(null);
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
const tableRef = useRef<HTMLDivElement | null>(null); const tableRef = useRef<HTMLDivElement | null>(null);
const parentRef = useRef<HTMLDivElement | null>(null); const parentRef = useRef<HTMLDivElement | null>(null);
const themeMode = useAppSelector(state => state.themeReducer.mode); const themeMode = useAppSelector(state => state.themeReducer.mode);
const { projectId } = useAppSelector(state => state.projectReducer);
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
const dispatch = useAppDispatch();
const { socket } = useSocket();
const currentSession = useAuthService().getCurrentSession();
const { trackMixpanelEvent } = useMixpanelTracking();
// Memoize all tasks including subtasks for virtualization // Configure sensors for drag and drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
// Memoize all tasks including subtasks
const flattenedTasks = useMemo(() => { const flattenedTasks = useMemo(() => {
return taskListGroup.tasks.reduce((acc: IProjectTask[], task: IProjectTask) => { return taskListGroup.tasks.reduce((acc: IProjectTask[], task: IProjectTask) => {
acc.push(task); acc.push(task);
@@ -33,13 +158,10 @@ const TaskListTable = ({
}, []); }, []);
}, [taskListGroup.tasks]); }, [taskListGroup.tasks]);
// Virtual row renderer // Get only main tasks for sortable context (exclude subtasks)
const rowVirtualizer = useVirtualizer({ const mainTasks = useMemo(() => {
count: flattenedTasks.length, return taskListGroup.tasks.filter(task => !task.isSubtask);
getScrollElement: () => parentRef.current, }, [taskListGroup.tasks]);
estimateSize: () => 42, // row height
overscan: 5,
});
// Memoize cell render functions // Memoize cell render functions
const renderCell = useCallback( const renderCell = useCallback(
@@ -54,7 +176,7 @@ const TaskListTable = ({
); );
}, },
task: () => ( task: () => (
<Flex align="center" className="pl-2"> <Flex align="center" className={isSubtask ? "pl-6" : "pl-2"}>
{task.name} {task.name}
</Flex> </Flex>
), ),
@@ -66,6 +188,77 @@ const TaskListTable = ({
[] []
); );
// Handle drag start
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id);
document.body.style.cursor = 'grabbing';
}, []);
// Handle drag end with socket integration
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
document.body.style.cursor = '';
if (!over || active.id === over.id) {
return;
}
const activeIndex = mainTasks.findIndex(task => task.id === active.id);
const overIndex = mainTasks.findIndex(task => task.id === over.id);
if (activeIndex !== -1 && overIndex !== -1) {
const activeTask = mainTasks[activeIndex];
const overTask = mainTasks[overIndex];
// Create updated task arrays
const updatedTasks = [...mainTasks];
updatedTasks.splice(activeIndex, 1);
updatedTasks.splice(overIndex, 0, activeTask);
// Dispatch Redux action for optimistic update
dispatch(reorderTasks({
activeGroupId: tableId,
overGroupId: tableId,
fromIndex: activeIndex,
toIndex: overIndex,
task: activeTask,
updatedSourceTasks: updatedTasks,
updatedTargetTasks: updatedTasks,
}));
// Emit socket event for backend persistence
if (socket && projectId && currentSession?.team_id) {
const toPos = overTask?.sort_order || mainTasks[mainTasks.length - 1]?.sort_order || -1;
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
project_id: projectId,
from_index: activeTask.sort_order,
to_index: toPos,
to_last_index: overIndex === mainTasks.length - 1,
from_group: tableId,
to_group: tableId,
group_by: groupBy,
task: activeTask,
team_id: currentSession.team_id,
});
// Track analytics event
trackMixpanelEvent(evt_project_task_list_drag_and_move);
}
}
}, [
mainTasks,
tableId,
dispatch,
socket,
projectId,
currentSession?.team_id,
groupBy,
trackMixpanelEvent
]);
// Memoize header rendering // Memoize header rendering
const TableHeader = useMemo( const TableHeader = useMemo(
() => ( () => (
@@ -94,48 +287,55 @@ const TaskListTable = ({
target.classList.toggle('show-shadow', hasHorizontalShadow); target.classList.toggle('show-shadow', hasHorizontalShadow);
}, []); }, []);
return ( // Find active task for drag overlay
<div ref={parentRef} className="h-[400px] overflow-auto" onScroll={handleScroll}> const activeTask = activeId ? flattenedTasks.find(task => task.id === activeId) : null;
{TableHeader}
<div return (
ref={tableRef} <DndContext
style={{ sensors={sensors}
height: `${rowVirtualizer.getTotalSize()}px`, onDragStart={handleDragStart}
width: '100%', onDragEnd={handleDragEnd}
position: 'relative', >
<div ref={parentRef} className="h-[400px] overflow-auto" onScroll={handleScroll}>
{TableHeader}
<SortableContext items={mainTasks.map(task => task.id)} strategy={verticalListSortingStrategy}>
<div ref={tableRef} style={{ width: '100%' }}>
{flattenedTasks.map((task, index) => (
<DraggableRow
key={task.id}
task={task}
visibleColumns={visibleColumns}
renderCell={renderCell}
hoverRow={hoverRow}
onRowHover={setHoverRow}
isSubtask={task.isSubtask}
/>
))}
</div>
</SortableContext>
</div>
<DragOverlay
dropAnimation={{
duration: 200,
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}} }}
> >
{rowVirtualizer.getVirtualItems().map(virtualRow => { {activeTask && (
const task = flattenedTasks[virtualRow.index]; <div className="bg-white dark:bg-gray-800 shadow-lg rounded border">
return ( <DraggableRow
<div task={activeTask}
key={task.id} visibleColumns={visibleColumns}
className="absolute top-0 left-0 flex w-full border-b hover:bg-gray-50 dark:hover:bg-gray-800" renderCell={renderCell}
style={{ hoverRow={null}
height: 42, onRowHover={() => {}}
transform: `translateY(${virtualRow.start}px)`, isSubtask={activeTask.isSubtask}
}} />
> </div>
<div className="sticky left-0 z-10 w-8 flex items-center justify-center"> )}
{/* <Checkbox checked={task.selected} /> */} </DragOverlay>
</div> </DndContext>
{visibleColumns.map(column => (
<div
key={column.key}
className={`flex items-center px-3 border-r ${
hoverRow === task.id ? 'bg-gray-50 dark:bg-gray-800' : ''
}`}
style={{ width: column.width }}
>
{renderCell(column.key, task, task.is_sub_task)}
</div>
))}
</div>
);
})}
</div>
</div>
); );
}; };

View File

@@ -10,7 +10,6 @@ import {
Row, Row,
Column, Column,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import React from 'react'; import React from 'react';
@@ -78,19 +77,6 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
const { rows } = table.getRowModel(); const { rows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 50,
overscan: 20,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
const paddingBottom =
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
const columnToggleItems = columns.map(column => ({ const columnToggleItems = columns.map(column => ({
key: column.id as string, key: column.id as string,
label: ( label: (
@@ -125,6 +111,7 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
overflowX: 'auto', overflowX: 'auto',
overflowY: 'auto',
maxHeight: '100%', maxHeight: '100%',
}} }}
> >
@@ -161,80 +148,75 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
))} ))}
</div> </div>
<div className="table-body"> <div className="table-body">
{paddingTop > 0 && <div style={{ height: `${paddingTop}px` }} />} {rows.map(row => (
{virtualRows.map(virtualRow => { <React.Fragment key={row.id}>
const row = rows[virtualRow.index]; <div
return ( className="table-row"
<React.Fragment key={row.id}> style={{
<div '&:hover div': {
className="table-row" background: `${token.colorFillAlter} !important`,
style={{ },
'&:hover div': { }}
background: `${token.colorFillAlter} !important`, >
}, {row.getVisibleCells().map((cell, index) => (
}} <div
> key={cell.id}
{row.getVisibleCells().map((cell, index) => ( className={`${cell.column.getIsPinned() === 'left' ? 'sticky left-0 z-10' : ''}`}
<div style={{
key={cell.id} width: cell.column.getSize(),
className={`${cell.column.getIsPinned() === 'left' ? 'sticky left-0 z-10' : ''}`} position: index < 2 ? 'sticky' : 'relative',
style={{ left: 'auto',
width: cell.column.getSize(), background: token.colorBgContainer,
position: index < 2 ? 'sticky' : 'relative', color: token.colorText,
left: 'auto', height: '42px',
background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`,
color: token.colorText, borderRight: `1px solid ${token.colorBorderSecondary}`,
height: '42px', padding: '8px 0px 8px 8px',
borderBottom: `1px solid ${token.colorBorderSecondary}`, }}
borderRight: `1px solid ${token.colorBorderSecondary}`, >
padding: '8px 0px 8px 8px', {flexRender(cell.column.columnDef.cell, cell.getContext())}
}} </div>
> ))}
{flexRender(cell.column.columnDef.cell, cell.getContext())} </div>
</div> {expandedRows[row.id] &&
))} row.original.sub_tasks?.map(subTask => (
</div> <div
{expandedRows[row.id] && key={subTask.task_key}
row.original.sub_tasks?.map(subTask => ( className="table-row"
<div style={{
key={subTask.task_key} '&:hover div': {
className="table-row" background: `${token.colorFillAlter} !important`,
style={{ },
'&:hover div': { }}
background: `${token.colorFillAlter} !important`, >
}, {columns.map((col, index) => (
}} <div
> key={`${subTask.task_key}-${col.id}`}
{columns.map((col, index) => ( style={{
<div width: col.getSize(),
key={`${subTask.task_key}-${col.id}`} position: index < 2 ? 'sticky' : 'relative',
style={{ left: index < 2 ? `${index * col.getSize()}px` : 'auto',
width: col.getSize(), background: token.colorBgContainer,
position: index < 2 ? 'sticky' : 'relative', color: token.colorText,
left: index < 2 ? `${index * col.getSize()}px` : 'auto', height: '42px',
background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`,
color: token.colorText, borderRight: `1px solid ${token.colorBorderSecondary}`,
height: '42px', paddingLeft: index === 3 ? '32px' : '8px',
borderBottom: `1px solid ${token.colorBorderSecondary}`, paddingRight: '8px',
borderRight: `1px solid ${token.colorBorderSecondary}`, }}
paddingLeft: index === 3 ? '32px' : '8px', >
paddingRight: '8px', {flexRender(col.cell, {
}} getValue: () => subTask[col.id as keyof typeof subTask] ?? null,
> row: { original: subTask } as Row<IProjectTask>,
{flexRender(col.cell, { column: col as Column<IProjectTask>,
getValue: () => subTask[col.id as keyof typeof subTask] ?? null, table,
row: { original: subTask } as Row<IProjectTask>, })}
column: col as Column<IProjectTask>, </div>
table, ))}
})} </div>
</div> ))}
))} </React.Fragment>
</div> ))}
))}
</React.Fragment>
);
})}
{paddingBottom > 0 && <div style={{ height: `${paddingBottom}px` }} />}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -4,12 +4,12 @@ import { TaskType } from '@/types/task.types';
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons'; import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
import { colors } from '@/styles/colors'; import { colors } from '@/styles/colors';
import './task-list-table-wrapper.css'; 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 { MenuProps } from 'antd/lib';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ITaskListGroup } from '@/types/tasks/taskList.types'; 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 = { type TaskListTableWrapperProps = {
taskList: ITaskListGroup; taskList: ITaskListGroup;
@@ -37,6 +37,22 @@ const TaskListTableWrapper = ({
// localization // localization
const { t } = useTranslation('task-list-table'); 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 // function to handle toggle expand
const handlToggleExpand = () => { const handlToggleExpand = () => {
setIsExpanded(!isExpanded); 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 ( return (
<ConfigProvider <ConfigProvider
wave={{ disabled: true }} wave={{ disabled: true }}
@@ -172,11 +196,13 @@ const TaskListTableWrapper = ({
key: groupId || '1', key: groupId || '1',
className: `custom-collapse-content-box relative after:content after:absolute after:h-full after:w-1 ${color} after:z-10 after:top-0 after:left-0`, className: `custom-collapse-content-box relative after:content after:absolute after:h-full after:w-1 ${color} after:z-10 after:top-0 after:left-0`,
children: ( children: (
<TaskListCustom <TaskListTable
key={groupId} key={groupId}
groupId={groupId} taskListGroup={taskList}
tasks={taskList.tasks} tableId={groupId || ''}
color={color || ''} visibleColumns={visibleColumns}
onTaskSelect={handleTaskSelect}
onTaskExpand={handleTaskExpand}
/> />
), ),
}, },

View File

@@ -6,9 +6,7 @@ import { ITaskListConfigV2, ITaskListGroup } from '@/types/tasks/taskList.types'
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fetchTaskGroups } from '@/features/tasks/taskSlice'; import { fetchTaskGroups } from '@/features/tasks/taskSlice';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import TaskListTableWrapper from './task-list-table-wrapper/task-list-table-wrapper';
import { columnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList';
import StatusGroupTables from '../taskList/statusTables/StatusGroupTables';
const TaskList = () => { const TaskList = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -31,6 +29,7 @@ const TaskList = () => {
const onTaskExpand = (taskId: string) => { const onTaskExpand = (taskId: string) => {
console.log('taskId:', taskId); console.log('taskId:', taskId);
}; };
useEffect(() => { useEffect(() => {
if (projectId) { if (projectId) {
const config: ITaskListConfigV2 = { const config: ITaskListConfigV2 = {
@@ -54,9 +53,15 @@ const TaskList = () => {
<Flex vertical gap={16}> <Flex vertical gap={16}>
<TaskListFilters position="list" /> <TaskListFilters position="list" />
<Skeleton active loading={loadingGroups}> <Skeleton active loading={loadingGroups}>
{/* {taskGroups.map((group: ITaskListGroup) => ( {taskGroups.map((group: ITaskListGroup) => (
<TaskListTableWrapper
))} */} key={group.id}
taskList={group}
groupId={group.id}
name={group.name}
color={group.color_code}
/>
))}
</Skeleton> </Skeleton>
</Flex> </Flex>
); );

View File

@@ -67,6 +67,7 @@ const ProjectViewHeader = () => {
const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer); const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer);
const [creatingTask, setCreatingTask] = useState(false); const [creatingTask, setCreatingTask] = useState(false);
const [subscriptionLoading, setSubscriptionLoading] = useState(false);
const handleRefresh = () => { const handleRefresh = () => {
if (!projectId) return; if (!projectId) return;
@@ -98,17 +99,51 @@ const ProjectViewHeader = () => {
}; };
const handleSubscribe = () => { const handleSubscribe = () => {
if (selectedProject?.id) { if (!selectedProject?.id || !socket || subscriptionLoading) return;
try {
setSubscriptionLoading(true);
const newSubscriptionState = !selectedProject.subscribed; const newSubscriptionState = !selectedProject.subscribed;
dispatch(setProject({ ...selectedProject, subscribed: newSubscriptionState })); // Emit socket event first, then update state based on response
socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), {
socket?.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), {
project_id: selectedProject.id, project_id: selectedProject.id,
user_id: currentSession?.id, user_id: currentSession?.id,
team_member_id: currentSession?.team_member_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 = () => {
<Tooltip title={t('subscribe')}> <Tooltip title={t('subscribe')}>
<Button <Button
shape="round" shape="round"
loading={subscriptionLoading}
icon={selectedProject?.subscribed ? <BellFilled /> : <BellOutlined />} icon={selectedProject?.subscribed ? <BellFilled /> : <BellOutlined />}
onClick={handleSubscribe} onClick={handleSubscribe}
> >

View File

@@ -3,11 +3,6 @@ import { createPortal } from 'react-dom';
import Flex from 'antd/es/flex'; import Flex from 'antd/es/flex';
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect'; import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
import {
DndContext,
pointerWithin,
} from '@dnd-kit/core';
import { ITaskListGroup } from '@/types/tasks/taskList.types'; import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
@@ -16,7 +11,6 @@ import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-a
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer'; import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { useTaskDragAndDrop } from '@/hooks/useTaskDragAndDrop';
interface TaskGroupWrapperOptimizedProps { interface TaskGroupWrapperOptimizedProps {
taskGroups: ITaskListGroup[]; taskGroups: ITaskListGroup[];
@@ -28,14 +22,6 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti
// Use extracted hooks // Use extracted hooks
useTaskSocketHandlers(); useTaskSocketHandlers();
const {
activeId,
sensors,
handleDragStart,
handleDragEnd,
handleDragOver,
resetTaskRowStyles,
} = useTaskDragAndDrop({ taskGroups, groupBy });
// Memoize task groups with colors // Memoize task groups with colors
const taskGroupsWithColors = useMemo(() => const taskGroupsWithColors = useMemo(() =>
@@ -46,18 +32,17 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti
[taskGroups, themeMode] [taskGroups, themeMode]
); );
// Add drag styles // Add drag styles without animations
useEffect(() => { useEffect(() => {
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = ` style.textContent = `
.task-row[data-is-dragging="true"] { .task-row[data-is-dragging="true"] {
opacity: 0.5 !important; opacity: 0.5 !important;
transform: rotate(5deg) !important;
z-index: 1000 !important; z-index: 1000 !important;
position: relative !important; position: relative !important;
} }
.task-row { .task-row {
transition: transform 0.2s ease, opacity 0.2s ease; /* Remove transitions during drag operations */
} }
`; `;
document.head.appendChild(style); document.head.appendChild(style);
@@ -67,45 +52,31 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti
}; };
}, []); }, []);
// Handle animation cleanup after drag ends // Remove the animation cleanup since we're simplifying the approach
useIsomorphicLayoutEffect(() => {
if (activeId === null) {
const timeoutId = setTimeout(resetTaskRowStyles, 50);
return () => clearTimeout(timeoutId);
}
}, [activeId, resetTaskRowStyles]);
return ( return (
<DndContext <Flex gap={24} vertical>
sensors={sensors} {taskGroupsWithColors.map(taskGroup => (
collisionDetection={pointerWithin} <TaskListTableWrapper
onDragStart={handleDragStart} key={taskGroup.id}
onDragEnd={handleDragEnd} taskList={taskGroup.tasks}
onDragOver={handleDragOver} tableId={taskGroup.id}
> name={taskGroup.name}
<Flex gap={24} vertical> groupBy={groupBy}
{taskGroupsWithColors.map(taskGroup => ( statusCategory={taskGroup.category_id}
<TaskListTableWrapper color={taskGroup.displayColor}
key={taskGroup.id} activeId={null}
taskList={taskGroup.tasks} />
tableId={taskGroup.id} ))}
name={taskGroup.name}
groupBy={groupBy}
statusCategory={taskGroup.category_id}
color={taskGroup.displayColor}
activeId={activeId}
/>
))}
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')} {createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
{createPortal( {createPortal(
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />, <TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
document.body, document.body,
'task-template-drawer' 'task-template-drawer'
)} )}
</Flex> </Flex>
</DndContext>
); );
}; };

View File

@@ -249,7 +249,7 @@ const TaskListTableWrapper = ({
className={`border-l-[3px] relative after:content after:absolute after:h-full after:w-1 after:z-10 after:top-0 after:left-0`} className={`border-l-[3px] relative after:content after:absolute after:h-full after:w-1 after:z-10 after:top-0 after:left-0`}
color={color} color={color}
> >
<TaskListTable taskList={taskList} tableId={tableId} activeId={activeId} /> <TaskListTable taskList={taskList} tableId={tableId} activeId={activeId} groupBy={groupBy} />
</Collapsible> </Collapsible>
</Flex> </Flex>
</ConfigProvider> </ConfigProvider>

View File

@@ -12,8 +12,8 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { DraggableAttributes, UniqueIdentifier } from '@dnd-kit/core'; import { DraggableAttributes, UniqueIdentifier } from '@dnd-kit/core';
import { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities'; import { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import { DragOverlay } from '@dnd-kit/core'; import { DragOverlay, DndContext, PointerSensor, useSensor, useSensors, KeyboardSensor, TouchSensor } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, verticalListSortingStrategy, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { DragEndEvent } from '@dnd-kit/core'; import { DragEndEvent } from '@dnd-kit/core';
import { List, Card, Avatar, Dropdown, Empty, Divider, Button } from 'antd'; import { List, Card, Avatar, Dropdown, Empty, Divider, Button } from 'antd';
@@ -50,19 +50,20 @@ import StatusDropdown from '@/components/task-list-common/status-dropdown/status
import PriorityDropdown from '@/components/task-list-common/priorityDropdown/priority-dropdown'; import PriorityDropdown from '@/components/task-list-common/priorityDropdown/priority-dropdown';
import AddCustomColumnButton from './custom-columns/custom-column-modal/add-custom-column-button'; import AddCustomColumnButton from './custom-columns/custom-column-modal/add-custom-column-button';
import { fetchSubTasks, reorderTasks, toggleTaskRowExpansion, updateCustomColumnValue } from '@/features/tasks/tasks.slice'; import { fetchSubTasks, reorderTasks, toggleTaskRowExpansion, updateCustomColumnValue } from '@/features/tasks/tasks.slice';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth'; import { useAuthService } from '@/hooks/useAuth';
import ConfigPhaseButton from '@/features/projects/singleProject/phase/ConfigPhaseButton'; import ConfigPhaseButton from '@/features/projects/singleProject/phase/ConfigPhaseButton';
import PhaseDropdown from '@/components/taskListCommon/phase-dropdown/phase-dropdown'; import PhaseDropdown from '@/components/taskListCommon/phase-dropdown/phase-dropdown';
import CustomColumnModal from './custom-columns/custom-column-modal/custom-column-modal'; import CustomColumnModal from './custom-columns/custom-column-modal/custom-column-modal';
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
import SingleAvatar from '@/components/common/single-avatar/single-avatar'; import SingleAvatar from '@/components/common/single-avatar/single-avatar';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
interface TaskListTableProps { interface TaskListTableProps {
taskList: IProjectTask[] | null; taskList: IProjectTask[] | null;
tableId: string; tableId: string;
activeId?: string | null; activeId?: string | null;
groupBy?: string;
} }
interface DraggableRowProps { interface DraggableRowProps {
@@ -71,44 +72,50 @@ interface DraggableRowProps {
groupId: string; groupId: string;
} }
// Add a simplified EmptyRow component that doesn't use hooks // Remove the EmptyRow component and fix the DraggableRow
const EmptyRow = () => null;
// Simplify DraggableRow to eliminate conditional hook calls
const DraggableRow = ({ task, children, groupId }: DraggableRowProps) => { const DraggableRow = ({ task, children, groupId }: DraggableRowProps) => {
// Return the EmptyRow component without using any hooks // Always call hooks in the same order - never conditionally
if (!task?.id) return <EmptyRow />;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id as UniqueIdentifier, id: task?.id || 'empty-task', // Provide fallback ID
data: { data: {
type: 'task', type: 'task',
task, task,
groupId, groupId,
}, },
disabled: !task?.id, // Disable dragging for invalid tasks
transition: null, // Disable sortable transitions
}); });
// If task is invalid, return null to not render anything
if (!task?.id) {
return null;
}
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition: isDragging ? 'none' : transition, // Disable transition during drag
opacity: isDragging ? 0.3 : 1, opacity: isDragging ? 0.3 : 1,
position: 'relative' as const, position: 'relative' as const,
zIndex: isDragging ? 1 : 'auto', zIndex: isDragging ? 1 : 'auto',
backgroundColor: isDragging ? 'var(--dragging-bg)' : undefined, backgroundColor: isDragging ? 'var(--dragging-bg)' : undefined,
}; // Handle border styling to avoid conflicts between shorthand and individual properties
...(isDragging ? {
// Handle border styling separately to avoid conflicts borderTopWidth: '1px',
const borderStyle = { borderRightWidth: '1px',
borderStyle: isDragging ? 'solid' : undefined, borderBottomWidth: '1px',
borderWidth: isDragging ? '1px' : undefined, borderLeftWidth: '1px',
borderColor: isDragging ? 'var(--border-color)' : undefined, borderStyle: 'solid',
borderBottomWidth: document.documentElement.getAttribute('data-theme') === 'light' && !isDragging ? '2px' : undefined borderColor: 'var(--border-color)',
} : {
// Only set borderBottomWidth when not dragging to avoid conflicts
borderBottomWidth: document.documentElement.getAttribute('data-theme') === 'light' ? '2px' : undefined
})
}; };
return ( return (
<tr <tr
ref={setNodeRef} ref={setNodeRef}
style={{ ...style, ...borderStyle }} style={style}
className={`task-row h-[42px] ${isDragging ? 'shadow-lg' : ''}`} className={`task-row h-[42px] ${isDragging ? 'shadow-lg' : ''}`}
data-is-dragging={isDragging ? 'true' : 'false'} data-is-dragging={isDragging ? 'true' : 'false'}
data-group-id={groupId} data-group-id={groupId}
@@ -1208,12 +1215,33 @@ const renderCustomColumnContent = (
return customComponents[fieldType] ? customComponents[fieldType]() : null; return customComponents[fieldType] ? customComponents[fieldType]() : null;
}; };
const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, activeId }) => { const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, activeId, groupBy }) => {
const { t } = useTranslation('task-list-table'); const { t } = useTranslation('task-list-table');
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const currentSession = useAuthService().getCurrentSession(); const currentSession = useAuthService().getCurrentSession();
const { socket } = useSocket(); const { socket } = useSocket();
// Add drag state
const [dragActiveId, setDragActiveId] = useState<string | null>(null);
// Configure sensors for drag and drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
const themeMode = useAppSelector(state => state.themeReducer.mode); const themeMode = useAppSelector(state => state.themeReducer.mode);
const columnList = useAppSelector(state => state.taskReducer.columns); const columnList = useAppSelector(state => state.taskReducer.columns);
const visibleColumns = columnList.filter(column => column.pinned); const visibleColumns = columnList.filter(column => column.pinned);
@@ -1525,27 +1553,8 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
// Use the tasks from the current group if available, otherwise fall back to taskList prop // Use the tasks from the current group if available, otherwise fall back to taskList prop
const displayTasks = currentGroup?.tasks || taskList || []; const displayTasks = currentGroup?.tasks || taskList || [];
const handleDragEnd = (event: DragEndEvent) => { // Remove the local handleDragEnd as it conflicts with the main DndContext
const { active, over } = event; // All drag handling is now done at the TaskGroupWrapperOptimized level
if (!over || active.id === over.id) return;
const activeIndex = displayTasks.findIndex(task => task.id === active.id);
const overIndex = displayTasks.findIndex(task => task.id === over.id);
if (activeIndex !== -1 && overIndex !== -1) {
dispatch(
reorderTasks({
activeGroupId: tableId,
overGroupId: tableId,
fromIndex: activeIndex,
toIndex: overIndex,
task: displayTasks[activeIndex],
updatedSourceTasks: displayTasks,
updatedTargetTasks: displayTasks,
})
);
}
};
const handleCustomColumnSettings = (columnKey: string) => { const handleCustomColumnSettings = (columnKey: string) => {
if (!columnKey) return; if (!columnKey) return;
@@ -1554,12 +1563,169 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
dispatch(toggleCustomColumnModalOpen(true)); dispatch(toggleCustomColumnModalOpen(true));
}; };
// Drag and drop handlers
const handleDragStart = (event: any) => {
setDragActiveId(event.active.id);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setDragActiveId(null);
if (!over || !active || active.id === over.id) {
return;
}
const activeTask = displayTasks.find(task => task.id === active.id);
if (!activeTask) {
console.error('Active task not found:', { activeId: active.id, displayTasks: displayTasks.map(t => ({ id: t.id, name: t.name })) });
return;
}
console.log('Found activeTask:', {
id: activeTask.id,
name: activeTask.name,
status_id: activeTask.status_id,
status: activeTask.status,
priority: activeTask.priority,
project_id: project?.id,
team_id: project?.team_id,
fullProject: project
});
// Use the tableId directly as the group ID (it should be the group ID)
const currentGroupId = tableId;
console.log('Drag operation:', {
activeId: active.id,
overId: over.id,
tableId,
currentGroupId,
displayTasksLength: displayTasks.length
});
// Check if this is a reorder within the same group
const overTask = displayTasks.find(task => task.id === over.id);
if (overTask) {
// Reordering within the same group
const oldIndex = displayTasks.findIndex(task => task.id === active.id);
const newIndex = displayTasks.findIndex(task => task.id === over.id);
console.log('Reorder details:', { oldIndex, newIndex, activeTask: activeTask.name });
if (oldIndex !== newIndex && oldIndex !== -1 && newIndex !== -1) {
// Get the actual sort_order values from the tasks
const fromSortOrder = activeTask.sort_order || oldIndex;
const overTaskAtNewIndex = displayTasks[newIndex];
const toSortOrder = overTaskAtNewIndex?.sort_order || newIndex;
console.log('Sort order details:', {
oldIndex,
newIndex,
fromSortOrder,
toSortOrder,
activeTaskSortOrder: activeTask.sort_order,
overTaskSortOrder: overTaskAtNewIndex?.sort_order
});
// Create updated task list with reordered tasks
const updatedTasks = [...displayTasks];
const [movedTask] = updatedTasks.splice(oldIndex, 1);
updatedTasks.splice(newIndex, 0, movedTask);
console.log('Dispatching reorderTasks with:', {
activeGroupId: currentGroupId,
overGroupId: currentGroupId,
fromIndex: oldIndex,
toIndex: newIndex,
taskName: activeTask.name
});
// Update local state immediately for better UX
dispatch(reorderTasks({
activeGroupId: currentGroupId,
overGroupId: currentGroupId,
fromIndex: oldIndex,
toIndex: newIndex,
task: activeTask,
updatedSourceTasks: updatedTasks,
updatedTargetTasks: updatedTasks
}));
// Send socket event for backend sync
if (socket && project?.id && active.id && activeTask.id) {
// Helper function to validate UUID or return null
const validateUUID = (value: string | undefined | null): string | null => {
if (!value || value.trim() === '') return null;
// Basic UUID format check (8-4-4-4-12 characters)
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(value) ? value : null;
};
const body = {
from_index: fromSortOrder,
to_index: toSortOrder,
project_id: project.id,
from_group: currentGroupId,
to_group: currentGroupId,
group_by: groupBy || 'status', // Use the groupBy prop
to_last_index: false,
task: {
id: activeTask.id, // Use activeTask.id instead of active.id to ensure it's valid
project_id: project.id,
status: validateUUID(activeTask.status_id || activeTask.status),
priority: validateUUID(activeTask.priority)
},
team_id: project.team_id || currentSession?.team_id || ''
};
// Validate required fields before sending
if (!body.task.id) {
console.error('Cannot send socket event: task.id is missing', { activeTask, active });
return;
}
console.log('Validated values:', {
from_index: body.from_index,
to_index: body.to_index,
status: body.task.status,
priority: body.task.priority,
team_id: body.team_id,
originalStatus: activeTask.status_id || activeTask.status,
originalPriority: activeTask.priority,
originalTeamId: project.team_id,
sessionTeamId: currentSession?.team_id,
finalTeamId: body.team_id
});
console.log('Sending socket event:', body);
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
} else {
console.error('Cannot send socket event: missing required data', {
hasSocket: !!socket,
hasProjectId: !!project?.id,
hasActiveId: !!active.id,
hasActiveTaskId: !!activeTask.id,
activeTask,
active
});
}
}
}
};
return ( return (
<div className={`border-x border-b ${customBorderColor}`}> <div className={`border-x border-b ${customBorderColor}`}>
<SortableContext <DndContext
items={(displayTasks?.map(t => t.id).filter(Boolean) || []) as string[]} sensors={sensors}
strategy={verticalListSortingStrategy} onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
autoScroll={false} // Disable auto-scroll animations
> >
<SortableContext
items={(displayTasks?.filter(t => t?.id).map(t => t.id).filter(Boolean) || []) as string[]}
strategy={verticalListSortingStrategy}
>
<div className={`tasklist-container-${tableId} min-h-0 max-w-full overflow-x-auto`}> <div className={`tasklist-container-${tableId} min-h-0 max-w-full overflow-x-auto`}>
<table className="rounded-2 w-full min-w-max border-collapse relative"> <table className="rounded-2 w-full min-w-max border-collapse relative">
<thead className="h-[42px]"> <thead className="h-[42px]">
@@ -1611,25 +1777,29 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
</thead> </thead>
<tbody> <tbody>
{displayTasks && displayTasks.length > 0 ? ( {displayTasks && displayTasks.length > 0 ? (
displayTasks.map(task => { displayTasks
const updatedTask = findTaskInGroups(task.id || '') || task; .filter(task => task?.id) // Filter out tasks without valid IDs
.map(task => {
const updatedTask = findTaskInGroups(task.id || '') || task;
return ( return (
<React.Fragment key={updatedTask.id}> <React.Fragment key={updatedTask.id}>
{renderTaskRow(updatedTask)} {renderTaskRow(updatedTask)}
{updatedTask.show_sub_tasks && ( {updatedTask.show_sub_tasks && (
<> <>
{updatedTask?.sub_tasks?.map(subtask => renderTaskRow(subtask, true))} {updatedTask?.sub_tasks?.map(subtask =>
<tr> subtask?.id ? renderTaskRow(subtask, true) : null
<td colSpan={visibleColumns.length + 1}> )}
<AddTaskListRow groupId={tableId} parentTask={updatedTask.id} /> <tr key={`add-subtask-${updatedTask.id}`}>
</td> <td colSpan={visibleColumns.length + 1}>
<AddTaskListRow groupId={tableId} parentTask={updatedTask.id} />
</td>
</tr> </tr>
</> </>
)} )}
</React.Fragment> </React.Fragment>
); );
}) })
) : ( ) : (
<tr> <tr>
<td colSpan={visibleColumns.length + 1} className="ps-2 py-2"> <td colSpan={visibleColumns.length + 1} className="ps-2 py-2">
@@ -1643,17 +1813,15 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
</SortableContext> </SortableContext>
<DragOverlay <DragOverlay
dropAnimation={{ dropAnimation={null} // Disable drop animation
duration: 200,
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}}
> >
{activeId && displayTasks?.length ? ( {dragActiveId ? (
<table className="w-full"> <div className="bg-white dark:bg-gray-800 shadow-lg rounded border p-2 opacity-90">
<tbody>{renderTaskRow(displayTasks.find(t => t.id === activeId))}</tbody> <span className="text-sm font-medium">Moving task...</span>
</table> </div>
) : null} ) : null}
</DragOverlay> </DragOverlay>
</DndContext>
{/* Add task row is positioned outside of the scrollable area */} {/* Add task row is positioned outside of the scrollable area */}
<div className={`border-t ${customBorderColor}`}> <div className={`border-t ${customBorderColor}`}>

View File

@@ -79,6 +79,7 @@ export interface IBillingAccountInfo {
unit_price?: number; unit_price?: number;
unit_price_per_month?: number; unit_price_per_month?: number;
usedPercentage?: number; usedPercentage?: number;
used_percent?: number;
usedStorage?: number; usedStorage?: number;
is_custom?: boolean; is_custom?: boolean;
is_ltd_user?: boolean; is_ltd_user?: boolean;