Merge branch 'feature/recurring-tasks' of https://github.com/Worklenz/worklenz into release/v2.0.3
This commit is contained in:
11
README.md
11
README.md
@@ -1,6 +1,6 @@
|
|||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
|
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
|
||||||
<img src="https://app.worklenz.com/assets/icons/icon-144x144.png" alt="Worklenz Logo" width="75">
|
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/icon-144x144.png" alt="Worklenz Logo" width="75">
|
||||||
</a>
|
</a>
|
||||||
<br>
|
<br>
|
||||||
Worklenz
|
Worklenz
|
||||||
@@ -315,6 +315,7 @@ docker-compose up -d
|
|||||||
docker-compose down
|
docker-compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## MinIO Integration
|
## MinIO Integration
|
||||||
|
|
||||||
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
|
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
|
||||||
@@ -403,6 +404,10 @@ This script generates properly configured environment files for both development
|
|||||||
- Frontend: http://localhost:5000
|
- Frontend: http://localhost:5000
|
||||||
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
|
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
|
||||||
|
|
||||||
|
4. Video Guide
|
||||||
|
|
||||||
|
For a visual walkthrough of the local Docker deployment process, check out our [step-by-step video guide](https://www.youtube.com/watch?v=AfwAKxJbqLg).
|
||||||
|
|
||||||
### Remote Server Deployment
|
### Remote Server Deployment
|
||||||
|
|
||||||
When deploying to a remote server:
|
When deploying to a remote server:
|
||||||
@@ -428,6 +433,10 @@ When deploying to a remote server:
|
|||||||
- Frontend: http://your-server-hostname:5000
|
- Frontend: http://your-server-hostname:5000
|
||||||
- Backend API: http://your-server-hostname:3000
|
- Backend API: http://your-server-hostname:3000
|
||||||
|
|
||||||
|
4. Video Guide
|
||||||
|
|
||||||
|
For a complete walkthrough of deploying Worklenz to a remote server, check out our [deployment video guide](https://www.youtube.com/watch?v=CAZGu2iOXQs&t=10s).
|
||||||
|
|
||||||
### Environment Configuration
|
### Environment Configuration
|
||||||
|
|
||||||
The Docker setup uses environment variables to configure the services:
|
The Docker setup uses environment variables to configure the services:
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ cat > worklenz-backend/.env << EOL
|
|||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
SESSION_NAME=worklenz.sid
|
SESSION_NAME=worklenz.sid
|
||||||
SESSION_SECRET=change_me_in_production
|
SESSION_SECRET=$(openssl rand -base64 48)
|
||||||
COOKIE_SECRET=change_me_in_production
|
COOKIE_SECRET=$(openssl rand -base64 48)
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
SOCKET_IO_CORS=${FRONTEND_URL}
|
SOCKET_IO_CORS=${FRONTEND_URL}
|
||||||
@@ -123,7 +123,7 @@ SLACK_WEBHOOK=
|
|||||||
COMMIT_BUILD_IMMEDIATELY=true
|
COMMIT_BUILD_IMMEDIATELY=true
|
||||||
|
|
||||||
# JWT Secret
|
# JWT Secret
|
||||||
JWT_SECRET=change_me_in_production
|
JWT_SECRET=$(openssl rand -base64 48)
|
||||||
EOL
|
EOL
|
||||||
|
|
||||||
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")
|
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")
|
||||||
@@ -138,4 +138,4 @@ echo "Frontend URL: ${FRONTEND_URL}"
|
|||||||
echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000"
|
echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000"
|
||||||
echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000"
|
echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000"
|
||||||
echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}"
|
echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}"
|
||||||
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"
|
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"
|
||||||
|
|||||||
@@ -35,8 +35,18 @@ export default class AuthController extends WorklenzControllerBase {
|
|||||||
const auth_error = errors.length > 0 ? errors[0] : null;
|
const auth_error = errors.length > 0 ? errors[0] : null;
|
||||||
const message = messages.length > 0 ? messages[0] : null;
|
const message = messages.length > 0 ? messages[0] : null;
|
||||||
|
|
||||||
const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
|
// Determine title based on authentication status and strategy
|
||||||
const title = req.query.strategy ? midTitle : null;
|
let title = null;
|
||||||
|
if (req.query.strategy) {
|
||||||
|
if (auth_error) {
|
||||||
|
// Show failure title only when there's an actual error
|
||||||
|
title = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
|
||||||
|
} else if (req.isAuthenticated() && message) {
|
||||||
|
// Show success title when authenticated and there's a success message
|
||||||
|
title = req.query.strategy === "login" ? "Login Successful!" : "Signup Successful!";
|
||||||
|
}
|
||||||
|
// If no error and not authenticated, don't show any title (this might be a redirect without completion)
|
||||||
|
}
|
||||||
|
|
||||||
if (req.user)
|
if (req.user)
|
||||||
req.user.build_v = FileConstants.getRelease();
|
req.user.build_v = FileConstants.getRelease();
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import { Strategy as LocalStrategy } from "passport-local";
|
|||||||
import { log_error } from "../../shared/utils";
|
import { log_error } from "../../shared/utils";
|
||||||
import db from "../../config/db";
|
import db from "../../config/db";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
import { ERROR_KEY, SUCCESS_KEY } from "./passport-constants";
|
||||||
|
|
||||||
async function handleLogin(req: Request, email: string, password: string, done: any) {
|
async function handleLogin(req: Request, email: string, password: string, done: any) {
|
||||||
console.log("Login attempt for:", email);
|
// Clear any existing flash messages
|
||||||
|
(req.session as any).flash = {};
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
console.log("Missing credentials");
|
const errorMsg = "Please enter both email and password";
|
||||||
return done(null, false, { message: "Please enter both email and password" });
|
req.flash(ERROR_KEY, errorMsg);
|
||||||
|
return done(null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -19,23 +22,27 @@ async function handleLogin(req: Request, email: string, password: string, done:
|
|||||||
AND google_id IS NULL
|
AND google_id IS NULL
|
||||||
AND is_deleted IS FALSE;`;
|
AND is_deleted IS FALSE;`;
|
||||||
const result = await db.query(q, [email]);
|
const result = await db.query(q, [email]);
|
||||||
console.log("User query result count:", result.rowCount);
|
|
||||||
|
|
||||||
const [data] = result.rows;
|
const [data] = result.rows;
|
||||||
|
|
||||||
if (!data?.password) {
|
if (!data?.password) {
|
||||||
console.log("No account found");
|
const errorMsg = "No account found with this email";
|
||||||
return done(null, false, { message: "No account found with this email" });
|
req.flash(ERROR_KEY, errorMsg);
|
||||||
|
return done(null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordMatch = bcrypt.compareSync(password, data.password);
|
const passwordMatch = bcrypt.compareSync(password, data.password);
|
||||||
console.log("Password match:", passwordMatch);
|
|
||||||
|
|
||||||
if (passwordMatch && email === data.email) {
|
if (passwordMatch && email === data.email) {
|
||||||
delete data.password;
|
delete data.password;
|
||||||
return done(null, data, {message: "User successfully logged in"});
|
const successMsg = "User successfully logged in";
|
||||||
|
req.flash(SUCCESS_KEY, successMsg);
|
||||||
|
return done(null, data);
|
||||||
}
|
}
|
||||||
return done(null, false, { message: "Incorrect email or password" });
|
|
||||||
|
const errorMsg = "Incorrect email or password";
|
||||||
|
req.flash(ERROR_KEY, errorMsg);
|
||||||
|
return done(null, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Login error:", error);
|
console.error("Login error:", error);
|
||||||
log_error(error, req.body);
|
log_error(error, req.body);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ COPY . .
|
|||||||
RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \
|
RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \
|
||||||
echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js
|
echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js
|
||||||
|
|
||||||
RUN npm run build
|
RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS production
|
FROM node:22-alpine AS production
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ import { ITaskLogViewModel } from "@/types/tasks/task-log-view.types";
|
|||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/task-time-log`;
|
const rootUrl = `${API_BASE_URL}/task-time-log`;
|
||||||
|
|
||||||
|
export interface IRunningTimer {
|
||||||
|
task_id: string;
|
||||||
|
start_time: string;
|
||||||
|
task_name: string;
|
||||||
|
project_id: string;
|
||||||
|
project_name: string;
|
||||||
|
parent_task_id?: string;
|
||||||
|
parent_task_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const taskTimeLogsApiService = {
|
export const taskTimeLogsApiService = {
|
||||||
getByTask: async (id: string) : Promise<IServerResponse<ITaskLogViewModel[]>> => {
|
getByTask: async (id: string) : Promise<IServerResponse<ITaskLogViewModel[]>> => {
|
||||||
const response = await apiClient.get(`${rootUrl}/task/${id}`);
|
const response = await apiClient.get(`${rootUrl}/task/${id}`);
|
||||||
@@ -26,6 +36,11 @@ export const taskTimeLogsApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getRunningTimers: async (): Promise<IServerResponse<IRunningTimer[]>> => {
|
||||||
|
const response = await apiClient.get(`${rootUrl}/running-timers`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
exportToExcel(taskId: string) {
|
exportToExcel(taskId: string) {
|
||||||
window.location.href = `${import.meta.env.VITE_API_URL}${API_BASE_URL}/task-time-log/export/${taskId}`;
|
window.location.href = `${import.meta.env.VITE_API_URL}${API_BASE_URL}/task-time-log/export/${taskId}`;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ const HomeTasksStatusDropdown = ({ task, teamId }: HomeTasksStatusDropdownProps)
|
|||||||
const { socket, connected } = useSocket();
|
const { socket, connected } = useSocket();
|
||||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||||
const {
|
const {
|
||||||
refetch
|
refetch
|
||||||
} = useGetMyTasksQuery(homeTasksConfig, {
|
} = useGetMyTasksQuery(homeTasksConfig, {
|
||||||
skip: true // Skip automatic queries entirely
|
skip: false, // Ensure this query runs
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState<ITaskStatus | undefined>(undefined);
|
const [selectedStatus, setSelectedStatus] = useState<ITaskStatus | undefined>(undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ const HomeTasksDatePicker = ({ record }: HomeTasksDatePickerProps) => {
|
|||||||
const { t } = useTranslation('home');
|
const { t } = useTranslation('home');
|
||||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||||
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
|
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
|
||||||
skip: true // Skip automatic queries entirely
|
skip: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use useMemo to avoid re-renders when record.end_date is the same
|
// Use useMemo to avoid re-renders when record.end_date is the same
|
||||||
const initialDate = useMemo(() =>
|
const initialDate = useMemo(() =>
|
||||||
record.end_date ? dayjs(record.end_date) : null
|
record.end_date ? dayjs(record.end_date) : null
|
||||||
, [record.end_date]);
|
, [record.end_date]);
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(initialDate);
|
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(initialDate);
|
||||||
|
|
||||||
// Update selected date when record changes
|
// Update selected date when record changes
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ const TaskListBulkActionsBar = () => {
|
|||||||
placement="top"
|
placement="top"
|
||||||
arrow
|
arrow
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
destroyPopupOnHide
|
destroyOnHidden
|
||||||
onOpenChange={value => {
|
onOpenChange={value => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
setSelectedLabels([]);
|
setSelectedLabels([]);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Col, ConfigProvider, Flex, Menu, MenuProps, Alert } from 'antd';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members';
|
import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members';
|
||||||
import HelpButton from './help/HelpButton';
|
|
||||||
import InviteButton from './invite/InviteButton';
|
import InviteButton from './invite/InviteButton';
|
||||||
import MobileMenuButton from './mobileMenu/MobileMenuButton';
|
import MobileMenuButton from './mobileMenu/MobileMenuButton';
|
||||||
import NavbarLogo from './navbar-logo';
|
import NavbarLogo from './navbar-logo';
|
||||||
@@ -22,6 +21,7 @@ import { useAuthService } from '@/hooks/useAuth';
|
|||||||
import { authApiService } from '@/api/auth/auth.api.service';
|
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';
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const [current, setCurrent] = useState<string>('home');
|
const [current, setCurrent] = useState<string>('home');
|
||||||
@@ -90,6 +90,7 @@ const Navbar = () => {
|
|||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<Col
|
<Col
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -144,7 +145,7 @@ const Navbar = () => {
|
|||||||
<Flex align="center">
|
<Flex align="center">
|
||||||
<SwitchTeamButton />
|
<SwitchTeamButton />
|
||||||
<NotificationButton />
|
<NotificationButton />
|
||||||
<HelpButton />
|
<TimerButton />
|
||||||
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
|
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
275
worklenz-frontend/src/features/navbar/timers/timer-button.tsx
Normal file
275
worklenz-frontend/src/features/navbar/timers/timer-button.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { ClockCircleOutlined, StopOutlined } from '@ant-design/icons';
|
||||||
|
import { Badge, Button, Dropdown, List, Tooltip, Typography, Space, Divider, theme } from 'antd';
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { taskTimeLogsApiService, IRunningTimer } from '@/api/tasks/task-time-logs.api.service';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const { useToken } = theme;
|
||||||
|
|
||||||
|
const TimerButton = () => {
|
||||||
|
const [runningTimers, setRunningTimers] = useState<IRunningTimer[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [currentTimes, setCurrentTimes] = useState<Record<string, string>>({});
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const { t } = useTranslation('navbar');
|
||||||
|
const { token } = useToken();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { socket } = useSocket();
|
||||||
|
|
||||||
|
const fetchRunningTimers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await taskTimeLogsApiService.getRunningTimers();
|
||||||
|
if (response.done) {
|
||||||
|
setRunningTimers(response.body || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching running timers:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateCurrentTimes = () => {
|
||||||
|
const newTimes: Record<string, string> = {};
|
||||||
|
runningTimers.forEach(timer => {
|
||||||
|
const startTime = moment(timer.start_time);
|
||||||
|
const now = moment();
|
||||||
|
const duration = moment.duration(now.diff(startTime));
|
||||||
|
const hours = Math.floor(duration.asHours());
|
||||||
|
const minutes = duration.minutes();
|
||||||
|
const seconds = duration.seconds();
|
||||||
|
newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
setCurrentTimes(newTimes);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRunningTimers();
|
||||||
|
|
||||||
|
// Set up polling to refresh timers every 30 seconds
|
||||||
|
const pollInterval = setInterval(() => {
|
||||||
|
fetchRunningTimers();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => clearInterval(pollInterval);
|
||||||
|
}, [fetchRunningTimers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (runningTimers.length > 0) {
|
||||||
|
updateCurrentTimes();
|
||||||
|
const interval = setInterval(updateCurrentTimes, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [runningTimers]);
|
||||||
|
|
||||||
|
// Listen for timer start/stop events and project updates to refresh the count
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
const handleTimerStart = (data: string) => {
|
||||||
|
try {
|
||||||
|
const { id } = typeof data === 'string' ? JSON.parse(data) : data;
|
||||||
|
if (id) {
|
||||||
|
// Refresh the running timers list when a new timer is started
|
||||||
|
fetchRunningTimers();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing timer start event:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimerStop = (data: string) => {
|
||||||
|
try {
|
||||||
|
const { id } = typeof data === 'string' ? JSON.parse(data) : data;
|
||||||
|
if (id) {
|
||||||
|
// Refresh the running timers list when a timer is stopped
|
||||||
|
fetchRunningTimers();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing timer stop event:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProjectUpdates = () => {
|
||||||
|
// Refresh timers when project updates are available
|
||||||
|
fetchRunningTimers();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
|
||||||
|
socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
|
||||||
|
socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
|
||||||
|
socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
|
||||||
|
socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
|
||||||
|
};
|
||||||
|
}, [socket, fetchRunningTimers]);
|
||||||
|
|
||||||
|
const hasRunningTimers = () => {
|
||||||
|
return runningTimers.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timerCount = () => {
|
||||||
|
return runningTimers.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopTimer = (taskId: string) => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId }));
|
||||||
|
dispatch(updateTaskTimeTracking({ taskId, timeTracking: null }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropdownContent = (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 350,
|
||||||
|
maxHeight: 400,
|
||||||
|
overflow: 'auto',
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
popupRender={() => dropdownContent}
|
||||||
|
trigger={['click']}
|
||||||
|
placement="bottomRight"
|
||||||
|
open={dropdownOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDropdownOpen(open);
|
||||||
|
if (open) {
|
||||||
|
fetchRunningTimers();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimerButton;
|
||||||
@@ -89,7 +89,7 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleSelectTask = useCallback((task : IMyTask) => {
|
const handleSelectTask = useCallback((task: IMyTask) => {
|
||||||
dispatch(setSelectedTaskId(task.id || ''));
|
dispatch(setSelectedTaskId(task.id || ''));
|
||||||
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
|
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
|
||||||
dispatch(setProjectId(task.project_id || ''));
|
dispatch(setProjectId(task.project_id || ''));
|
||||||
@@ -155,7 +155,7 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
return (
|
return (
|
||||||
<Tooltip title={record.project_name}>
|
<Tooltip title={record.project_name}>
|
||||||
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth:120 }} ellipsis={{ tooltip: true }}>
|
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth: 120 }} ellipsis={{ tooltip: true }}>
|
||||||
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
|
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
|
||||||
{record.project_name}
|
{record.project_name}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
@@ -271,10 +271,10 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
columns={columns as TableProps<IMyTask>['columns']}
|
columns={columns as TableProps<IMyTask>['columns']}
|
||||||
size="middle"
|
size="middle"
|
||||||
rowClassName={() => 'custom-row-height'}
|
rowClassName={() => 'custom-row-height'}
|
||||||
loading={homeTasksFetching && !skipAutoRefetch}
|
loading={homeTasksFetching && skipAutoRefetch}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ marginTop: 16, textAlign: 'right', display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ marginTop: 16, textAlign: 'right', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<Pagination
|
<Pagination
|
||||||
current={currentPage}
|
current={currentPage}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ const ProjectView = () => {
|
|||||||
onChange={handleTabChange}
|
onChange={handleTabChange}
|
||||||
items={tabMenuItems}
|
items={tabMenuItems}
|
||||||
tabBarStyle={{ paddingInline: 0 }}
|
tabBarStyle={{ paddingInline: 0 }}
|
||||||
destroyInactiveTabPane={true}
|
destroyOnHidden={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{portalElements}
|
{portalElements}
|
||||||
|
|||||||
@@ -19,36 +19,19 @@ const ProjectViewTaskList = () => {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||||
|
|
||||||
// Combine related selectors to reduce subscriptions
|
// Split selectors to prevent unnecessary rerenders
|
||||||
const {
|
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||||
projectId,
|
const taskGroups = useAppSelector(state => state.taskReducer.taskGroups);
|
||||||
taskGroups,
|
const loadingGroups = useAppSelector(state => state.taskReducer.loadingGroups);
|
||||||
loadingGroups,
|
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
|
||||||
groupBy,
|
const archived = useAppSelector(state => state.taskReducer.archived);
|
||||||
archived,
|
const fields = useAppSelector(state => state.taskReducer.fields);
|
||||||
fields,
|
const search = useAppSelector(state => state.taskReducer.search);
|
||||||
search,
|
|
||||||
} = useAppSelector(state => ({
|
|
||||||
projectId: state.projectReducer.projectId,
|
|
||||||
taskGroups: state.taskReducer.taskGroups,
|
|
||||||
loadingGroups: state.taskReducer.loadingGroups,
|
|
||||||
groupBy: state.taskReducer.groupBy,
|
|
||||||
archived: state.taskReducer.archived,
|
|
||||||
fields: state.taskReducer.fields,
|
|
||||||
search: state.taskReducer.search,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const {
|
const statusCategories = useAppSelector(state => state.taskStatusReducer.statusCategories);
|
||||||
statusCategories,
|
const loadingStatusCategories = useAppSelector(state => state.taskStatusReducer.loading);
|
||||||
loading: loadingStatusCategories,
|
|
||||||
} = useAppSelector(state => ({
|
|
||||||
statusCategories: state.taskStatusReducer.statusCategories,
|
|
||||||
loading: state.taskStatusReducer.loading,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { loadingPhases } = useAppSelector(state => ({
|
const loadingPhases = useAppSelector(state => state.phaseReducer.loadingPhases);
|
||||||
loadingPhases: state.phaseReducer.loadingPhases,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Single source of truth for loading state - EXCLUDE labels loading from skeleton
|
// Single source of truth for loading state - EXCLUDE labels loading from skeleton
|
||||||
// Labels loading should not block the main task list display
|
// Labels loading should not block the main task list display
|
||||||
|
|||||||
Reference in New Issue
Block a user