Merge branch 'release/v2.0.3' of https://github.com/Worklenz/worklenz into feature/project-list-grouping
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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;
|
||||||
setSelectedMembers(prev =>
|
|
||||||
prev.some(mention => mention.team_member_id === member.value)
|
|
||||||
? prev
|
|
||||||
: [...prev, { team_member_id: member.value, name: member.label }]
|
|
||||||
);
|
|
||||||
|
|
||||||
setCommentValue(prev => {
|
// Find the member ID from the members list using the name
|
||||||
const parts = prev.split('@');
|
const selectedMember = members.find(m => m.name === member.value);
|
||||||
const lastPart = parts[parts.length - 1];
|
if (!selectedMember) return;
|
||||||
const mentionText = member.label;
|
|
||||||
// Keep only the part before the @ and add the new mention
|
// Add to selected members if not already present
|
||||||
return prev.slice(0, prev.length - lastPart.length) + mentionText;
|
setSelectedMembers(prev =>
|
||||||
});
|
prev.some(mention => mention.team_member_id === selectedMember.id)
|
||||||
}, []);
|
? prev
|
||||||
|
: [...prev, { team_member_id: selectedMember.id!, name: selectedMember.name! }]
|
||||||
|
);
|
||||||
|
}, [members]);
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|
||||||
// Memoize all tasks including subtasks for virtualization
|
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();
|
||||||
|
|
||||||
|
// 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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user