Merge branch 'release/v2.0.3' of https://github.com/Worklenz/worklenz into release/v2.0.3-kanban-handle-drag-over

This commit is contained in:
shancds
2025-06-13 08:40:27 +05:30
11 changed files with 301 additions and 231 deletions

View File

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

View File

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

View File

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

View File

@@ -47,5 +47,6 @@
"weightedProgress": "Progreso Ponderado", "weightedProgress": "Progreso Ponderado",
"weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas", "weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas",
"timeProgress": "Progreso Basado en Tiempo", "timeProgress": "Progreso Basado en Tiempo",
"timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado" "timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado",
"enterProjectKey": "Ingresa la clave del proyecto"
} }

View File

@@ -47,5 +47,6 @@
"weightedProgress": "Progresso Ponderado", "weightedProgress": "Progresso Ponderado",
"weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas", "weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas",
"timeProgress": "Progresso Baseado em Tempo", "timeProgress": "Progresso Baseado em Tempo",
"timeProgressTooltip": "Calcular o progresso com base no tempo estimado" "timeProgressTooltip": "Calcular o progresso com base no tempo estimado",
"enterProjectKey": "Insira a chave do projeto"
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,38 +17,70 @@ const TimerButton = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [currentTimes, setCurrentTimes] = useState<Record<string, string>>({}); const [currentTimes, setCurrentTimes] = useState<Record<string, string>>({});
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const { t } = useTranslation('navbar'); const { t } = useTranslation('navbar');
const { token } = useToken(); const { token } = useToken();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { socket } = useSocket(); const { socket } = useSocket();
const logError = (message: string, error?: any) => {
// Production-safe error logging
console.error(`[TimerButton] ${message}`, error);
setError(message);
};
const fetchRunningTimers = useCallback(async () => { const fetchRunningTimers = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
setError(null);
const response = await taskTimeLogsApiService.getRunningTimers(); const response = await taskTimeLogsApiService.getRunningTimers();
if (response.done) {
setRunningTimers(response.body || []); if (response && response.done) {
const timers = Array.isArray(response.body) ? response.body : [];
setRunningTimers(timers);
} else {
logError('Invalid response from getRunningTimers API');
setRunningTimers([]);
} }
} catch (error) { } catch (error) {
console.error('Error fetching running timers:', error); logError('Error fetching running timers', error);
setRunningTimers([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, []);
const updateCurrentTimes = () => { const updateCurrentTimes = useCallback(() => {
try {
if (!Array.isArray(runningTimers) || runningTimers.length === 0) return;
const newTimes: Record<string, string> = {}; const newTimes: Record<string, string> = {};
runningTimers.forEach(timer => { runningTimers.forEach(timer => {
try {
if (!timer || !timer.task_id || !timer.start_time) return;
const startTime = moment(timer.start_time); const startTime = moment(timer.start_time);
if (!startTime.isValid()) {
logError(`Invalid start time for timer ${timer.task_id}: ${timer.start_time}`);
return;
}
const now = moment(); const now = moment();
const duration = moment.duration(now.diff(startTime)); const duration = moment.duration(now.diff(startTime));
const hours = Math.floor(duration.asHours()); const hours = Math.floor(duration.asHours());
const minutes = duration.minutes(); const minutes = duration.minutes();
const seconds = duration.seconds(); const seconds = duration.seconds();
newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; 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); setCurrentTimes(newTimes);
}; } catch (error) {
logError('Error in updateCurrentTimes', error);
}
}, [runningTimers]);
useEffect(() => { useEffect(() => {
fetchRunningTimers(); fetchRunningTimers();
@@ -67,68 +99,107 @@ 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 = () => {
try {
// Refresh timers when project updates are available // Refresh timers when project updates are available
fetchRunningTimers(); fetchRunningTimers();
} catch (error) {
logError('Error handling project updates', error);
}
}; };
try {
socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
return () => { return () => {
try {
socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); 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;
}
if (!taskId) {
logError('Invalid task ID for stopping timer');
return;
}
try {
socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId })); socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId }));
dispatch(updateTaskTimeTracking({ taskId, timeTracking: null })); dispatch(updateTaskTimeTracking({ taskId, timeTracking: null }));
} catch (error) {
logError(`Error stopping timer for task ${taskId}`, error);
}
}; };
const dropdownContent = ( const renderDropdownContent = () => {
try {
if (error) {
return (
<div style={{ padding: 16, textAlign: 'center', width: 350 }}>
<Text type="danger">Error loading timers</Text>
</div>
);
}
return (
<div <div
style={{ style={{
width: 350, width: 350,
@@ -140,14 +211,17 @@ const TimerButton = () => {
border: `1px solid ${token.colorBorderSecondary}` border: `1px solid ${token.colorBorderSecondary}`
}} }}
> >
{runningTimers.length === 0 ? ( {!Array.isArray(runningTimers) || runningTimers.length === 0 ? (
<div style={{ padding: 16, textAlign: 'center' }}> <div style={{ padding: 16, textAlign: 'center' }}>
<Text type="secondary">No running timers</Text> <Text type="secondary">No running timers</Text>
</div> </div>
) : ( ) : (
<List <List
dataSource={runningTimers} dataSource={runningTimers}
renderItem={(timer) => ( renderItem={(timer) => {
if (!timer || !timer.task_id) return null;
return (
<List.Item <List.Item
style={{ style={{
padding: '12px 16px', padding: '12px 16px',
@@ -158,7 +232,7 @@ const TimerButton = () => {
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}> <Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text strong style={{ fontSize: 14, color: token.colorText }}> <Text strong style={{ fontSize: 14, color: token.colorText }}>
{timer.task_name} {timer.task_name || 'Unnamed Task'}
</Text> </Text>
<div style={{ <div style={{
display: 'inline-block', display: 'inline-block',
@@ -170,7 +244,7 @@ const TimerButton = () => {
fontWeight: 500, fontWeight: 500,
marginTop: 2 marginTop: 2
}}> }}>
{timer.project_name} {timer.project_name || 'Unnamed Project'}
</div> </div>
{timer.parent_task_name && ( {timer.parent_task_name && (
<Text type="secondary" style={{ fontSize: 11 }}> <Text type="secondary" style={{ fontSize: 11 }}>
@@ -181,7 +255,7 @@ const TimerButton = () => {
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 11 }}> <Text type="secondary" style={{ fontSize: 11 }}>
Started: {moment(timer.start_time).format('HH:mm')} Started: {timer.start_time ? moment(timer.start_time).format('HH:mm') : '--:--'}
</Text> </Text>
<Text <Text
strong strong
@@ -215,10 +289,11 @@ const TimerButton = () => {
</Space> </Space>
</div> </div>
</List.Item> </List.Item>
)} );
}}
/> />
)} )}
{runningTimers.length > 0 && ( {hasRunningTimers() && (
<> <>
<Divider style={{ margin: 0, borderColor: token.colorBorderSecondary }} /> <Divider style={{ margin: 0, borderColor: token.colorBorderSecondary }} />
<div <div
@@ -231,26 +306,42 @@ const TimerButton = () => {
}} }}
> >
<Text type="secondary" style={{ fontSize: 11 }}> <Text type="secondary" style={{ fontSize: 11 }}>
{runningTimers.length} timer{runningTimers.length !== 1 ? 's' : ''} running {timerCount()} timer{timerCount() !== 1 ? 's' : ''} running
</Text> </Text>
</div> </div>
</> </>
)} )}
</div> </div>
); );
} catch (error) {
logError('Error rendering dropdown content', error);
return ( return (
<Dropdown <div style={{ padding: 16, textAlign: 'center', width: 350 }}>
popupRender={() => dropdownContent} <Text type="danger">Error rendering timers</Text>
trigger={['click']} </div>
placement="bottomRight" );
open={dropdownOpen} }
onOpenChange={(open) => { };
const handleDropdownOpenChange = (open: boolean) => {
try {
setDropdownOpen(open); setDropdownOpen(open);
if (open) { if (open) {
fetchRunningTimers(); 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"> <Tooltip title="Running Timers">
<Button <Button
@@ -270,6 +361,19 @@ const TimerButton = () => {
</Tooltip> </Tooltip>
</Dropdown> </Dropdown>
); );
} catch (error) {
logError('Error rendering TimerButton', error);
return (
<Tooltip title="Timer Error">
<Button
style={{ height: '62px', width: '60px' }}
type="text"
icon={<ClockCircleOutlined style={{ fontSize: 20 }} />}
disabled
/>
</Tooltip>
);
}
}; };
export default TimerButton; export default TimerButton;

View File

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