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:
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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