From fbbd820512abe0e2ea762fc0fa3f4ad959666cde Mon Sep 17 00:00:00 2001 From: Dev-Tanaay Date: Tue, 1 Jul 2025 17:29:02 +0530 Subject: [PATCH 01/49] Updation in Documentation --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 2e0b1ae5..7023bafb 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,7 @@ cd worklenz 2. Set up environment variables - Copy the example environment files ```bash - cp .env.example .env - cp worklenz-backend/.env.example worklenz-backend/.env + cp worklenz-backend/.env.template worklenz-backend/.env ``` - Update the environment variables with your configuration From a47a9045e6290e252a880a7282e7e2f5ce850029 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 11:31:00 +0530 Subject: [PATCH 02/49] feat(localization): add new label management translations for multiple languages - Introduced new translation keys for label management features in Albanian, German, Spanish, Portuguese, Chinese, and English. - Updated placeholder texts, button labels, and paths to enhance user experience in the task list component. --- .../public/locales/alb/task-list-table.json | 3 +++ .../public/locales/de/task-list-table.json | 3 +++ .../public/locales/en/task-list-table.json | 3 +++ .../public/locales/es/task-list-table.json | 3 +++ .../public/locales/pt/task-list-table.json | 3 +++ .../public/locales/zh/task-list-table.json | 3 +++ .../src/components/LabelsSelector.tsx | 24 +++++++------------ 7 files changed, 26 insertions(+), 16 deletions(-) diff --git a/worklenz-frontend/public/locales/alb/task-list-table.json b/worklenz-frontend/public/locales/alb/task-list-table.json index c6e1dc44..7e3f83dd 100644 --- a/worklenz-frontend/public/locales/alb/task-list-table.json +++ b/worklenz-frontend/public/locales/alb/task-list-table.json @@ -48,6 +48,9 @@ "searchInputPlaceholder": "Kërko ose krijo", "assigneeSelectorInviteButton": "Fto një anëtar të ri me email", "labelInputPlaceholder": "Kërko ose krijo", + "searchLabelsPlaceholder": "Kërko etiketa...", + "createLabelButton": "Krijo \"{{name}}\"", + "manageLabelsPath": "Cilësimet → Etiketat", "pendingInvitation": "Ftesë në Pritje", diff --git a/worklenz-frontend/public/locales/de/task-list-table.json b/worklenz-frontend/public/locales/de/task-list-table.json index 2caa8e5c..9c2ff314 100644 --- a/worklenz-frontend/public/locales/de/task-list-table.json +++ b/worklenz-frontend/public/locales/de/task-list-table.json @@ -48,6 +48,9 @@ "searchInputPlaceholder": "Suchen oder erstellen", "assigneeSelectorInviteButton": "Neues Mitglied per E-Mail einladen", "labelInputPlaceholder": "Suchen oder erstellen", + "searchLabelsPlaceholder": "Labels suchen...", + "createLabelButton": "\"{{name}}\" erstellen", + "manageLabelsPath": "Einstellungen → Labels", "pendingInvitation": "Einladung ausstehend", diff --git a/worklenz-frontend/public/locales/en/task-list-table.json b/worklenz-frontend/public/locales/en/task-list-table.json index adea199f..5c03f203 100644 --- a/worklenz-frontend/public/locales/en/task-list-table.json +++ b/worklenz-frontend/public/locales/en/task-list-table.json @@ -48,6 +48,9 @@ "searchInputPlaceholder": "Search or create", "assigneeSelectorInviteButton": "Invite a new member by email", "labelInputPlaceholder": "Search or create", + "searchLabelsPlaceholder": "Search labels...", + "createLabelButton": "Create \"{{name}}\"", + "manageLabelsPath": "Settings → Labels", "pendingInvitation": "Pending Invitation", diff --git a/worklenz-frontend/public/locales/es/task-list-table.json b/worklenz-frontend/public/locales/es/task-list-table.json index c67225de..0648c2ff 100644 --- a/worklenz-frontend/public/locales/es/task-list-table.json +++ b/worklenz-frontend/public/locales/es/task-list-table.json @@ -48,6 +48,9 @@ "searchInputPlaceholder": "Buscar o crear", "assigneeSelectorInviteButton": "Invitar a un nuevo miembro por correo", "labelInputPlaceholder": "Buscar o crear", + "searchLabelsPlaceholder": "Buscar etiquetas...", + "createLabelButton": "Crear \"{{name}}\"", + "manageLabelsPath": "Configuración → Etiquetas", "pendingInvitation": "Invitación pendiente", diff --git a/worklenz-frontend/public/locales/pt/task-list-table.json b/worklenz-frontend/public/locales/pt/task-list-table.json index b7f90398..f53d834f 100644 --- a/worklenz-frontend/public/locales/pt/task-list-table.json +++ b/worklenz-frontend/public/locales/pt/task-list-table.json @@ -48,6 +48,9 @@ "searchInputPlaceholder": "Buscar ou criar", "assigneeSelectorInviteButton": "Convide um novo membro por e-mail", "labelInputPlaceholder": "Buscar ou criar", + "searchLabelsPlaceholder": "Buscar etiquetas...", + "createLabelButton": "Criar \"{{name}}\"", + "manageLabelsPath": "Configurações → Etiquetas", "pendingInvitation": "Convite Pendente", diff --git a/worklenz-frontend/public/locales/zh/task-list-table.json b/worklenz-frontend/public/locales/zh/task-list-table.json index d2f9634c..f3ec040f 100644 --- a/worklenz-frontend/public/locales/zh/task-list-table.json +++ b/worklenz-frontend/public/locales/zh/task-list-table.json @@ -43,6 +43,9 @@ "searchInputPlaceholder": "搜索或创建", "assigneeSelectorInviteButton": "通过电子邮件邀请新成员", "labelInputPlaceholder": "搜索或创建", + "searchLabelsPlaceholder": "搜索标签...", + "createLabelButton": "创建 \"{{name}}\"", + "manageLabelsPath": "设置 → 标签", "pendingInvitation": "待处理邀请", "contextMenu": { "assignToMe": "分配给我", diff --git a/worklenz-frontend/src/components/LabelsSelector.tsx b/worklenz-frontend/src/components/LabelsSelector.tsx index 37f39c78..350e5aad 100644 --- a/worklenz-frontend/src/components/LabelsSelector.tsx +++ b/worklenz-frontend/src/components/LabelsSelector.tsx @@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react' import { createPortal } from 'react-dom'; import { useSelector } from 'react-redux'; import { PlusOutlined, TagOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; import { RootState } from '@/app/store'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { ITaskLabel } from '@/types/tasks/taskLabel.types'; @@ -26,6 +27,7 @@ const LabelsSelector: React.FC = ({ task, isDarkMode = fals const { labels } = useSelector((state: RootState) => state.taskLabelsReducer); const currentSession = useAuthService().getCurrentSession(); const { socket } = useSocket(); + const { t } = useTranslation('task-list-table'); const filteredLabels = useMemo(() => { return ( @@ -203,7 +205,7 @@ const LabelsSelector: React.FC = ({ task, isDarkMode = fals value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Search labels..." + placeholder={t('searchLabelsPlaceholder')} className={` w-full px-2 py-1 text-xs rounded border ${ @@ -257,7 +259,7 @@ const LabelsSelector: React.FC = ({ task, isDarkMode = fals
-
No labels found
+
{t('noLabelsFound')}
{searchQuery.trim() && ( )}
@@ -279,20 +281,10 @@ const LabelsSelector: React.FC = ({ task, isDarkMode = fals {/* Footer */}
- + {t('manageLabelsPath')} +
, document.body From bc6a15de8f14bd0467906ae0aee9fcc42ab73dbc Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 11:41:09 +0530 Subject: [PATCH 03/49] feat(localization): add 'share' label translations for multiple languages - Added the 'share' label to project view headers in Albanian, German, Spanish, Portuguese, Chinese, and English to enhance user interaction. - Updated corresponding button icons and labels in the project view header for improved functionality and consistency. --- .../public/locales/alb/project-view/project-view-header.json | 1 + .../public/locales/de/project-view/project-view-header.json | 1 + .../public/locales/en/project-view/project-view-header.json | 1 + .../public/locales/es/project-view/project-view-header.json | 1 + .../public/locales/pt/project-view/project-view-header.json | 1 + .../public/locales/zh/project-view/project-view-header.json | 1 + .../src/pages/projects/projectView/project-view-header.tsx | 5 +++-- 7 files changed, 9 insertions(+), 2 deletions(-) diff --git a/worklenz-frontend/public/locales/alb/project-view/project-view-header.json b/worklenz-frontend/public/locales/alb/project-view/project-view-header.json index f12bdd8d..51d91ba1 100644 --- a/worklenz-frontend/public/locales/alb/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/alb/project-view/project-view-header.json @@ -14,6 +14,7 @@ "refreshProject": "Rifresko projektin", "saveAsTemplate": "Ruaj si model", "invite": "Fto", + "share": "Ndaj", "subscribeTooltip": "Abonohu tek njoftimet e projektit", "unsubscribeTooltip": "Çabonohu nga njoftimet e projektit", "refreshTooltip": "Rifresko të dhënat e projektit", diff --git a/worklenz-frontend/public/locales/de/project-view/project-view-header.json b/worklenz-frontend/public/locales/de/project-view/project-view-header.json index dae5f67a..c52c6052 100644 --- a/worklenz-frontend/public/locales/de/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/de/project-view/project-view-header.json @@ -14,6 +14,7 @@ "refreshProject": "Projekt aktualisieren", "saveAsTemplate": "Als Vorlage speichern", "invite": "Einladen", + "share": "Teilen", "subscribeTooltip": "Projektbenachrichtigungen abonnieren", "unsubscribeTooltip": "Projektbenachrichtigungen beenden", "refreshTooltip": "Projektdaten aktualisieren", diff --git a/worklenz-frontend/public/locales/en/project-view/project-view-header.json b/worklenz-frontend/public/locales/en/project-view/project-view-header.json index 536ccad4..1bbb6c15 100644 --- a/worklenz-frontend/public/locales/en/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/en/project-view/project-view-header.json @@ -14,6 +14,7 @@ "refreshProject": "Refresh project", "saveAsTemplate": "Save as template", "invite": "Invite", + "share": "Share", "subscribeTooltip": "Subscribe to project notifications", "unsubscribeTooltip": "Unsubscribe from project notifications", "refreshTooltip": "Refresh project data", diff --git a/worklenz-frontend/public/locales/es/project-view/project-view-header.json b/worklenz-frontend/public/locales/es/project-view/project-view-header.json index c6fb854b..0215b89c 100644 --- a/worklenz-frontend/public/locales/es/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/es/project-view/project-view-header.json @@ -14,6 +14,7 @@ "refreshProject": "Actualizar proyecto", "saveAsTemplate": "Guardar como plantilla", "invite": "Invitar", + "share": "Compartir", "subscribeTooltip": "Suscribirse a notificaciones del proyecto", "unsubscribeTooltip": "Cancelar suscripción a notificaciones del proyecto", "refreshTooltip": "Actualizar datos del proyecto", diff --git a/worklenz-frontend/public/locales/pt/project-view/project-view-header.json b/worklenz-frontend/public/locales/pt/project-view/project-view-header.json index 6e295e38..4649b768 100644 --- a/worklenz-frontend/public/locales/pt/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/pt/project-view/project-view-header.json @@ -14,6 +14,7 @@ "refreshProject": "Atualizar projeto", "saveAsTemplate": "Salvar como modelo", "invite": "Convidar", + "share": "Compartilhar", "subscribeTooltip": "Inscrever-se nas notificações do projeto", "unsubscribeTooltip": "Cancelar inscrição nas notificações do projeto", "refreshTooltip": "Atualizar dados do projeto", diff --git a/worklenz-frontend/public/locales/zh/project-view/project-view-header.json b/worklenz-frontend/public/locales/zh/project-view/project-view-header.json index ca0ead5c..9f8ca8ed 100644 --- a/worklenz-frontend/public/locales/zh/project-view/project-view-header.json +++ b/worklenz-frontend/public/locales/zh/project-view/project-view-header.json @@ -14,6 +14,7 @@ "refreshProject": "刷新项目", "saveAsTemplate": "保存为模板", "invite": "邀请", + "share": "分享", "subscribeTooltip": "订阅项目通知", "unsubscribeTooltip": "取消订阅项目通知", "refreshTooltip": "刷新项目数据", diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx index 24a577c7..4132f0c7 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx @@ -65,6 +65,7 @@ import { addTaskCardToTheTop, fetchBoardTaskGroups } from '@/features/board/boar import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; +import { ShareAltOutlined } from '@ant-design/icons'; const ProjectViewHeader = memo(() => { const navigate = useNavigate(); @@ -395,8 +396,8 @@ const ProjectViewHeader = memo(() => { if (isOwnerOrAdmin || isProjectManager) { actions.push( - ); From ab7ca33ac10aef27ed56009a807b2b25c3e5b23b Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 11:58:40 +0530 Subject: [PATCH 04/49] feat(localization): improve translation handling and update UI labels - Updated the 'board' label in project-view translations for consistency. - Enhanced the getTabLabel function to include fallback labels for better user experience when translations are not available. - Implemented translation loading checks in ProjectView to ensure labels are updated correctly based on the selected language. - Refactored tab label updates to handle translation loading errors gracefully. --- .../public/locales/en/project-view.json | 2 +- .../src/lib/project/project-view-constants.ts | 79 +++++++++++++------ .../projects/projectView/project-view.tsx | 49 +++++++++--- 3 files changed, 93 insertions(+), 37 deletions(-) diff --git a/worklenz-frontend/public/locales/en/project-view.json b/worklenz-frontend/public/locales/en/project-view.json index 16d2a0bc..82ab21f2 100644 --- a/worklenz-frontend/public/locales/en/project-view.json +++ b/worklenz-frontend/public/locales/en/project-view.json @@ -1,6 +1,6 @@ { "taskList": "Task List", - "board": "Kanban Board", + "board": "Board", "insights": "Insights", "files": "Files", "members": "Members", diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index 289b98c5..c6b71a79 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -29,9 +29,36 @@ type TabItems = { element: ReactNode; }; -// Function to get translated labels +// Function to get translated labels with fallback const getTabLabel = (key: string): string => { - return i18n.t(`project-view:${key}`); + try { + const translated = i18n.t(`project-view:${key}`); + // If translation is not loaded, it returns the key back, so we provide fallbacks + if (translated === `project-view:${key}` || translated === key) { + // Provide fallback labels + const fallbacks: Record = { + taskList: 'Task List', + board: 'Board', + insights: 'Insights', + files: 'Files', + members: 'Members', + updates: 'Updates', + }; + return fallbacks[key] || key; + } + return translated; + } catch (error) { + // Fallback labels in case of any error + const fallbacks: Record = { + taskList: 'Task List', + board: 'Board', + insights: 'Insights', + files: 'Files', + members: 'Members', + updates: 'Updates', + }; + return fallbacks[key] || key; + } }; // settings all element items use for tabs @@ -94,26 +121,30 @@ export const tabItems: TabItems[] = [ // Function to update tab labels when language changes export const updateTabLabels = () => { - tabItems.forEach(item => { - switch (item.key) { - case 'tasks-list': - item.label = getTabLabel('taskList'); - break; - case 'board': - item.label = getTabLabel('board'); - break; - case 'project-insights-member-overview': - item.label = getTabLabel('insights'); - break; - case 'all-attachments': - item.label = getTabLabel('files'); - break; - case 'members': - item.label = getTabLabel('members'); - break; - case 'updates': - item.label = getTabLabel('updates'); - break; - } - }); + try { + tabItems.forEach(item => { + switch (item.key) { + case 'tasks-list': + item.label = getTabLabel('taskList'); + break; + case 'board': + item.label = getTabLabel('board'); + break; + case 'project-insights-member-overview': + item.label = getTabLabel('insights'); + break; + case 'all-attachments': + item.label = getTabLabel('files'); + break; + case 'members': + item.label = getTabLabel('members'); + break; + case 'updates': + item.label = getTabLabel('updates'); + break; + } + }); + } catch (error) { + console.error('Error updating tab labels:', error); + } }; diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index 7509d74b..c0228007 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -7,8 +7,6 @@ import { Button, ConfigProvider, Flex, - Tooltip, - Badge, Tabs, PushpinFilled, PushpinOutlined, @@ -20,7 +18,6 @@ import { useAppSelector } from '@/hooks/useAppSelector'; import { getProject, setProjectId, setProjectView } from '@/features/project/project.slice'; import { fetchStatuses, resetStatuses } from '@/features/taskAttributes/taskStatusSlice'; import { projectsApiService } from '@/api/projects/projects.api.service'; -import { colors } from '@/styles/colors'; import { useDocumentTitle } from '@/hooks/useDoumentTItle'; import ProjectViewHeader from './project-view-header'; import './project-view.css'; @@ -42,6 +39,7 @@ import { resetState as resetEnhancedKanbanState } from '@/features/enhanced-kanb import { setProjectId as setInsightsProjectId } from '@/features/projects/insights/project-insights.slice'; import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; import { useTranslation } from 'react-i18next'; +import { ensureTranslationsLoaded } from '@/i18n'; // Import critical components synchronously to avoid suspense interruptions import TaskDrawer from '@components/task-drawer/task-drawer'; @@ -64,12 +62,15 @@ const ProjectView = React.memo(() => { const dispatch = useAppDispatch(); const [searchParams] = useSearchParams(); const { projectId } = useParams(); - const { t } = useTranslation('project-view'); + const { t, i18n } = useTranslation('project-view'); // Memoized selectors to prevent unnecessary re-renders const selectedProject = useAppSelector(state => state.projectReducer.project); const projectLoading = useAppSelector(state => state.projectReducer.projectLoading); + // State to track translation loading + const [translationsReady, setTranslationsReady] = useState(false); + // Optimize document title updates useDocumentTitle(selectedProject?.name || t('projectView')); @@ -95,6 +96,30 @@ const ProjectView = React.memo(() => { setTaskId(urlParams.taskId); }, [urlParams]); + // Ensure translations are loaded for project-view namespace + useEffect(() => { + const loadTranslations = async () => { + try { + await ensureTranslationsLoaded(['project-view'], [i18n.language]); + updateTabLabels(); + setTranslationsReady(true); + } catch (error) { + console.error('Failed to load project-view translations:', error); + // Set ready to true anyway to prevent infinite loading + setTranslationsReady(true); + } + }; + + loadTranslations(); + }, [i18n.language]); + + // Update tab labels when language changes + useEffect(() => { + if (translationsReady) { + updateTabLabels(); + } + }, [t, translationsReady]); + // Comprehensive cleanup function for when leaving project view entirely const resetAllProjectData = useCallback(() => { dispatch(setProjectId(null)); @@ -176,11 +201,6 @@ const ProjectView = React.memo(() => { setIsInitialized(false); }, [projectId]); - // Update tab labels when language changes - useEffect(() => { - updateTabLabels(); - }, [t]); - // Effect for handling task drawer opening from URL params useEffect(() => { if (taskid && isInitialized) { @@ -250,6 +270,11 @@ const ProjectView = React.memo(() => { // Memoized tab menu items with enhanced styling const tabMenuItems = useMemo(() => { + // Only render tabs when translations are ready + if (!translationsReady) { + return []; + } + const menuItems = tabItems.map(item => ({ key: item.key, label: ( @@ -304,7 +329,7 @@ const ProjectView = React.memo(() => { })); return menuItems; - }, [pinnedTab, pinToDefaultTab, t]); + }, [pinnedTab, pinToDefaultTab, t, translationsReady]); // Optimized secondary components loading with better UX const [shouldLoadSecondaryComponents, setShouldLoadSecondaryComponents] = useState(false); @@ -341,8 +366,8 @@ const ProjectView = React.memo(() => { [shouldLoadSecondaryComponents] ); - // Show loading state while project is being fetched - if (projectLoading || !isInitialized) { + // Show loading state while project is being fetched or translations are loading + if (projectLoading || !isInitialized || !translationsReady) { return (
From 6dba080ade7a1a65f4b58f8e08f8c5171d2b0216 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 12:11:11 +0530 Subject: [PATCH 05/49] refactor(task-drawer): streamline task name handling and enhance socket management - Removed local socket listener for task name changes, relying on global socket handlers for real-time updates. - Simplified task name change handling logic to improve clarity and maintainability. - Enhanced task status group matching logic in useTaskSocketHandlers for better accuracy with multiple matching strategies. - Added detailed logging for task movement and status changes to aid in debugging and tracking. --- .../task-drawer-header/task-drawer-header.tsx | 22 +----- .../src/hooks/useTaskSocketHandlers.ts | 78 ++++++++++++++++++- 2 files changed, 78 insertions(+), 22 deletions(-) diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx index 735544ea..a41f56c0 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx @@ -19,6 +19,7 @@ import { deleteBoardTask, updateTaskName } from '@/features/board/board-slice'; import { updateEnhancedKanbanTaskName } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import useTabSearchParam from '@/hooks/useTabSearchParam'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { ITaskViewModel } from '@/types/tasks/task.types'; type TaskDrawerHeaderProps = { inputRef: React.RefObject; @@ -89,22 +90,6 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { }, ]; - const handleReceivedTaskNameChange = (data: { - id: string; - parent_task: string; - name: string; - }) => { - if (data.id === selectedTaskId) { - const taskData = { ...data, manual_progress: false } as IProjectTask; - dispatch(updateTaskName({ task: taskData })); - - // Also update enhanced kanban if on board tab - if (tab === 'board') { - dispatch(updateEnhancedKanbanTaskName({ task: taskData })); - } - } - }; - const handleInputBlur = () => { setIsEditing(false); if ( @@ -124,9 +109,8 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { parent_task: taskFormViewModel?.task?.parent_task_id, }) ); - socket?.once(SocketEvents.TASK_NAME_CHANGE.toString(), (data: any) => { - handleReceivedTaskNameChange(data); - }); + // Note: Real-time updates are handled by the global useTaskSocketHandlers hook + // No need for local socket listeners that could interfere with global handlers }; return ( diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index cbcb46c1..cf857653 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -243,10 +243,64 @@ export const useTaskSocketHandlers = () => { // Find current group containing the task const currentGroup = groups.find(group => group.taskIds.includes(response.id)); - // Find target group based on new status value (not UUID) - const targetGroup = groups.find(group => group.groupValue === newStatusValue); + // Find target group based on new status value with multiple matching strategies + let targetGroup = groups.find(group => group.groupValue === newStatusValue); + + // If not found, try case-insensitive matching + if (!targetGroup) { + targetGroup = groups.find(group => + group.groupValue?.toLowerCase() === newStatusValue.toLowerCase() + ); + } + + // If still not found, try matching with title + if (!targetGroup) { + targetGroup = groups.find(group => + group.title?.toLowerCase() === newStatusValue.toLowerCase() + ); + } + + // If still not found, try matching common status patterns + if (!targetGroup && newStatusValue === 'todo') { + targetGroup = groups.find(group => + group.title?.toLowerCase().includes('todo') || + group.title?.toLowerCase().includes('to do') || + group.title?.toLowerCase().includes('pending') || + group.groupValue?.toLowerCase().includes('todo') + ); + } else if (!targetGroup && newStatusValue === 'doing') { + targetGroup = groups.find(group => + group.title?.toLowerCase().includes('doing') || + group.title?.toLowerCase().includes('progress') || + group.title?.toLowerCase().includes('active') || + group.groupValue?.toLowerCase().includes('doing') + ); + } else if (!targetGroup && newStatusValue === 'done') { + targetGroup = groups.find(group => + group.title?.toLowerCase().includes('done') || + group.title?.toLowerCase().includes('complete') || + group.title?.toLowerCase().includes('finish') || + group.groupValue?.toLowerCase().includes('done') + ); + } + + console.log('🔄 Status change group movement debug:', { + taskId: response.id, + newStatusValue, + currentGroupId: currentGroup?.id, + currentGroupValue: currentGroup?.groupValue, + currentGroupTitle: currentGroup?.title, + targetGroupId: targetGroup?.id, + targetGroupValue: targetGroup?.groupValue, + targetGroupTitle: targetGroup?.title, + allGroups: groups.map(g => ({ id: g.id, title: g.title, groupValue: g.groupValue })) + }); if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) { + console.log('✅ Moving task between groups:', { + from: currentGroup.title, + to: targetGroup.title + }); // Use the action to move task between groups dispatch( moveTaskBetweenGroups({ @@ -255,8 +309,12 @@ export const useTaskSocketHandlers = () => { targetGroupId: targetGroup.id, }) ); + } else if (!targetGroup) { + console.log('❌ Target group not found for status:', newStatusValue); + } else if (!currentGroup) { + console.log('❌ Current group not found for task:', response.id); } else { - console.log('🔧 No group movement needed for status change'); + console.log('🔧 No group movement needed - task already in correct group'); } } else { console.log('🔧 Not grouped by status, skipping group movement'); @@ -628,7 +686,21 @@ export const useTaskSocketHandlers = () => { const handleTaskDescriptionChange = useCallback( (data: { id: string; parent_task: string; description: string }) => { if (!data) return; + + // Update the old task slice (for backward compatibility) dispatch(updateTaskDescription(data)); + + // Update task-management slice for task-list-v2 components + const currentTask = store.getState().taskManagement.entities[data.id]; + if (currentTask) { + const updatedTask: Task = { + ...currentTask, + description: data.description, + updatedAt: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + dispatch(updateTask(updatedTask)); + } }, [dispatch] ); From 29a09ec500b97d177fbe40606cf2377ac628924b Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 12:22:37 +0530 Subject: [PATCH 06/49] refactor(task-drawer): enhance task deletion handling and update imports - Updated task deletion logic to ensure consistency across task management slices, including clearing selections and updating the board state. - Refactored imports to streamline task management functionality and improve code clarity. - Added new Ant Design icon import for enhanced UI options. --- .../task-drawer-header/task-drawer-header.tsx | 24 ++++++++++--------- worklenz-frontend/src/shared/antd-imports.ts | 1 + 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx index a41f56c0..4777257a 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx @@ -15,10 +15,10 @@ import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import useTaskDrawerUrlSync from '@/hooks/useTaskDrawerUrlSync'; import { deleteTask } from '@/features/tasks/tasks.slice'; -import { deleteBoardTask, updateTaskName } from '@/features/board/board-slice'; -import { updateEnhancedKanbanTaskName } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { deleteTask as deleteTaskFromManagement } from '@/features/task-management/task-management.slice'; +import { deselectTask } from '@/features/task-management/selection.slice'; +import { deleteBoardTask } from '@/features/board/board-slice'; import useTabSearchParam from '@/hooks/useTabSearchParam'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { ITaskViewModel } from '@/types/tasks/task.types'; type TaskDrawerHeaderProps = { @@ -30,7 +30,6 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { const dispatch = useAppDispatch(); const { socket, connected } = useSocket(); const { clearTaskFromUrl } = useTaskDrawerUrlSync(); - const { tab } = useTabSearchParam(); const isDeleting = useRef(false); const [isEditing, setIsEditing] = useState(false); @@ -54,16 +53,19 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { const res = await tasksApiService.deleteTask(selectedTaskId); if (res.done) { - // Explicitly clear the task parameter from URL - clearTaskFromUrl(); + // Update all relevant slices to ensure task is removed everywhere + dispatch(deleteTask({ taskId: selectedTaskId })); // Old tasks slice + dispatch(deleteTaskFromManagement(selectedTaskId)); // Task management slice (TaskListV2) + dispatch(deselectTask(selectedTaskId)); // Remove from selection if selected + dispatch(deleteBoardTask({ sectionId: '', taskId: selectedTaskId })); // Board slice - dispatch(setShowTaskDrawer(false)); + // Clear the task drawer state and URL dispatch(setSelectedTaskId(null)); - dispatch(deleteTask({ taskId: selectedTaskId })); - dispatch(deleteBoardTask({ sectionId: '', taskId: selectedTaskId })); - - // Reset the flag after a short delay + dispatch(setShowTaskDrawer(false)); + + // Clear the URL parameter last to avoid race conditions setTimeout(() => { + clearTaskFromUrl(); isDeleting.current = false; }, 100); if (taskFormViewModel?.task?.parent_task_id) { diff --git a/worklenz-frontend/src/shared/antd-imports.ts b/worklenz-frontend/src/shared/antd-imports.ts index 9b8498c7..bd0b31bd 100644 --- a/worklenz-frontend/src/shared/antd-imports.ts +++ b/worklenz-frontend/src/shared/antd-imports.ts @@ -101,6 +101,7 @@ export { DoubleRightOutlined, UserAddOutlined, ArrowsAltOutlined, + EllipsisOutlined } from '@ant-design/icons'; // Re-export all components with React From 10c53d954e2594059395ab6b053b416e400547c6 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 12:32:17 +0530 Subject: [PATCH 07/49] refactor(task-list): unify date handling and enhance column widths - Updated TaskRow component to handle both camelCase and snake_case date fields for created and updated timestamps. - Adjusted column widths for due date, start date, completed date, created date, and last updated fields for better layout consistency. - Ensured whitespace handling in date display spans for improved UI presentation. --- .../src/components/task-list-v2/TaskRow.tsx | 29 ++++++++++--------- .../task-list-v2/constants/columns.ts | 10 +++---- .../task-management/task-management.slice.ts | 16 +++++----- .../src/types/task-management.types.ts | 7 +++-- 4 files changed, 32 insertions(+), 30 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index b3130cf8..bf2a663c 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -161,9 +161,9 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn })(), start: task.startDate ? formatDate(task.startDate) : null, completed: task.completedAt ? formatDate(task.completedAt) : null, - created: task.created_at ? formatDate(task.created_at) : null, + created: (task.createdAt || task.created_at) ? formatDate(task.createdAt || task.created_at) : null, updated: task.updatedAt ? formatDate(task.updatedAt) : null, - }), [task.dueDate, task.due_date, task.startDate, task.completedAt, task.created_at, task.updatedAt]); + }), [task.dueDate, task.due_date, task.startDate, task.completedAt, task.createdAt, task.created_at, task.updatedAt]); // Memoize date values for DatePicker const dateValues = useMemo( @@ -517,11 +517,11 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn }} > {formattedDates.due ? ( - + {formattedDates.due} ) : ( - + {t('setDueDate')} )} @@ -640,11 +640,11 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn }} > {formattedDates.start ? ( - + {formattedDates.start} ) : ( - + {t('setStartDate')} )} @@ -657,11 +657,11 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn return (
{formattedDates.completed ? ( - + {formattedDates.completed} ) : ( - - + - )}
); @@ -670,24 +670,25 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn return (
{formattedDates.created ? ( - + {formattedDates.created} ) : ( - - + - )}
); case 'lastUpdated': + console.log('formattedDates.updated', formattedDates.updated); return (
{formattedDates.updated ? ( - + {formattedDates.updated} ) : ( - - + - )}
); @@ -696,9 +697,9 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn return (
{task.reporter ? ( - {task.reporter} + {task.reporter} ) : ( - - + - )}
); diff --git a/worklenz-frontend/src/components/task-list-v2/constants/columns.ts b/worklenz-frontend/src/components/task-list-v2/constants/columns.ts index 8f105f12..d8b229fe 100644 --- a/worklenz-frontend/src/components/task-list-v2/constants/columns.ts +++ b/worklenz-frontend/src/components/task-list-v2/constants/columns.ts @@ -21,16 +21,16 @@ export const BASE_COLUMNS = [ { id: 'status', label: 'statusColumn', width: '120px', key: COLUMN_KEYS.STATUS }, { id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES }, { id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY }, - { id: 'dueDate', label: 'dueDateColumn', width: '120px', key: COLUMN_KEYS.DUE_DATE }, + { id: 'dueDate', label: 'dueDateColumn', width: '140px', key: COLUMN_KEYS.DUE_DATE }, { id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS }, { id: 'labels', label: 'labelsColumn', width: 'auto', key: COLUMN_KEYS.LABELS }, { id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE }, { id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING }, { id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION }, - { id: 'startDate', label: 'startDateColumn', width: '120px', key: COLUMN_KEYS.START_DATE }, + { id: 'startDate', label: 'startDateColumn', width: '140px', key: COLUMN_KEYS.START_DATE }, { id: 'dueTime', label: 'dueTimeColumn', width: '120px', key: COLUMN_KEYS.DUE_TIME }, - { id: 'completedDate', label: 'completedDateColumn', width: '120px', key: COLUMN_KEYS.COMPLETED_DATE }, - { id: 'createdDate', label: 'createdDateColumn', width: '120px', key: COLUMN_KEYS.CREATED_DATE }, - { id: 'lastUpdated', label: 'lastUpdatedColumn', width: '120px', key: COLUMN_KEYS.LAST_UPDATED }, + { id: 'completedDate', label: 'completedDateColumn', width: '140px', key: COLUMN_KEYS.COMPLETED_DATE }, + { id: 'createdDate', label: 'createdDateColumn', width: '140px', key: COLUMN_KEYS.CREATED_DATE }, + { id: 'lastUpdated', label: 'lastUpdatedColumn', width: '140px', key: COLUMN_KEYS.LAST_UPDATED }, { id: 'reporter', label: 'reporterColumn', width: '120px', key: COLUMN_KEYS.REPORTER }, ]; \ No newline at end of file diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index 9a41b589..54eee67d 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -171,10 +171,10 @@ export const fetchTasks = createAsyncThunk( logged: convertTimeValue(task.time_spent), }, customFields: {}, - createdAt: task.created_at || new Date().toISOString(), - updatedAt: task.updated_at || new Date().toISOString(), - created_at: task.created_at || new Date().toISOString(), - updated_at: task.updated_at || new Date().toISOString(), + createdAt: task.createdAt || task.created_at || new Date().toISOString(), + updatedAt: task.updatedAt || task.updated_at || new Date().toISOString(), + created_at: task.createdAt || task.created_at || new Date().toISOString(), + updated_at: task.updatedAt || task.updated_at || new Date().toISOString(), order: typeof task.sort_order === 'number' ? task.sort_order : 0, // Ensure all Task properties are mapped, even if undefined in API response sub_tasks: task.sub_tasks || [], @@ -285,10 +285,10 @@ export const fetchTasksV3 = createAsyncThunk( }, customFields: {}, custom_column_values: task.custom_column_values || {}, - createdAt: task.created_at || now, - updatedAt: task.updated_at || now, - created_at: task.created_at || now, - updated_at: task.updated_at || now, + createdAt: task.createdAt || task.created_at || now, + updatedAt: task.updatedAt || task.updated_at || now, + created_at: task.createdAt || task.created_at || now, + updated_at: task.updatedAt || task.updated_at || now, order: typeof task.sort_order === 'number' ? task.sort_order : 0, sub_tasks: task.sub_tasks || [], sub_tasks_count: task.sub_tasks_count || 0, diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index 32518142..cb0e749d 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -19,9 +19,10 @@ export interface Task { dueDate?: string; // Alternative due date field startDate?: string; // Start date field completedAt?: string; // Completion date - updatedAt?: string; // Update timestamp - created_at: string; - updated_at: string; + updatedAt?: string; // Update timestamp (camelCase from API) + createdAt?: string; // Creation timestamp (camelCase from API) + created_at: string; // Creation timestamp (snake_case, legacy) + updated_at: string; // Update timestamp (snake_case, legacy) sub_tasks?: Task[]; sub_tasks_count?: number; show_sub_tasks?: boolean; From fadc115412ece97286f0026b32e300b43c720695 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 13:01:51 +0530 Subject: [PATCH 08/49] feat(task-reporter): add reporter field to task data structure - Introduced a new 'reporter' field in the task data structure for both backend and frontend task management. - Updated the tasks-controller to include the reporter information when transforming task data. - Modified the fetchTasks and fetchTasksV3 functions to handle the reporter field, ensuring it defaults to undefined when not present. --- worklenz-backend/src/controllers/tasks-controller-v2.ts | 1 + .../src/features/task-management/task-management.slice.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index f5dcc666..39877ea1 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -1161,6 +1161,7 @@ export default class TasksControllerV2 extends TasksControllerBase { attachments_count: task.attachments_count || 0, has_dependencies: !!task.has_dependencies, schedule_id: task.schedule_id || null, + reporter: task.reporter || null, }; }); diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index 54eee67d..c555f6e3 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -189,6 +189,7 @@ export const fetchTasks = createAsyncThunk( attachments_count: task.attachments_count || 0, has_dependencies: task.has_dependencies || false, schedule_id: task.schedule_id || null, + reporter: task.reporter || undefined, })) ); @@ -302,6 +303,7 @@ export const fetchTasksV3 = createAsyncThunk( attachments_count: task.attachments_count || 0, has_dependencies: task.has_dependencies || false, schedule_id: task.schedule_id || null, + reporter: task.reporter || undefined, }; return transformedTask; From 04f622a7f0d7f61b2936d1fc3670c9ccb08c29dd Mon Sep 17 00:00:00 2001 From: shancds Date: Wed, 9 Jul 2025 14:21:10 +0530 Subject: [PATCH 09/49] refactor(task-list): streamline TaskListV2 component and improve structure - Removed unused imports and consolidated task list logic for better readability. - Introduced TaskListV2Section for improved organization and separation of concerns. - Enhanced task filtering and rendering logic to optimize performance and maintainability. - Updated styling and layout for a cleaner user interface and better usability. --- .../src/controllers/tasks-controller-v2.ts | 997 ++++++------------ .../components/task-list-v2/TaskListV2.tsx | 682 +----------- .../task-list-v2/TaskListV2Table.tsx | 678 ++++++++++++ 3 files changed, 1035 insertions(+), 1322 deletions(-) create mode 100644 worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index f5dcc666..79f1c7c0 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -69,13 +69,13 @@ export default class TasksControllerV2 extends TasksControllerBase { } private static getFilterByProjectsWhereClosure(text: string) { - return text ? `t.project_id IN (${this.flatString(text)})` : ""; + return text ? `project_id IN (${this.flatString(text)})` : ""; } private static getFilterByAssignee(filterBy: string) { return filterBy === "member" - ? `t.id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id = $1)` - : "t.project_id = $1"; + ? `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id = $1)` + : "project_id = $1"; } private static getStatusesQuery(filterBy: string) { @@ -130,20 +130,42 @@ export default class TasksControllerV2 extends TasksControllerBase { const filterByAssignee = TasksControllerV2.getFilterByAssignee(options.filterBy as string); // Returns statuses of each task as a json array if filterBy === "member" const statusesQuery = TasksControllerV2.getStatusesQuery(options.filterBy as string); - - // Custom columns data query - optimized with LEFT JOIN - const customColumnsQuery = options.customColumns - ? `, COALESCE(cc_data.custom_column_values, '{}'::JSONB) AS custom_column_values` + + // Custom columns data query + const customColumnsQuery = options.customColumns + ? `, (SELECT COALESCE( + jsonb_object_agg( + custom_cols.key, + custom_cols.value + ), + '{}'::JSONB + ) + FROM ( + SELECT + cc.key, + CASE + WHEN ccv.text_value IS NOT NULL THEN to_jsonb(ccv.text_value) + WHEN ccv.number_value IS NOT NULL THEN to_jsonb(ccv.number_value) + WHEN ccv.boolean_value IS NOT NULL THEN to_jsonb(ccv.boolean_value) + WHEN ccv.date_value IS NOT NULL THEN to_jsonb(ccv.date_value) + WHEN ccv.json_value IS NOT NULL THEN ccv.json_value + ELSE NULL::JSONB + END AS value + FROM cc_column_values ccv + JOIN cc_custom_columns cc ON ccv.column_id = cc.id + WHERE ccv.task_id = t.id + ) AS custom_cols + WHERE custom_cols.value IS NOT NULL) AS custom_column_values` : ""; - const archivedFilter = options.archived === "true" ? "t.archived IS TRUE" : "t.archived IS FALSE"; + const archivedFilter = options.archived === "true" ? "archived IS TRUE" : "archived IS FALSE"; let subTasksFilter; if (options.isSubtasksInclude === "true") { subTasksFilter = ""; } else { - subTasksFilter = isSubTasks ? "t.parent_task_id = $2" : "t.parent_task_id IS NULL"; + subTasksFilter = isSubTasks ? "parent_task_id = $2" : "parent_task_id IS NULL"; } const filters = [ @@ -157,171 +179,94 @@ export default class TasksControllerV2 extends TasksControllerBase { projectsFilter ].filter(i => !!i).join(" AND "); - // PERFORMANCE OPTIMIZED QUERY - Using CTEs and JOINs instead of correlated subqueries return ` - WITH task_aggregates AS ( - SELECT - t.id, - COUNT(DISTINCT sub.id) AS sub_tasks_count, - COUNT(DISTINCT CASE WHEN sub_status.is_done THEN sub.id END) AS completed_sub_tasks, - COUNT(DISTINCT tc.id) AS comments_count, - COUNT(DISTINCT ta.id) AS attachments_count, - COUNT(DISTINCT twl.id) AS work_log_count, - COALESCE(SUM(twl.time_spent), 0) AS total_minutes_spent, - MAX(CASE WHEN ts.id IS NOT NULL THEN 1 ELSE 0 END) AS has_subscribers, - MAX(CASE WHEN td.id IS NOT NULL THEN 1 ELSE 0 END) AS has_dependencies - FROM tasks t - LEFT JOIN tasks sub ON t.id = sub.parent_task_id AND sub.archived = FALSE - LEFT JOIN task_statuses sub_ts ON sub.status_id = sub_ts.id - LEFT JOIN sys_task_status_categories sub_status ON sub_ts.category_id = sub_status.id - LEFT JOIN task_comments tc ON t.id = tc.task_id - LEFT JOIN task_attachments ta ON t.id = ta.task_id - LEFT JOIN task_work_log twl ON t.id = twl.task_id - LEFT JOIN task_subscribers ts ON t.id = ts.task_id - LEFT JOIN task_dependencies td ON t.id = td.task_id - WHERE t.project_id = $1 AND t.archived = FALSE - GROUP BY t.id - ), - task_assignees AS ( - SELECT - ta.task_id, - JSON_AGG(JSON_BUILD_OBJECT( - 'team_member_id', ta.team_member_id, - 'project_member_id', ta.project_member_id, - 'name', COALESCE(tmiv.name, ''), - 'avatar_url', COALESCE(tmiv.avatar_url, ''), - 'email', COALESCE(tmiv.email, ''), - 'user_id', tmiv.user_id, - 'socket_id', COALESCE(u.socket_id, ''), - 'team_id', tmiv.team_id, - 'email_notifications_enabled', COALESCE(ns.email_notifications_enabled, false) - )) AS assignees, - STRING_AGG(COALESCE(tmiv.name, ''), ', ') AS assignee_names, - STRING_AGG(COALESCE(tmiv.name, ''), ', ') AS names - FROM tasks_assignees ta - LEFT JOIN team_member_info_view tmiv ON ta.team_member_id = tmiv.team_member_id - LEFT JOIN users u ON tmiv.user_id = u.id - LEFT JOIN notification_settings ns ON ns.user_id = u.id AND ns.team_id = tmiv.team_id - GROUP BY ta.task_id - ), - task_labels AS ( - SELECT - tl.task_id, - JSON_AGG(JSON_BUILD_OBJECT( - 'id', tl.label_id, - 'label_id', tl.label_id, - 'name', team_l.name, - 'color_code', team_l.color_code - )) AS labels, - JSON_AGG(JSON_BUILD_OBJECT( - 'id', tl.label_id, - 'label_id', tl.label_id, - 'name', team_l.name, - 'color_code', team_l.color_code - )) AS all_labels - FROM task_labels tl - JOIN team_labels team_l ON tl.label_id = team_l.id - GROUP BY tl.task_id - ) - ${options.customColumns ? `, - custom_columns_data AS ( - SELECT - ccv.task_id, - JSONB_OBJECT_AGG( - cc.key, - CASE - WHEN ccv.text_value IS NOT NULL THEN to_jsonb(ccv.text_value) - WHEN ccv.number_value IS NOT NULL THEN to_jsonb(ccv.number_value) - WHEN ccv.boolean_value IS NOT NULL THEN to_jsonb(ccv.boolean_value) - WHEN ccv.date_value IS NOT NULL THEN to_jsonb(ccv.date_value) - WHEN ccv.json_value IS NOT NULL THEN ccv.json_value - ELSE NULL::JSONB - END - ) AS custom_column_values - FROM cc_column_values ccv - JOIN cc_custom_columns cc ON ccv.column_id = cc.id - GROUP BY ccv.task_id - )` : ""} - SELECT - t.id, - t.name, - CONCAT(p.key, '-', t.task_no) AS task_key, - p.name AS project_name, - t.project_id, - t.parent_task_id, - t.parent_task_id IS NOT NULL AS is_sub_task, - parent_task.name AS parent_task_name, - t.status_id AS status, - t.archived, - t.description, - t.sort_order, - t.progress_value, - t.manual_progress, - t.weight, - p.use_manual_progress AS project_use_manual_progress, - p.use_weighted_progress AS project_use_weighted_progress, - p.use_time_progress AS project_use_time_progress, - -- Use stored progress value instead of expensive function call - COALESCE(t.progress_value, 0) AS complete_ratio, - -- Phase information via JOINs - tp.phase_id, - pp.name AS phase_name, - pp.color_code AS phase_color_code, - -- Status information via JOINs - stsc.color_code AS status_color, - stsc.color_code_dark AS status_color_dark, - JSON_BUILD_OBJECT( - 'is_done', stsc.is_done, - 'is_doing', stsc.is_doing, - 'is_todo', stsc.is_todo - ) AS status_category, - -- Aggregated counts - COALESCE(agg.sub_tasks_count, 0) AS sub_tasks_count, - COALESCE(agg.completed_sub_tasks, 0) AS completed_sub_tasks, - COALESCE(agg.comments_count, 0) AS comments_count, - COALESCE(agg.attachments_count, 0) AS attachments_count, - COALESCE(agg.total_minutes_spent, 0) AS total_minutes_spent, - CASE WHEN agg.has_subscribers > 0 THEN true ELSE false END AS has_subscribers, - CASE WHEN agg.has_dependencies > 0 THEN true ELSE false END AS has_dependencies, - -- Task completion status - CASE WHEN stsc.is_done THEN 1 ELSE 0 END AS parent_task_completed, - -- Assignees and labels via JOINs - COALESCE(assignees.assignees, '[]'::JSON) AS assignees, - COALESCE(assignees.assignee_names, '') AS assignee_names, - COALESCE(assignees.names, '') AS names, - COALESCE(labels.labels, '[]'::JSON) AS labels, - COALESCE(labels.all_labels, '[]'::JSON) AS all_labels, - -- Other fields - stsc.is_done AS is_complete, - reporter.name AS reporter, - t.priority_id AS priority, - tp_priority.value AS priority_value, - t.total_minutes, - t.created_at, - t.updated_at, - t.completed_at, - t.start_date, - t.billable, - t.schedule_id, - t.END_DATE, - -- Timer information - tt.start_time AS timer_start_time - ${customColumnsQuery} - ${statusesQuery} + SELECT id, + name, + CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no) AS task_key, + (SELECT name FROM projects WHERE id = t.project_id) AS project_name, + t.project_id AS project_id, + t.parent_task_id, + t.parent_task_id IS NOT NULL AS is_sub_task, + (SELECT name FROM tasks WHERE id = t.parent_task_id) AS parent_task_name, + (SELECT COUNT(*) + FROM tasks + WHERE parent_task_id = t.id)::INT AS sub_tasks_count, + + t.status_id AS status, + t.archived, + t.description, + t.sort_order, + t.progress_value, + t.manual_progress, + t.weight, + (SELECT use_manual_progress FROM projects WHERE id = t.project_id) AS project_use_manual_progress, + (SELECT use_weighted_progress FROM projects WHERE id = t.project_id) AS project_use_weighted_progress, + (SELECT use_time_progress FROM projects WHERE id = t.project_id) AS project_use_time_progress, + (SELECT get_task_complete_ratio(t.id)->>'ratio') AS complete_ratio, + + (SELECT phase_id FROM task_phase WHERE task_id = t.id) AS phase_id, + (SELECT name + FROM project_phases + WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_name, + (SELECT color_code + FROM project_phases + WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_color_code, + + (EXISTS(SELECT 1 FROM task_subscribers WHERE task_id = t.id)) AS has_subscribers, + (EXISTS(SELECT 1 FROM task_dependencies td WHERE td.task_id = t.id)) AS has_dependencies, + (SELECT start_time + FROM task_timers + WHERE task_id = t.id + AND user_id = '${userId}') AS timer_start_time, + + (SELECT color_code + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color, + + (SELECT color_code_dark + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color_dark, + + (SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON) + FROM (SELECT is_done, is_doing, is_todo + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) r) AS status_category, + + (SELECT COUNT(*) FROM task_comments WHERE task_id = t.id) AS comments_count, + (SELECT COUNT(*) FROM task_attachments WHERE task_id = t.id) AS attachments_count, + (CASE + WHEN EXISTS(SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE) THEN 1 + ELSE 0 END) AS parent_task_completed, + (SELECT get_task_assignees(t.id)) AS assignees, + (SELECT COUNT(*) + FROM tasks_with_status_view tt + WHERE tt.parent_task_id = t.id + AND tt.is_done IS TRUE)::INT + AS completed_sub_tasks, + + (SELECT COALESCE(JSON_AGG(r), '[]'::JSON) + FROM (SELECT task_labels.label_id AS id, + (SELECT name FROM team_labels WHERE id = task_labels.label_id), + (SELECT color_code FROM team_labels WHERE id = task_labels.label_id) + FROM task_labels + WHERE task_id = t.id) r) AS labels, + (SELECT is_completed(status_id, project_id)) AS is_complete, + (SELECT name FROM users WHERE id = t.reporter_id) AS reporter, + (SELECT id FROM task_priorities WHERE id = t.priority_id) AS priority, + (SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value, + total_minutes, + (SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent, + created_at, + updated_at, + completed_at, + start_date, + billable, + schedule_id, + END_DATE ${customColumnsQuery} ${statusesQuery} FROM tasks t - JOIN projects p ON t.project_id = p.id - JOIN task_statuses ts ON t.status_id = ts.id - JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id - LEFT JOIN tasks parent_task ON t.parent_task_id = parent_task.id - LEFT JOIN task_phase tp ON t.id = tp.task_id - LEFT JOIN project_phases pp ON tp.phase_id = pp.id - LEFT JOIN task_priorities tp_priority ON t.priority_id = tp_priority.id - LEFT JOIN users reporter ON t.reporter_id = reporter.id - LEFT JOIN task_timers tt ON t.id = tt.task_id AND tt.user_id = $${isSubTasks ? "3" : "2"} - LEFT JOIN task_aggregates agg ON t.id = agg.id - LEFT JOIN task_assignees assignees ON t.id = assignees.task_id - LEFT JOIN task_labels labels ON t.id = labels.task_id - ${options.customColumns ? "LEFT JOIN custom_columns_data cc_data ON t.id = cc_data.task_id" : ""} WHERE ${filters} ${searchQuery} ORDER BY ${sortFields} `; @@ -383,7 +328,7 @@ export default class TasksControllerV2 extends TasksControllerBase { public static async getList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const startTime = performance.now(); console.log(`[PERFORMANCE] getList method called for project ${req.params.id} - THIS METHOD IS DEPRECATED, USE getTasksV3 INSTEAD`); - + // PERFORMANCE OPTIMIZATION: Skip expensive progress calculation by default // Progress values are already calculated and stored in the database // Only refresh if explicitly requested via refresh_progress=true query parameter @@ -397,12 +342,12 @@ export default class TasksControllerV2 extends TasksControllerBase { const isSubTasks = !!req.query.parent_task; const groupBy = (req.query.group || GroupBy.STATUS) as string; - + // Add customColumns flag to query params req.query.customColumns = "true"; const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks ? [req.params.id || null, req.query.parent_task, req.user?.id] : [req.params.id || null, req.user?.id]; + const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; const result = await db.query(q, params); const tasks = [...result.rows]; @@ -433,7 +378,7 @@ export default class TasksControllerV2 extends TasksControllerBase { const endTime = performance.now(); const totalTime = endTime - startTime; console.log(`[PERFORMANCE] getList method completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${tasks.length} tasks`); - + // Log warning if this deprecated method is taking too long if (totalTime > 1000) { console.warn(`[PERFORMANCE WARNING] DEPRECATED getList method taking ${totalTime.toFixed(2)}ms - Frontend should use getTasksV3 instead!`); @@ -445,16 +390,16 @@ export default class TasksControllerV2 extends TasksControllerBase { public static async updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) { let index = 0; const unmapped = []; - + // PERFORMANCE OPTIMIZATION: Remove expensive individual DB calls for each task // Progress values are already calculated and included in the main query // No need to make additional database calls here - + // Process tasks with their already-calculated progress values for (const task of tasks) { task.index = index++; TasksControllerV2.updateTaskViewModel(task); - + if (groupBy === GroupBy.STATUS) { map[task.status]?.tasks.push(task); } else if (groupBy === GroupBy.PRIORITY) { @@ -492,7 +437,7 @@ export default class TasksControllerV2 extends TasksControllerBase { public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const startTime = performance.now(); console.log(`[PERFORMANCE] getTasksOnly method called for project ${req.params.id} - Consider using getTasksV3 for better performance`); - + // PERFORMANCE OPTIMIZATION: Skip expensive progress calculation by default // Progress values are already calculated and stored in the database // Only refresh if explicitly requested via refresh_progress=true query parameter @@ -505,12 +450,12 @@ export default class TasksControllerV2 extends TasksControllerBase { } const isSubTasks = !!req.query.parent_task; - + // Add customColumns flag to query params req.query.customColumns = "true"; - + const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks ? [req.params.id || null, req.query.parent_task, req.user?.id] : [req.params.id || null, req.user?.id]; + const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; const result = await db.query(q, params); let data: any[] = []; @@ -520,11 +465,11 @@ export default class TasksControllerV2 extends TasksControllerBase { [data] = result.rows; } else { // else we return a flat list of tasks data = [...result.rows]; - + // PERFORMANCE OPTIMIZATION: Remove expensive individual DB calls for each task // Progress values are already calculated and included in the main query via get_task_complete_ratio // The database query already includes complete_ratio, so no need for additional calls - + for (const task of data) { TasksControllerV2.updateTaskViewModel(task); } @@ -533,7 +478,7 @@ export default class TasksControllerV2 extends TasksControllerBase { const endTime = performance.now(); const totalTime = endTime - startTime; console.log(`[PERFORMANCE] getTasksOnly method completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${data.length} tasks`); - + // Log warning if this method is taking too long if (totalTime > 1000) { console.warn(`[PERFORMANCE WARNING] getTasksOnly method taking ${totalTime.toFixed(2)}ms - Consider using getTasksV3 for better performance!`); @@ -575,9 +520,9 @@ export default class TasksControllerV2 extends TasksControllerBase { "SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1 AND archived IS FALSE", [parentTaskId] ); - + const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || "0"); - + // If it has subtasks, reset the manual_progress flag to false if (subtaskCount > 0) { await db.query( @@ -585,24 +530,24 @@ export default class TasksControllerV2 extends TasksControllerBase { [parentTaskId] ); console.log(`Reset manual progress for parent task ${parentTaskId} with ${subtaskCount} subtasks`); - + // Get the project settings to determine which calculation method to use const projectResult = await db.query( "SELECT project_id FROM tasks WHERE id = $1", [parentTaskId] ); - + const projectId = projectResult.rows[0]?.project_id; - + if (projectId) { // Recalculate the parent task's progress based on its subtasks const progressResult = await db.query( "SELECT get_task_complete_ratio($1) AS ratio", [parentTaskId] ); - + const progressRatio = progressResult.rows[0]?.ratio?.ratio || 0; - + // Emit the updated progress value to all clients // Note: We don't have socket context here, so we can't directly emit // This will be picked up on the next client refresh @@ -653,7 +598,7 @@ export default class TasksControllerV2 extends TasksControllerBase { ? [req.body.id, req.body.to_group_id] : [req.body.id, req.body.project_id, req.body.parent_task_id, req.body.to_group_id]; await db.query(q, params); - + // Reset the parent task's manual progress when converting a task to a subtask if (req.body.parent_task_id) { await this.resetParentTaskManualProgress(req.body.parent_task_id); @@ -824,27 +769,27 @@ export default class TasksControllerV2 extends TasksControllerBase { // Get column information const columnQuery = ` - SELECT id, field_type - FROM cc_custom_columns + SELECT id, field_type + FROM cc_custom_columns WHERE project_id = $1 AND key = $2 `; const columnResult = await db.query(columnQuery, [project_id, column_key]); - + if (columnResult.rowCount === 0) { return res.status(404).send(new ServerResponse(false, "Custom column not found")); } - + const column = columnResult.rows[0]; const columnId = column.id; const fieldType = column.field_type; - + // Determine which value field to use based on the field_type let textValue = null; let numberValue = null; let dateValue = null; let booleanValue = null; let jsonValue = null; - + switch (fieldType) { case "number": numberValue = parseFloat(String(value)); @@ -861,55 +806,55 @@ export default class TasksControllerV2 extends TasksControllerBase { default: textValue = String(value); } - + // Check if a value already exists const existingValueQuery = ` - SELECT id - FROM cc_column_values + SELECT id + FROM cc_column_values WHERE task_id = $1 AND column_id = $2 `; const existingValueResult = await db.query(existingValueQuery, [taskId, columnId]); - + if (existingValueResult.rowCount && existingValueResult.rowCount > 0) { // Update existing value const updateQuery = ` - UPDATE cc_column_values - SET text_value = $1, - number_value = $2, - date_value = $3, - boolean_value = $4, - json_value = $5, - updated_at = NOW() + UPDATE cc_column_values + SET text_value = $1, + number_value = $2, + date_value = $3, + boolean_value = $4, + json_value = $5, + updated_at = NOW() WHERE task_id = $6 AND column_id = $7 `; await db.query(updateQuery, [ - textValue, - numberValue, - dateValue, - booleanValue, - jsonValue, - taskId, + textValue, + numberValue, + dateValue, + booleanValue, + jsonValue, + taskId, columnId ]); } else { // Insert new value const insertQuery = ` - INSERT INTO cc_column_values - (task_id, column_id, text_value, number_value, date_value, boolean_value, json_value, created_at, updated_at) + INSERT INTO cc_column_values + (task_id, column_id, text_value, number_value, date_value, boolean_value, json_value, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) `; await db.query(insertQuery, [ - taskId, - columnId, - textValue, - numberValue, - dateValue, - booleanValue, + taskId, + columnId, + textValue, + numberValue, + dateValue, + booleanValue, jsonValue ]); } - return res.status(200).send(new ServerResponse(true, { + return res.status(200).send(new ServerResponse(true, { task_id: taskId, column_key, value @@ -917,7 +862,7 @@ export default class TasksControllerV2 extends TasksControllerBase { } public static async refreshProjectTaskProgressValues(projectId: string): Promise { - try { + try { // Run the recalculate_all_task_progress function only for tasks in this project const query = ` DO $$ @@ -932,12 +877,12 @@ export default class TasksControllerV2 extends TasksControllerBase { WHERE parent_task_id = t.id AND archived IS FALSE ); - + -- Start recalculation from leaf tasks (no subtasks) and propagate upward -- This ensures calculations are done in the right order WITH RECURSIVE task_hierarchy AS ( -- Base case: Start with all leaf tasks (no subtasks) in this project - SELECT + SELECT id, parent_task_id, 0 AS level @@ -949,11 +894,11 @@ export default class TasksControllerV2 extends TasksControllerBase { AND sub.archived IS FALSE ) AND archived IS FALSE - + UNION ALL - + -- Recursive case: Move up to parent tasks, but only after processing all their children - SELECT + SELECT t.id, t.parent_task_id, th.level + 1 @@ -974,7 +919,7 @@ export default class TasksControllerV2 extends TasksControllerBase { AND (manual_progress IS FALSE OR manual_progress IS NULL); END $$; `; - + await db.query(query); console.log(`Finished refreshing progress values for project ${projectId}`); } catch (error) { @@ -987,24 +932,24 @@ export default class TasksControllerV2 extends TasksControllerBase { // Calculate the task's progress using get_task_complete_ratio const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]); const [data] = result.rows; - + if (data && data.info && data.info.ratio !== undefined) { const progressValue = +((data.info.ratio || 0).toFixed()); - + // Update the task's progress_value in the database await db.query( "UPDATE tasks SET progress_value = $1 WHERE id = $2", [progressValue, taskId] ); - + console.log(`Updated progress for task ${taskId} to ${progressValue}%`); - + // If this task has a parent, update the parent's progress as well const parentResult = await db.query( "SELECT parent_task_id FROM tasks WHERE id = $1", [taskId] ); - + if (parentResult.rows.length > 0 && parentResult.rows[0].parent_task_id) { await this.updateTaskProgress(parentResult.rows[0].parent_task_id); } @@ -1022,13 +967,13 @@ export default class TasksControllerV2 extends TasksControllerBase { "UPDATE tasks SET weight = $1 WHERE id = $2", [weight, taskId] ); - + // Get the parent task ID const parentResult = await db.query( "SELECT parent_task_id FROM tasks WHERE id = $1", [taskId] ); - + // If this task has a parent, update the parent's progress if (parentResult.rows.length > 0 && parentResult.rows[0].parent_task_id) { await this.updateTaskProgress(parentResult.rows[0].parent_task_id); @@ -1041,62 +986,60 @@ export default class TasksControllerV2 extends TasksControllerBase { @HandleExceptions() public static async getTasksV3(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const startTime = performance.now(); - console.log(`[PERFORMANCE] getTasksV3 method called for project ${req.params.id}`); - + const isSubTasks = !!req.query.parent_task; + const groupBy = (req.query.group || GroupBy.STATUS) as string; + const archived = req.query.archived === "true"; + // PERFORMANCE OPTIMIZATION: Skip expensive progress calculation by default // Progress values are already calculated and stored in the database // Only refresh if explicitly requested via refresh_progress=true query parameter - if (req.query.refresh_progress === "true" && req.params.id) { - console.log(`[PERFORMANCE] Starting progress refresh for project ${req.params.id} (getTasksV3)`); + // This dramatically improves initial load performance (from ~2-5s to ~200-500ms) + const shouldRefreshProgress = req.query.refresh_progress === "true"; + + if (shouldRefreshProgress && req.params.id) { const progressStartTime = performance.now(); await this.refreshProjectTaskProgressValues(req.params.id); const progressEndTime = performance.now(); - console.log(`[PERFORMANCE] Progress refresh completed in ${(progressEndTime - progressStartTime).toFixed(2)}ms`); } - const isSubTasks = !!req.query.parent_task; - const groupBy = (req.query.group || GroupBy.STATUS) as string; - - // Add customColumns flag to query params (same as getList) - req.query.customColumns = "true"; - - // Use the exact same database query as getList method + const queryStartTime = performance.now(); const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks ? [req.params.id || null, req.query.parent_task, req.user?.id] : [req.params.id || null, req.user?.id]; + const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; const result = await db.query(q, params); const tasks = [...result.rows]; + const queryEndTime = performance.now(); - // Use the same groups query as getList method + // Get groups metadata dynamically from database + const groupsStartTime = performance.now(); const groups = await this.getGroups(groupBy, req.params.id); - const map = groups.reduce((g: { [x: string]: ITaskGroup }, group) => { - if (group.id) - g[group.id] = new TaskListGroup(group); - return g; - }, {}); + const groupsEndTime = performance.now(); - // Use the same updateMapByGroup method as getList - await this.updateMapByGroup(tasks, groupBy, map); - - // Calculate progress for groups (same as getList) - const updatedGroups = Object.keys(map).map(key => { - const group = map[key]; - TasksControllerV2.updateTaskProgresses(group); - return { - id: key, - ...group - }; - }); - - // Transform to V3 response format while maintaining the same data processing + // Create priority value to name mapping const priorityMap: Record = { "0": "low", - "1": "medium", + "1": "medium", "2": "high" }; - // Transform all tasks to V3 format + // Create status category mapping based on actual status names from database + const statusCategoryMap: Record = {}; + for (const group of groups) { + if (groupBy === GroupBy.STATUS && group.id) { + // Use the actual status name from database, convert to lowercase for consistency + statusCategoryMap[group.id] = group.name.toLowerCase().replace(/\s+/g, "_"); + } + } + + + + // Transform tasks with all necessary data preprocessing + const transformStartTime = performance.now(); const transformedTasks = tasks.map((task, index) => { + // Update task with calculated values (lightweight version) + TasksControllerV2.updateTaskViewModel(task); + task.index = index; + // Convert time values const convertTimeValue = (value: any): number => { if (typeof value === "number") return value; @@ -1119,12 +1062,15 @@ export default class TasksControllerV2 extends TasksControllerBase { task_key: task.task_key || "", title: task.name || "", description: task.description || "", - status: task.status || "todo", + // Use dynamic status mapping from database + status: statusCategoryMap[task.status] || task.status, + // Pre-processed priority using mapping priority: priorityMap[task.priority_value?.toString()] || "medium", + // Use actual phase name from database phase: task.phase_name || "Development", progress: typeof task.complete_ratio === "number" ? task.complete_ratio : 0, assignees: task.assignees?.map((a: any) => a.team_member_id) || [], - assignee_names: task.assignees || [], + assignee_names: task.assignee_names || task.names || [], labels: task.labels?.map((l: any) => ({ id: l.id || l.label_id, name: l.name, @@ -1132,11 +1078,6 @@ export default class TasksControllerV2 extends TasksControllerBase { end: l.end, names: l.names })) || [], - all_labels: task.all_labels?.map((l: any) => ({ - id: l.id || l.label_id, - name: l.name, - color_code: l.color_code || "#1890ff" - })) || [], dueDate: task.end_date || task.END_DATE, startDate: task.start_date, timeTracking: { @@ -1144,7 +1085,7 @@ export default class TasksControllerV2 extends TasksControllerBase { logged: convertTimeValue(task.time_spent), }, customFields: {}, - custom_column_values: task.custom_column_values || {}, + custom_column_values: task.custom_column_values || {}, // Include custom column values createdAt: task.created_at || new Date().toISOString(), updatedAt: task.updated_at || new Date().toISOString(), order: typeof task.sort_order === "number" ? task.sort_order : 0, @@ -1163,53 +1104,124 @@ export default class TasksControllerV2 extends TasksControllerBase { schedule_id: task.schedule_id || null, }; }); + const transformEndTime = performance.now(); - // Transform groups to V3 format while preserving the getList logic - const responseGroups = updatedGroups.map(group => { - // Create status category mapping for consistent group naming - let groupValue = group.name; - if (groupBy === GroupBy.STATUS) { - groupValue = group.name.toLowerCase().replace(/\s+/g, "_"); - } else if (groupBy === GroupBy.PRIORITY) { - groupValue = group.name.toLowerCase(); - } else if (groupBy === GroupBy.PHASE) { - groupValue = group.name.toLowerCase().replace(/\s+/g, "_"); - } + // Create groups based on dynamic data from database + const groupingStartTime = performance.now(); + const groupedResponse: Record = {}; - // Transform tasks in this group to V3 format - const groupTasks = group.tasks.map(task => { - const foundTask = transformedTasks.find(t => t.id === task.id); - return foundTask || task; - }); + // Initialize groups from database data + groups.forEach(group => { + const groupKey = groupBy === GroupBy.STATUS + ? group.name.toLowerCase().replace(/\s+/g, "_") + : groupBy === GroupBy.PRIORITY + ? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase() + : group.name.toLowerCase().replace(/\s+/g, "_"); - return { - id: group.id, - title: group.name, - groupType: groupBy, - groupValue, - collapsed: false, - tasks: groupTasks, - taskIds: groupTasks.map((task: any) => task.id), - color: group.color_code || this.getDefaultGroupColor(groupBy, groupValue), - // Include additional metadata from database - category_id: group.category_id, - start_date: group.start_date, - end_date: group.end_date, - sort_index: (group as any).sort_index, - // Include progress information from getList logic - todo_progress: group.todo_progress, - doing_progress: group.doing_progress, - done_progress: group.done_progress, - }; - }).filter(group => group.tasks.length > 0 || req.query.include_empty === "true"); + groupedResponse[groupKey] = { + id: group.id, + title: group.name, + groupType: groupBy, + groupValue: groupKey, + collapsed: false, + tasks: [], + taskIds: [], + color: group.color_code || this.getDefaultGroupColor(groupBy, groupKey), + // Include additional metadata from database + category_id: group.category_id, + start_date: group.start_date, + end_date: group.end_date, + sort_index: (group as any).sort_index, + }; + }); + + // Distribute tasks into groups + const unmappedTasks: any[] = []; + + transformedTasks.forEach(task => { + let groupKey: string; + let taskAssigned = false; + + if (groupBy === GroupBy.STATUS) { + groupKey = task.status; + if (groupedResponse[groupKey]) { + groupedResponse[groupKey].tasks.push(task); + groupedResponse[groupKey].taskIds.push(task.id); + taskAssigned = true; + } + } else if (groupBy === GroupBy.PRIORITY) { + groupKey = task.priority; + if (groupedResponse[groupKey]) { + groupedResponse[groupKey].tasks.push(task); + groupedResponse[groupKey].taskIds.push(task.id); + taskAssigned = true; + } + } else if (groupBy === GroupBy.PHASE) { + // For phase grouping, check if task has a valid phase + if (task.phase && task.phase.trim() !== "") { + groupKey = task.phase.toLowerCase().replace(/\s+/g, "_"); + if (groupedResponse[groupKey]) { + groupedResponse[groupKey].tasks.push(task); + groupedResponse[groupKey].taskIds.push(task.id); + taskAssigned = true; + } + } + // If task doesn't have a valid phase, add to unmapped + if (!taskAssigned) { + unmappedTasks.push(task); + } + } + }); + + // Create unmapped group if there are tasks without proper phase assignment + if (unmappedTasks.length > 0 && groupBy === GroupBy.PHASE) { + groupedResponse[UNMAPPED.toLowerCase()] = { + id: UNMAPPED, + title: UNMAPPED, + groupType: groupBy, + groupValue: UNMAPPED.toLowerCase(), + collapsed: false, + tasks: unmappedTasks, + taskIds: unmappedTasks.map(task => task.id), + color: "#fbc84c69", // Orange color with transparency + category_id: null, + start_date: null, + end_date: null, + sort_index: 999, // Put unmapped group at the end + }; + } + + // Sort tasks within each group by order + Object.values(groupedResponse).forEach((group: any) => { + group.tasks.sort((a: any, b: any) => a.order - b.order); + }); + + // Convert to array format expected by frontend, maintaining database order + const responseGroups = groups + .map(group => { + const groupKey = groupBy === GroupBy.STATUS + ? group.name.toLowerCase().replace(/\s+/g, "_") + : groupBy === GroupBy.PRIORITY + ? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase() + : group.name.toLowerCase().replace(/\s+/g, "_"); + + return groupedResponse[groupKey]; + }) + .filter(group => group && (group.tasks.length > 0 || req.query.include_empty === "true")); + + // Add unmapped group to the end if it exists + if (groupedResponse[UNMAPPED.toLowerCase()]) { + responseGroups.push(groupedResponse[UNMAPPED.toLowerCase()]); + } + + const groupingEndTime = performance.now(); const endTime = performance.now(); const totalTime = endTime - startTime; - console.log(`[PERFORMANCE] getTasksV3 method completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks`); - - // Log warning if this method is taking too long + + // Log warning if request is taking too long if (totalTime > 1000) { - console.warn(`[PERFORMANCE WARNING] getTasksV3 method taking ${totalTime.toFixed(2)}ms - Consider optimizing the query or data processing!`); + console.warn(`[PERFORMANCE WARNING] Slow request detected: ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks`); } return res.status(200).send(new ServerResponse(true, { @@ -1220,320 +1232,11 @@ export default class TasksControllerV2 extends TasksControllerBase { })); } - /** - * NEW OPTIMIZED METHOD: Split complex query into focused segments for better performance - */ - @HandleExceptions() - public static async getTasksV4Optimized(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const startTime = performance.now(); - console.log(`[PERFORMANCE] getTasksV4Optimized method called for project ${req.params.id}`); - - // Skip progress refresh by default for better performance - if (req.query.refresh_progress === "true" && req.params.id) { - const progressStartTime = performance.now(); - await this.refreshProjectTaskProgressValues(req.params.id); - const progressEndTime = performance.now(); - console.log(`[PERFORMANCE] Progress refresh completed in ${(progressEndTime - progressStartTime).toFixed(2)}ms`); - } - - const isSubTasks = !!req.query.parent_task; - const groupBy = (req.query.group || GroupBy.STATUS) as string; - const projectId = req.params.id; - const userId = req.user?.id; - - // STEP 1: Get basic task data with optimized query - const baseTasksQuery = ` - SELECT - t.id, - t.name, - CONCAT(p.key, '-', t.task_no) AS task_key, - p.name AS project_name, - t.project_id, - t.parent_task_id, - t.parent_task_id IS NOT NULL AS is_sub_task, - t.status_id AS status, - t.priority_id AS priority, - t.description, - t.sort_order, - t.progress_value AS complete_ratio, - t.manual_progress, - t.weight, - t.start_date, - t.end_date, - t.created_at, - t.updated_at, - t.completed_at, - t.billable, - t.schedule_id, - t.total_minutes, - -- Status information via JOINs - stsc.color_code AS status_color, - stsc.color_code_dark AS status_color_dark, - stsc.is_done, - stsc.is_doing, - stsc.is_todo, - -- Priority information - tp_priority.value AS priority_value, - -- Phase information - tp.phase_id, - pp.name AS phase_name, - pp.color_code AS phase_color_code, - -- Reporter information - reporter.name AS reporter, - -- Timer information - tt.start_time AS timer_start_time - FROM tasks t - JOIN projects p ON t.project_id = p.id - JOIN task_statuses ts ON t.status_id = ts.id - JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id - LEFT JOIN task_phase tp ON t.id = tp.task_id - LEFT JOIN project_phases pp ON tp.phase_id = pp.id - LEFT JOIN task_priorities tp_priority ON t.priority_id = tp_priority.id - LEFT JOIN users reporter ON t.reporter_id = reporter.id - LEFT JOIN task_timers tt ON t.id = tt.task_id AND tt.user_id = $2 - WHERE t.project_id = $1 - AND t.archived = FALSE - ${isSubTasks ? "AND t.parent_task_id = $3" : "AND t.parent_task_id IS NULL"} - ORDER BY t.sort_order - `; - - const baseParams = isSubTasks ? [projectId, userId, req.query.parent_task] : [projectId, userId]; - const baseResult = await db.query(baseTasksQuery, baseParams); - const baseTasks = baseResult.rows; - - if (baseTasks.length === 0) { - return res.status(200).send(new ServerResponse(true, { - groups: [], - allTasks: [], - grouping: groupBy, - totalTasks: 0 - })); - } - - const taskIds = baseTasks.map(t => t.id); - - // STEP 2: Get aggregated data in parallel - const [assigneesResult, labelsResult, aggregatesResult] = await Promise.all([ - // Get assignees - db.query(` - SELECT - ta.task_id, - JSON_AGG(JSON_BUILD_OBJECT( - 'team_member_id', ta.team_member_id, - 'project_member_id', ta.project_member_id, - 'name', COALESCE(tm.name, ''), - 'avatar_url', COALESCE(u.avatar_url, ''), - 'email', COALESCE(u.email, ei.email, ''), - 'user_id', tm.user_id, - 'socket_id', COALESCE(u.socket_id, ''), - 'team_id', tm.team_id - )) AS assignees - FROM tasks_assignees ta - LEFT JOIN team_members tm ON ta.team_member_id = tm.id - LEFT JOIN users u ON tm.user_id = u.id - LEFT JOIN email_invitations ei ON ta.team_member_id = ei.team_member_id - WHERE ta.task_id = ANY($1) - GROUP BY ta.task_id - `, [taskIds]), - - // Get labels - db.query(` - SELECT - tl.task_id, - JSON_AGG(JSON_BUILD_OBJECT( - 'id', tl.label_id, - 'label_id', tl.label_id, - 'name', team_l.name, - 'color_code', team_l.color_code - )) AS labels - FROM task_labels tl - JOIN team_labels team_l ON tl.label_id = team_l.id - WHERE tl.task_id = ANY($1) - GROUP BY tl.task_id - `, [taskIds]), - - // Get aggregated counts - db.query(` - SELECT - t.id, - COUNT(DISTINCT sub.id) AS sub_tasks_count, - COUNT(DISTINCT CASE WHEN sub_status.is_done THEN sub.id END) AS completed_sub_tasks, - COUNT(DISTINCT tc.id) AS comments_count, - COUNT(DISTINCT ta.id) AS attachments_count, - COALESCE(SUM(twl.time_spent), 0) AS total_minutes_spent, - CASE WHEN COUNT(ts.id) > 0 THEN true ELSE false END AS has_subscribers, - CASE WHEN COUNT(td.id) > 0 THEN true ELSE false END AS has_dependencies - FROM unnest($1::uuid[]) AS t(id) - LEFT JOIN tasks sub ON t.id = sub.parent_task_id AND sub.archived = FALSE - LEFT JOIN task_statuses sub_ts ON sub.status_id = sub_ts.id - LEFT JOIN sys_task_status_categories sub_status ON sub_ts.category_id = sub_status.id - LEFT JOIN task_comments tc ON t.id = tc.task_id - LEFT JOIN task_attachments ta ON t.id = ta.task_id - LEFT JOIN task_work_log twl ON t.id = twl.task_id - LEFT JOIN task_subscribers ts ON t.id = ts.task_id - LEFT JOIN task_dependencies td ON t.id = td.task_id - GROUP BY t.id - `, [taskIds]) - ]); - - // STEP 3: Create lookup maps for efficient data merging - const assigneesMap = new Map(); - assigneesResult.rows.forEach(row => assigneesMap.set(row.task_id, row.assignees || [])); - - const labelsMap = new Map(); - labelsResult.rows.forEach(row => labelsMap.set(row.task_id, row.labels || [])); - - const aggregatesMap = new Map(); - aggregatesResult.rows.forEach(row => aggregatesMap.set(row.id, row)); - - // STEP 4: Merge data efficiently - const enrichedTasks = baseTasks.map(task => { - const aggregates = aggregatesMap.get(task.id) || {}; - const assignees = assigneesMap.get(task.id) || []; - const labels = labelsMap.get(task.id) || []; - - return { - ...task, - assignees, - assignee_names: assignees.map((a: any) => a.name).join(", "), - names: assignees.map((a: any) => a.name).join(", "), - labels, - all_labels: labels, - sub_tasks_count: parseInt(aggregates.sub_tasks_count || 0), - completed_sub_tasks: parseInt(aggregates.completed_sub_tasks || 0), - comments_count: parseInt(aggregates.comments_count || 0), - attachments_count: parseInt(aggregates.attachments_count || 0), - total_minutes_spent: parseFloat(aggregates.total_minutes_spent || 0), - has_subscribers: aggregates.has_subscribers || false, - has_dependencies: aggregates.has_dependencies || false, - status_category: { - is_done: task.is_done, - is_doing: task.is_doing, - is_todo: task.is_todo - } - }; - }); - - // STEP 5: Group tasks (same logic as existing method) - const groups = await this.getGroups(groupBy, req.params.id); - const map = groups.reduce((g: { [x: string]: ITaskGroup }, group) => { - if (group.id) - g[group.id] = new TaskListGroup(group); - return g; - }, {}); - - await this.updateMapByGroup(enrichedTasks, groupBy, map); - - const updatedGroups = Object.keys(map).map(key => { - const group = map[key]; - TasksControllerV2.updateTaskProgresses(group); - return { - id: key, - ...group - }; - }); - - // STEP 6: Transform to V3 format (same as existing method) - const priorityMap: Record = { - "0": "low", - "1": "medium", - "2": "high" - }; - - const transformedTasks = enrichedTasks.map(task => ({ - id: task.id, - task_key: task.task_key || "", - title: task.name || "", - description: task.description || "", - status: task.status || "todo", - priority: priorityMap[task.priority_value?.toString()] || "medium", - phase: task.phase_name || "Development", - progress: typeof task.complete_ratio === "number" ? task.complete_ratio : 0, - assignees: task.assignees?.map((a: any) => a.team_member_id) || [], - assignee_names: task.assignees || [], - labels: task.labels?.map((l: any) => ({ - id: l.id || l.label_id, - name: l.name, - color: l.color_code || "#1890ff" - })) || [], - dueDate: task.end_date, - startDate: task.start_date, - timeTracking: { - estimated: task.total_minutes || 0, - logged: task.total_minutes_spent || 0, - }, - customFields: {}, - createdAt: task.created_at || new Date().toISOString(), - updatedAt: task.updated_at || new Date().toISOString(), - order: typeof task.sort_order === "number" ? task.sort_order : 0, - originalStatusId: task.status, - originalPriorityId: task.priority, - statusColor: task.status_color, - priorityColor: task.priority_color, - sub_tasks_count: task.sub_tasks_count || 0, - comments_count: task.comments_count || 0, - has_subscribers: !!task.has_subscribers, - attachments_count: task.attachments_count || 0, - has_dependencies: !!task.has_dependencies, - schedule_id: task.schedule_id || null, - })); - - const responseGroups = updatedGroups.map(group => { - let groupValue = group.name; - if (groupBy === GroupBy.STATUS) { - groupValue = group.name.toLowerCase().replace(/\s+/g, "_"); - } else if (groupBy === GroupBy.PRIORITY) { - groupValue = group.name.toLowerCase(); - } else if (groupBy === GroupBy.PHASE) { - groupValue = group.name.toLowerCase().replace(/\s+/g, "_"); - } - - const groupTasks = group.tasks.map(task => { - const foundTask = transformedTasks.find(t => t.id === task.id); - return foundTask || task; - }); - - return { - id: group.id, - title: group.name, - groupType: groupBy, - groupValue, - collapsed: false, - tasks: groupTasks, - taskIds: groupTasks.map((task: any) => task.id), - color: group.color_code || this.getDefaultGroupColor(groupBy, groupValue), - category_id: group.category_id, - start_date: group.start_date, - end_date: group.end_date, - sort_index: (group as any).sort_index, - todo_progress: group.todo_progress, - doing_progress: group.doing_progress, - done_progress: group.done_progress, - }; - }).filter(group => group.tasks.length > 0 || req.query.include_empty === "true"); - - const endTime = performance.now(); - const totalTime = endTime - startTime; - console.log(`[PERFORMANCE] getTasksV4Optimized method completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks - Improvement: ${2136 - totalTime > 0 ? "+" : ""}${(2136 - totalTime).toFixed(2)}ms`); - - return res.status(200).send(new ServerResponse(true, { - groups: responseGroups, - allTasks: transformedTasks, - grouping: groupBy, - totalTasks: transformedTasks.length, - performanceMetrics: { - executionTime: Math.round(totalTime), - tasksCount: transformedTasks.length, - optimizationGain: Math.round(2136 - totalTime) - } - })); - } - private static getDefaultGroupColor(groupBy: string, groupValue: string): string { const colorMaps: Record> = { [GroupBy.STATUS]: { todo: "#f0f0f0", - doing: "#1890ff", + doing: "#1890ff", done: "#52c41a", }, [GroupBy.PRIORITY]: { @@ -1550,7 +1253,7 @@ export default class TasksControllerV2 extends TasksControllerBase { unmapped: "#fbc84c69", }, }; - + return colorMaps[groupBy]?.[groupValue] || "#d9d9d9"; } @@ -1558,15 +1261,15 @@ export default class TasksControllerV2 extends TasksControllerBase { public static async refreshTaskProgress(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { try { const startTime = performance.now(); - + if (req.params.id) { console.log(`[PERFORMANCE] Starting background progress refresh for project ${req.params.id}`); await this.refreshProjectTaskProgressValues(req.params.id); - + const endTime = performance.now(); const totalTime = endTime - startTime; console.log(`[PERFORMANCE] Background progress refresh completed in ${totalTime.toFixed(2)}ms for project ${req.params.id}`); - + return res.status(200).send(new ServerResponse(true, { message: "Task progress values refreshed successfully", performanceMetrics: { @@ -1592,31 +1295,31 @@ export default class TasksControllerV2 extends TasksControllerBase { // Get basic progress stats without expensive calculations const result = await db.query(` - SELECT + SELECT COUNT(*) as total_tasks, COUNT(CASE WHEN EXISTS( - SELECT 1 FROM tasks_with_status_view - WHERE tasks_with_status_view.task_id = tasks.id + SELECT 1 FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = tasks.id AND is_done IS TRUE ) THEN 1 END) as completed_tasks, - AVG(CASE - WHEN progress_value IS NOT NULL THEN progress_value - ELSE 0 + AVG(CASE + WHEN progress_value IS NOT NULL THEN progress_value + ELSE 0 END) as avg_progress, MAX(updated_at) as last_updated - FROM tasks + FROM tasks WHERE project_id = $1 AND archived IS FALSE `, [req.params.id]); const [stats] = result.rows; - + return res.status(200).send(new ServerResponse(true, { projectId: req.params.id, totalTasks: parseInt(stats.total_tasks) || 0, completedTasks: parseInt(stats.completed_tasks) || 0, avgProgress: parseFloat(stats.avg_progress) || 0, lastUpdated: stats.last_updated, - completionPercentage: stats.total_tasks > 0 ? + completionPercentage: stats.total_tasks > 0 ? Math.round((parseInt(stats.completed_tasks) / parseInt(stats.total_tasks)) * 100) : 0 })); } catch (error) { @@ -1624,6 +1327,4 @@ export default class TasksControllerV2 extends TasksControllerBase { return res.status(500).send(new ServerResponse(false, null, "Failed to get task progress status")); } } - - } diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx index 0d6f5df4..85b02ea7 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx @@ -1,682 +1,16 @@ -import React, { useCallback, useMemo, useEffect, useState, useRef } from 'react'; -import { GroupedVirtuoso } from 'react-virtuoso'; -import { - DndContext, - DragOverlay, - PointerSensor, - useSensor, - useSensors, - KeyboardSensor, - TouchSensor, - closestCenter, -} from '@dnd-kit/core'; -import { - SortableContext, - verticalListSortingStrategy, - sortableKeyboardCoordinates, -} from '@dnd-kit/sortable'; -import { useTranslation } from 'react-i18next'; -import { useParams } from 'react-router-dom'; -import { createPortal } from 'react-dom'; -import { Skeleton } from 'antd'; -import { HolderOutlined } from '@ant-design/icons'; - -// Redux hooks and selectors -import { useAppSelector } from '@/hooks/useAppSelector'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { - selectAllTasksArray, - selectGroups, - selectGrouping, - selectLoading, - selectError, - fetchTasksV3, - fetchTaskListColumns, - selectColumns, - selectCustomColumns, - selectLoadingColumns, - updateColumnVisibility, -} from '@/features/task-management/task-management.slice'; -import { - selectCurrentGrouping, - selectCollapsedGroups, - toggleGroupCollapsed, -} from '@/features/task-management/grouping.slice'; -import { - selectSelectedTaskIds, - selectLastSelectedTaskId, - selectTask, - toggleTaskSelection, - selectRange, - clearSelection, -} from '@/features/task-management/selection.slice'; -import { - setCustomColumnModalAttributes, - toggleCustomColumnModalOpen, -} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice'; - -// Components -import TaskRowWithSubtasks from './TaskRowWithSubtasks'; -import TaskGroupHeader from './TaskGroupHeader'; -import ImprovedTaskFilters from '@/components/task-management/improved-task-filters'; -import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar'; -import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal'; -import AddTaskRow from './components/AddTaskRow'; -import { - AddCustomColumnButton, - CustomColumnHeader, -} from './components/CustomColumnComponents'; - -// Hooks and utilities -import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; -import { useSocket } from '@/socket/socketContext'; -import { useDragAndDrop } from './hooks/useDragAndDrop'; -import { useBulkActions } from './hooks/useBulkActions'; - -// Constants and types -import { BASE_COLUMNS, ColumnStyle } from './constants/columns'; -import { Task } from '@/types/task-management.types'; -import { SocketEvents } from '@/shared/socket-events'; +import ImprovedTaskFilters from "../task-management/improved-task-filters"; +import TaskListV2Section from "./TaskListV2Table"; const TaskListV2: React.FC = () => { - const dispatch = useAppDispatch(); - const { projectId: urlProjectId } = useParams(); - const { t } = useTranslation('task-list-table'); - const { socket, connected } = useSocket(); - - // Redux state selectors - const allTasks = useAppSelector(selectAllTasksArray); - const groups = useAppSelector(selectGroups); - const grouping = useAppSelector(selectGrouping); - const loading = useAppSelector(selectLoading); - const error = useAppSelector(selectError); - const currentGrouping = useAppSelector(selectCurrentGrouping); - const selectedTaskIds = useAppSelector(selectSelectedTaskIds); - const lastSelectedTaskId = useAppSelector(selectLastSelectedTaskId); - const collapsedGroups = useAppSelector(selectCollapsedGroups); - - const fields = useAppSelector(state => state.taskManagementFields) || []; - const columns = useAppSelector(selectColumns); - const customColumns = useAppSelector(selectCustomColumns); - const loadingColumns = useAppSelector(selectLoadingColumns); - - // Refs for scroll synchronization - const headerScrollRef = useRef(null); - const contentScrollRef = useRef(null); - - // State hooks - const [initializedFromDatabase, setInitializedFromDatabase] = useState(false); - - // 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, - }, - }) - ); - - // Custom hooks - const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(allTasks, groups); - const bulkActions = useBulkActions(); - - // Enable real-time updates via socket handlers - useTaskSocketHandlers(); - - // Filter visible columns based on local fields (primary) and backend columns (fallback) - const visibleColumns = useMemo(() => { - // Start with base columns - const baseVisibleColumns = BASE_COLUMNS.filter(column => { - // Always show drag handle and title (sticky columns) - if (column.isSticky) return true; - - // Primary: Check local fields configuration - const field = fields.find(f => f.key === column.key); - if (field) { - return field.visible; - } - - // Fallback: Check backend column configuration if local field not found - const backendColumn = columns.find(c => c.key === column.key); - if (backendColumn) { - return backendColumn.pinned ?? false; - } - - // Default: hide if neither local field nor backend column found - return false; - }); - - // Add visible custom columns - const visibleCustomColumns = customColumns - ?.filter(column => column.pinned) - ?.map(column => { - // Give selection columns more width for dropdown content - const fieldType = column.custom_column_obj?.fieldType; - let defaultWidth = 160; - if (fieldType === 'selection') { - defaultWidth = 150; // Reduced width for selection dropdowns - } else if (fieldType === 'people') { - defaultWidth = 170; // Extra width for people with avatars - } - - // Map the configuration data structure to the expected format - const customColumnObj = column.custom_column_obj || (column as any).configuration; - - // Transform configuration format to custom_column_obj format if needed - let transformedColumnObj = customColumnObj; - if (customColumnObj && !customColumnObj.fieldType && customColumnObj.field_type) { - transformedColumnObj = { - ...customColumnObj, - fieldType: customColumnObj.field_type, - numberType: customColumnObj.number_type, - labelPosition: customColumnObj.label_position, - previewValue: customColumnObj.preview_value, - firstNumericColumn: customColumnObj.first_numeric_column_key, - secondNumericColumn: customColumnObj.second_numeric_column_key, - selectionsList: customColumnObj.selections_list || customColumnObj.selectionsList || [], - labelsList: customColumnObj.labels_list || customColumnObj.labelsList || [], - }; - } - - return { - id: column.key || column.id || 'unknown', - label: column.name || t('customColumns.customColumnHeader'), - width: `${(column as any).width || defaultWidth}px`, - key: column.key || column.id || 'unknown', - custom_column: true, - custom_column_obj: transformedColumnObj, - isCustom: true, - name: column.name, - uuid: column.id, - }; - }) || []; - - return [...baseVisibleColumns, ...visibleCustomColumns]; - }, [fields, columns, customColumns, t]); - - // Effects - useEffect(() => { - if (urlProjectId) { - dispatch(fetchTasksV3(urlProjectId)); - dispatch(fetchTaskListColumns(urlProjectId)); - } - }, [dispatch, urlProjectId]); - - // Initialize field visibility from database when columns are loaded (only once) - useEffect(() => { - if (columns.length > 0 && fields.length > 0 && !initializedFromDatabase) { - // Update local fields to match database state only on initial load - import('@/features/task-management/taskListFields.slice').then(({ setFields }) => { - // Create updated fields based on database column state - const updatedFields = fields.map(field => { - const backendColumn = columns.find(c => c.key === field.key); - if (backendColumn) { - return { - ...field, - visible: backendColumn.pinned ?? field.visible - }; - } - return field; - }); - - // Only update if there are actual changes - const hasChanges = updatedFields.some((field, index) => - field.visible !== fields[index].visible - ); - - if (hasChanges) { - dispatch(setFields(updatedFields)); - } - - setInitializedFromDatabase(true); - }); - } - }, [columns, fields, dispatch, initializedFromDatabase]); - - // Event handlers - const handleTaskSelect = useCallback( - (taskId: string, event: React.MouseEvent) => { - if (event.ctrlKey || event.metaKey) { - dispatch(toggleTaskSelection(taskId)); - } else if (event.shiftKey && lastSelectedTaskId) { - const taskIds = allTasks.map(t => t.id); - const startIdx = taskIds.indexOf(lastSelectedTaskId); - const endIdx = taskIds.indexOf(taskId); - const rangeIds = taskIds.slice(Math.min(startIdx, endIdx), Math.max(startIdx, endIdx) + 1); - dispatch(selectRange(rangeIds)); - } else { - dispatch(clearSelection()); - dispatch(selectTask(taskId)); - } - }, - [dispatch, lastSelectedTaskId, allTasks] - ); - - const handleGroupCollapse = useCallback( - (groupId: string) => { - dispatch(toggleGroupCollapsed(groupId)); - }, - [dispatch] - ); - - // Function to update custom column values - const updateTaskCustomColumnValue = useCallback((taskId: string, columnKey: string, value: string) => { - try { - if (!urlProjectId) { - console.error('Project ID is missing'); - return; - } - - const body = { - task_id: taskId, - column_key: columnKey, - value: value, - project_id: urlProjectId, - }; - - // Update the Redux store immediately for optimistic updates - const currentTask = allTasks.find(task => task.id === taskId); - if (currentTask) { - const updatedTask = { - ...currentTask, - custom_column_values: { - ...currentTask.custom_column_values, - [columnKey]: value, - }, - updated_at: new Date().toISOString(), - }; - - // Import and dispatch the updateTask action - import('@/features/task-management/task-management.slice').then(({ updateTask }) => { - dispatch(updateTask(updatedTask)); - }); - } - - if (socket && connected) { - socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body)); - } else { - console.warn('Socket not connected, unable to emit TASK_CUSTOM_COLUMN_UPDATE event'); - } - } catch (error) { - console.error('Error updating custom column value:', error); - } - }, [urlProjectId, socket, connected, allTasks, dispatch]); - - // Custom column settings handler - const handleCustomColumnSettings = useCallback((columnKey: string) => { - if (!columnKey) return; - - const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey); - - // Use the UUID for API calls, not the key (nanoid) - // For custom columns, prioritize the uuid field over id field - const columnId = (columnData as any)?.uuid || columnData?.id || columnKey; - - dispatch(setCustomColumnModalAttributes({ - modalType: 'edit', - columnId: columnId, - columnData: columnData - })); - dispatch(toggleCustomColumnModalOpen(true)); - }, [dispatch, visibleColumns]); - - // Add callback for task added - const handleTaskAdded = useCallback(() => { - // Task is now added in real-time via socket, no need to refetch - // The global socket handler will handle the real-time update - }, []); - - // Handle scroll synchronization - disabled since header is now sticky inside content - const handleContentScroll = useCallback(() => { - // No longer needed since header scrolls naturally with content - }, []); - - // Memoized values for GroupedVirtuoso - const virtuosoGroups = useMemo(() => { - let currentTaskIndex = 0; - - return groups.map(group => { - const isCurrentGroupCollapsed = collapsedGroups.has(group.id); - - const visibleTasksInGroup = isCurrentGroupCollapsed - ? [] - : group.taskIds - .map(taskId => allTasks.find(task => task.id === taskId)) - .filter((task): task is Task => task !== undefined); - - const tasksForVirtuoso = visibleTasksInGroup.map(task => ({ - ...task, - originalIndex: allTasks.indexOf(task), - })); - - const itemsWithAddTask = !isCurrentGroupCollapsed ? [ - ...tasksForVirtuoso, - { - id: `add-task-${group.id}`, - isAddTaskRow: true, - groupId: group.id, - groupType: currentGrouping || 'status', - groupValue: group.id, // Use the actual database ID from backend - projectId: urlProjectId, - } - ] : tasksForVirtuoso; - - const groupData = { - ...group, - tasks: itemsWithAddTask, - startIndex: currentTaskIndex, - count: itemsWithAddTask.length, - actualCount: group.taskIds.length, - groupValue: group.groupValue || group.title, - }; - currentTaskIndex += itemsWithAddTask.length; - return groupData; - }); - }, [groups, allTasks, collapsedGroups, currentGrouping, urlProjectId]); - - const virtuosoGroupCounts = useMemo(() => { - return virtuosoGroups.map(group => group.count); - }, [virtuosoGroups]); - - const virtuosoItems = useMemo(() => { - return virtuosoGroups.flatMap(group => group.tasks); - }, [virtuosoGroups]); - - // Render functions - const renderGroup = useCallback( - (groupIndex: number) => { - const group = virtuosoGroups[groupIndex]; - const isGroupCollapsed = collapsedGroups.has(group.id); - const isGroupEmpty = group.actualCount === 0; - - - - return ( -
0 ? 'mt-2' : ''}> - handleGroupCollapse(group.id)} - projectId={urlProjectId || ''} - /> - {isGroupEmpty && !isGroupCollapsed && ( -
-
- {visibleColumns.map((column, index) => ( -
- ))} -
-
-
- {t('noTasksInGroup')} -
-
-
- )} -
- ); - }, - [virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t] - ); - - const renderTask = useCallback( - (taskIndex: number) => { - const item = virtuosoItems[taskIndex]; - - - if (!item || !urlProjectId) return null; - - if ('isAddTaskRow' in item && item.isAddTaskRow) { - return ( - - ); - } - - return ( - - ); - }, - [virtuosoItems, visibleColumns, urlProjectId, handleTaskAdded, updateTaskCustomColumnValue] - ); - - // Render column headers - const renderColumnHeaders = useCallback(() => ( -
-
- {visibleColumns.map((column, index) => { - const columnStyle: ColumnStyle = { - width: column.width, - flexShrink: 0, - ...(column.id === 'labels' && column.width === 'auto' - ? { - minWidth: '200px', - flexGrow: 1, - } - : {}), - ...((column as any).minWidth && { minWidth: (column as any).minWidth }), - ...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }), - }; - - return ( -
- {column.id === 'dragHandle' || column.id === 'checkbox' ? ( - - ) : (column as any).isCustom ? ( - - ) : ( - t(column.label || '') - )} -
- ); - })} - {/* Add Custom Column Button - positioned at the end and scrolls with content */} -
- -
-
-
- ), [visibleColumns, t, handleCustomColumnSettings]); - - - - // Loading and error states - if (loading || loadingColumns) return ; - if (error) return
{t('emptyStates.errorPrefix')} {error}
; - - // Show message when no data - if (groups.length === 0 && !loading) { - return ( -
-
- -
-
-
-
- {t('emptyStates.noTaskGroups')} -
-
- {t('emptyStates.noTaskGroupsDescription')} -
-
-
-
- ); - } return ( - -
- {/* Task Filters */} -
- -
- - {/* Table Container */} -
- {/* Task List Content with Sticky Header */} -
- {/* Sticky Column Headers */} -
- {renderColumnHeaders()} -
- !('isAddTaskRow' in item) && !item.parent_task_id) - .map(item => item.id) - .filter((id): id is string => id !== undefined)} - strategy={verticalListSortingStrategy} - > -
- {/* Render groups manually for debugging */} - {virtuosoGroups.map((group, groupIndex) => ( -
- {/* Group Header */} - {renderGroup(groupIndex)} - - {/* Group Tasks */} - {!collapsedGroups.has(group.id) && group.tasks.map((task, taskIndex) => { - const globalTaskIndex = virtuosoGroups - .slice(0, groupIndex) - .reduce((sum, g) => sum + g.count, 0) + taskIndex; - - return ( -
- {renderTask(globalTaskIndex)} -
- ); - })} -
- ))} -
-
-
-
- - {/* Drag Overlay */} - - {activeId ? ( -
-
-
- -
-
- {allTasks.find(task => task.id === activeId)?.name || - allTasks.find(task => task.id === activeId)?.title || - t('emptyStates.dragTaskFallback')} -
-
- {allTasks.find(task => task.id === activeId)?.task_key} -
-
-
-
-
- ) : null} -
- - {/* Bulk Action Bar */} - {selectedTaskIds.length > 0 && urlProjectId && ( -
- bulkActions.handleBulkStatusChange(statusId, selectedTaskIds)} - onBulkPriorityChange={(priorityId) => bulkActions.handleBulkPriorityChange(priorityId, selectedTaskIds)} - onBulkPhaseChange={(phaseId) => bulkActions.handleBulkPhaseChange(phaseId, selectedTaskIds)} - onBulkAssignToMe={() => bulkActions.handleBulkAssignToMe(selectedTaskIds)} - onBulkAssignMembers={(memberIds) => bulkActions.handleBulkAssignMembers(memberIds, selectedTaskIds)} - onBulkAddLabels={(labelIds) => bulkActions.handleBulkAddLabels(labelIds, selectedTaskIds)} - onBulkArchive={() => bulkActions.handleBulkArchive(selectedTaskIds)} - onBulkDelete={() => bulkActions.handleBulkDelete(selectedTaskIds)} - onBulkDuplicate={() => bulkActions.handleBulkDuplicate(selectedTaskIds)} - onBulkExport={() => bulkActions.handleBulkExport(selectedTaskIds)} - onBulkSetDueDate={(date) => bulkActions.handleBulkSetDueDate(date, selectedTaskIds)} - /> -
- )} - - {/* Custom Column Modal */} - {createPortal(, document.body, 'custom-column-modal')} +
+ {/* Task Filters */} +
+
- + +
); }; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx new file mode 100644 index 00000000..997a8256 --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -0,0 +1,678 @@ +import React, { useCallback, useMemo, useEffect, useState, useRef } from 'react'; +import { GroupedVirtuoso } from 'react-virtuoso'; +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + KeyboardSensor, + TouchSensor, + closestCenter, +} from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { createPortal } from 'react-dom'; +import { Skeleton } from 'antd'; +import { HolderOutlined } from '@ant-design/icons'; + +// Redux hooks and selectors +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { + selectAllTasksArray, + selectGroups, + selectGrouping, + selectLoading, + selectError, + fetchTasksV3, + fetchTaskListColumns, + selectColumns, + selectCustomColumns, + selectLoadingColumns, + updateColumnVisibility, +} from '@/features/task-management/task-management.slice'; +import { + selectCurrentGrouping, + selectCollapsedGroups, + toggleGroupCollapsed, +} from '@/features/task-management/grouping.slice'; +import { + selectSelectedTaskIds, + selectLastSelectedTaskId, + selectTask, + toggleTaskSelection, + selectRange, + clearSelection, +} from '@/features/task-management/selection.slice'; +import { + setCustomColumnModalAttributes, + toggleCustomColumnModalOpen, +} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice'; + +// Components +import TaskRowWithSubtasks from './TaskRowWithSubtasks'; +import TaskGroupHeader from './TaskGroupHeader'; +import ImprovedTaskFilters from '@/components/task-management/improved-task-filters'; +import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar'; +import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal'; +import AddTaskRow from './components/AddTaskRow'; +import { + AddCustomColumnButton, + CustomColumnHeader, +} from './components/CustomColumnComponents'; + +// Hooks and utilities +import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; +import { useSocket } from '@/socket/socketContext'; +import { useDragAndDrop } from './hooks/useDragAndDrop'; +import { useBulkActions } from './hooks/useBulkActions'; + +// Constants and types +import { BASE_COLUMNS, ColumnStyle } from './constants/columns'; +import { Task } from '@/types/task-management.types'; +import { SocketEvents } from '@/shared/socket-events'; + +const TaskListV2Section: React.FC = () => { + const dispatch = useAppDispatch(); + const { projectId: urlProjectId } = useParams(); + const { t } = useTranslation('task-list-table'); + const { socket, connected } = useSocket(); + + // Redux state selectors + const allTasks = useAppSelector(selectAllTasksArray); + const groups = useAppSelector(selectGroups); + const grouping = useAppSelector(selectGrouping); + const loading = useAppSelector(selectLoading); + const error = useAppSelector(selectError); + const currentGrouping = useAppSelector(selectCurrentGrouping); + const selectedTaskIds = useAppSelector(selectSelectedTaskIds); + const lastSelectedTaskId = useAppSelector(selectLastSelectedTaskId); + const collapsedGroups = useAppSelector(selectCollapsedGroups); + + const fields = useAppSelector(state => state.taskManagementFields) || []; + const columns = useAppSelector(selectColumns); + const customColumns = useAppSelector(selectCustomColumns); + const loadingColumns = useAppSelector(selectLoadingColumns); + + // Refs for scroll synchronization + const headerScrollRef = useRef(null); + const contentScrollRef = useRef(null); + + // State hooks + const [initializedFromDatabase, setInitializedFromDatabase] = useState(false); + + // 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, + }, + }) + ); + + // Custom hooks + const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(allTasks, groups); + const bulkActions = useBulkActions(); + + // Enable real-time updates via socket handlers + useTaskSocketHandlers(); + + // Filter visible columns based on local fields (primary) and backend columns (fallback) + const visibleColumns = useMemo(() => { + // Start with base columns + const baseVisibleColumns = BASE_COLUMNS.filter(column => { + // Always show drag handle and title (sticky columns) + if (column.isSticky) return true; + + // Primary: Check local fields configuration + const field = fields.find(f => f.key === column.key); + if (field) { + return field.visible; + } + + // Fallback: Check backend column configuration if local field not found + const backendColumn = columns.find(c => c.key === column.key); + if (backendColumn) { + return backendColumn.pinned ?? false; + } + + // Default: hide if neither local field nor backend column found + return false; + }); + + // Add visible custom columns + const visibleCustomColumns = customColumns + ?.filter(column => column.pinned) + ?.map(column => { + // Give selection columns more width for dropdown content + const fieldType = column.custom_column_obj?.fieldType; + let defaultWidth = 160; + if (fieldType === 'selection') { + defaultWidth = 150; // Reduced width for selection dropdowns + } else if (fieldType === 'people') { + defaultWidth = 170; // Extra width for people with avatars + } + + // Map the configuration data structure to the expected format + const customColumnObj = column.custom_column_obj || (column as any).configuration; + + // Transform configuration format to custom_column_obj format if needed + let transformedColumnObj = customColumnObj; + if (customColumnObj && !customColumnObj.fieldType && customColumnObj.field_type) { + transformedColumnObj = { + ...customColumnObj, + fieldType: customColumnObj.field_type, + numberType: customColumnObj.number_type, + labelPosition: customColumnObj.label_position, + previewValue: customColumnObj.preview_value, + firstNumericColumn: customColumnObj.first_numeric_column_key, + secondNumericColumn: customColumnObj.second_numeric_column_key, + selectionsList: customColumnObj.selections_list || customColumnObj.selectionsList || [], + labelsList: customColumnObj.labels_list || customColumnObj.labelsList || [], + }; + } + + return { + id: column.key || column.id || 'unknown', + label: column.name || t('customColumns.customColumnHeader'), + width: `${(column as any).width || defaultWidth}px`, + key: column.key || column.id || 'unknown', + custom_column: true, + custom_column_obj: transformedColumnObj, + isCustom: true, + name: column.name, + uuid: column.id, + }; + }) || []; + + return [...baseVisibleColumns, ...visibleCustomColumns]; + }, [fields, columns, customColumns, t]); + + // Effects + useEffect(() => { + if (urlProjectId) { + dispatch(fetchTasksV3(urlProjectId)); + dispatch(fetchTaskListColumns(urlProjectId)); + } + }, [dispatch, urlProjectId]); + + // Initialize field visibility from database when columns are loaded (only once) + useEffect(() => { + if (columns.length > 0 && fields.length > 0 && !initializedFromDatabase) { + // Update local fields to match database state only on initial load + import('@/features/task-management/taskListFields.slice').then(({ setFields }) => { + // Create updated fields based on database column state + const updatedFields = fields.map(field => { + const backendColumn = columns.find(c => c.key === field.key); + if (backendColumn) { + return { + ...field, + visible: backendColumn.pinned ?? field.visible + }; + } + return field; + }); + + // Only update if there are actual changes + const hasChanges = updatedFields.some((field, index) => + field.visible !== fields[index].visible + ); + + if (hasChanges) { + dispatch(setFields(updatedFields)); + } + + setInitializedFromDatabase(true); + }); + } + }, [columns, fields, dispatch, initializedFromDatabase]); + + // Event handlers + const handleTaskSelect = useCallback( + (taskId: string, event: React.MouseEvent) => { + if (event.ctrlKey || event.metaKey) { + dispatch(toggleTaskSelection(taskId)); + } else if (event.shiftKey && lastSelectedTaskId) { + const taskIds = allTasks.map(t => t.id); + const startIdx = taskIds.indexOf(lastSelectedTaskId); + const endIdx = taskIds.indexOf(taskId); + const rangeIds = taskIds.slice(Math.min(startIdx, endIdx), Math.max(startIdx, endIdx) + 1); + dispatch(selectRange(rangeIds)); + } else { + dispatch(clearSelection()); + dispatch(selectTask(taskId)); + } + }, + [dispatch, lastSelectedTaskId, allTasks] + ); + + const handleGroupCollapse = useCallback( + (groupId: string) => { + dispatch(toggleGroupCollapsed(groupId)); + }, + [dispatch] + ); + + // Function to update custom column values + const updateTaskCustomColumnValue = useCallback((taskId: string, columnKey: string, value: string) => { + try { + if (!urlProjectId) { + console.error('Project ID is missing'); + return; + } + + const body = { + task_id: taskId, + column_key: columnKey, + value: value, + project_id: urlProjectId, + }; + + // Update the Redux store immediately for optimistic updates + const currentTask = allTasks.find(task => task.id === taskId); + if (currentTask) { + const updatedTask = { + ...currentTask, + custom_column_values: { + ...currentTask.custom_column_values, + [columnKey]: value, + }, + updated_at: new Date().toISOString(), + }; + + // Import and dispatch the updateTask action + import('@/features/task-management/task-management.slice').then(({ updateTask }) => { + dispatch(updateTask(updatedTask)); + }); + } + + if (socket && connected) { + socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body)); + } else { + console.warn('Socket not connected, unable to emit TASK_CUSTOM_COLUMN_UPDATE event'); + } + } catch (error) { + console.error('Error updating custom column value:', error); + } + }, [urlProjectId, socket, connected, allTasks, dispatch]); + + // Custom column settings handler + const handleCustomColumnSettings = useCallback((columnKey: string) => { + if (!columnKey) return; + + const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey); + + // Use the UUID for API calls, not the key (nanoid) + // For custom columns, prioritize the uuid field over id field + const columnId = (columnData as any)?.uuid || columnData?.id || columnKey; + + dispatch(setCustomColumnModalAttributes({ + modalType: 'edit', + columnId: columnId, + columnData: columnData + })); + dispatch(toggleCustomColumnModalOpen(true)); + }, [dispatch, visibleColumns]); + + // Add callback for task added + const handleTaskAdded = useCallback(() => { + // Task is now added in real-time via socket, no need to refetch + // The global socket handler will handle the real-time update + }, []); + + // Handle scroll synchronization - disabled since header is now sticky inside content + const handleContentScroll = useCallback(() => { + // No longer needed since header scrolls naturally with content + }, []); + + // Memoized values for GroupedVirtuoso + const virtuosoGroups = useMemo(() => { + let currentTaskIndex = 0; + + return groups.map(group => { + const isCurrentGroupCollapsed = collapsedGroups.has(group.id); + + const visibleTasksInGroup = isCurrentGroupCollapsed + ? [] + : group.taskIds + .map(taskId => allTasks.find(task => task.id === taskId)) + .filter((task): task is Task => task !== undefined); + + const tasksForVirtuoso = visibleTasksInGroup.map(task => ({ + ...task, + originalIndex: allTasks.indexOf(task), + })); + + const itemsWithAddTask = !isCurrentGroupCollapsed ? [ + ...tasksForVirtuoso, + { + id: `add-task-${group.id}`, + isAddTaskRow: true, + groupId: group.id, + groupType: currentGrouping || 'status', + groupValue: group.id, // Use the actual database ID from backend + projectId: urlProjectId, + } + ] : tasksForVirtuoso; + + const groupData = { + ...group, + tasks: itemsWithAddTask, + startIndex: currentTaskIndex, + count: itemsWithAddTask.length, + actualCount: group.taskIds.length, + groupValue: group.groupValue || group.title, + }; + currentTaskIndex += itemsWithAddTask.length; + return groupData; + }); + }, [groups, allTasks, collapsedGroups, currentGrouping, urlProjectId]); + + const virtuosoGroupCounts = useMemo(() => { + return virtuosoGroups.map(group => group.count); + }, [virtuosoGroups]); + + const virtuosoItems = useMemo(() => { + return virtuosoGroups.flatMap(group => group.tasks); + }, [virtuosoGroups]); + + // Render functions + const renderGroup = useCallback( + (groupIndex: number) => { + const group = virtuosoGroups[groupIndex]; + const isGroupCollapsed = collapsedGroups.has(group.id); + const isGroupEmpty = group.actualCount === 0; + + + + return ( +
0 ? 'mt-2' : ''}> + handleGroupCollapse(group.id)} + projectId={urlProjectId || ''} + /> + {isGroupEmpty && !isGroupCollapsed && ( +
+
+ {visibleColumns.map((column, index) => ( +
+ ))} +
+
+
+ {t('noTasksInGroup')} +
+
+
+ )} +
+ ); + }, + [virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t] + ); + + const renderTask = useCallback( + (taskIndex: number) => { + const item = virtuosoItems[taskIndex]; + + + if (!item || !urlProjectId) return null; + + if ('isAddTaskRow' in item && item.isAddTaskRow) { + return ( + + ); + } + + return ( + + ); + }, + [virtuosoItems, visibleColumns, urlProjectId, handleTaskAdded, updateTaskCustomColumnValue] + ); + + // Render column headers + const renderColumnHeaders = useCallback(() => ( +
+
+ {visibleColumns.map((column, index) => { + const columnStyle: ColumnStyle = { + width: column.width, + flexShrink: 0, + ...(column.id === 'labels' && column.width === 'auto' + ? { + minWidth: '200px', + flexGrow: 1, + } + : {}), + ...((column as any).minWidth && { minWidth: (column as any).minWidth }), + ...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }), + }; + + return ( +
+ {column.id === 'dragHandle' || column.id === 'checkbox' ? ( + + ) : (column as any).isCustom ? ( + + ) : ( + t(column.label || '') + )} +
+ ); + })} + {/* Add Custom Column Button - positioned at the end and scrolls with content */} +
+ +
+
+
+ ), [visibleColumns, t, handleCustomColumnSettings]); + + + + // Loading and error states + if (loading || loadingColumns) return ; + if (error) return
{t('emptyStates.errorPrefix')} {error}
; + + // Show message when no data + if (groups.length === 0 && !loading) { + return ( +
+
+ +
+
+
+
+ {t('emptyStates.noTaskGroups')} +
+
+ {t('emptyStates.noTaskGroupsDescription')} +
+
+
+
+ ); + } + + return ( + +
+ + {/* Table Container */} +
+ {/* Task List Content with Sticky Header */} +
+ {/* Sticky Column Headers */} +
+ {renderColumnHeaders()} +
+ !('isAddTaskRow' in item) && !item.parent_task_id) + .map(item => item.id) + .filter((id): id is string => id !== undefined)} + strategy={verticalListSortingStrategy} + > +
+ {/* Render groups manually for debugging */} + {virtuosoGroups.map((group, groupIndex) => ( +
+ {/* Group Header */} + {renderGroup(groupIndex)} + + {/* Group Tasks */} + {!collapsedGroups.has(group.id) && group.tasks.map((task, taskIndex) => { + const globalTaskIndex = virtuosoGroups + .slice(0, groupIndex) + .reduce((sum, g) => sum + g.count, 0) + taskIndex; + + return ( +
+ {renderTask(globalTaskIndex)} +
+ ); + })} +
+ ))} +
+
+
+
+ + {/* Drag Overlay */} + + {activeId ? ( +
+
+
+ +
+
+ {allTasks.find(task => task.id === activeId)?.name || + allTasks.find(task => task.id === activeId)?.title || + t('emptyStates.dragTaskFallback')} +
+
+ {allTasks.find(task => task.id === activeId)?.task_key} +
+
+
+
+
+ ) : null} +
+ + {/* Bulk Action Bar */} + {selectedTaskIds.length > 0 && urlProjectId && ( +
+ bulkActions.handleBulkStatusChange(statusId, selectedTaskIds)} + onBulkPriorityChange={(priorityId) => bulkActions.handleBulkPriorityChange(priorityId, selectedTaskIds)} + onBulkPhaseChange={(phaseId) => bulkActions.handleBulkPhaseChange(phaseId, selectedTaskIds)} + onBulkAssignToMe={() => bulkActions.handleBulkAssignToMe(selectedTaskIds)} + onBulkAssignMembers={(memberIds) => bulkActions.handleBulkAssignMembers(memberIds, selectedTaskIds)} + onBulkAddLabels={(labelIds) => bulkActions.handleBulkAddLabels(labelIds, selectedTaskIds)} + onBulkArchive={() => bulkActions.handleBulkArchive(selectedTaskIds)} + onBulkDelete={() => bulkActions.handleBulkDelete(selectedTaskIds)} + onBulkDuplicate={() => bulkActions.handleBulkDuplicate(selectedTaskIds)} + onBulkExport={() => bulkActions.handleBulkExport(selectedTaskIds)} + onBulkSetDueDate={(date) => bulkActions.handleBulkSetDueDate(date, selectedTaskIds)} + /> +
+ )} + + {/* Custom Column Modal */} + {createPortal(, document.body, 'custom-column-modal')} +
+
+ ); +}; + +export default TaskListV2Section; From 399a01904aa59fa3040f479c362d6579d5ae95eb Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 14:58:54 +0530 Subject: [PATCH 10/49] refactor(task-list): enhance styling and structure in TaskListV2 and TaskRow components - Consolidated import statements for better readability. - Improved layout and styling consistency by adding border styles to various elements in TaskRow and AddTaskRow components. - Updated TaskListV2Table to enhance the rendering logic and maintainability. - Adjusted custom column handling and task estimation display for improved user experience. --- .../task-list-v2/TaskListV2Table.tsx | 586 ++++++++++-------- .../src/components/task-list-v2/TaskRow.tsx | 71 ++- .../task-list-v2/components/AddTaskRow.tsx | 12 +- .../task-management/task-management.slice.ts | 4 +- .../src/hooks/useTaskSocketHandlers.ts | 23 +- 5 files changed, 396 insertions(+), 300 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 997a8256..99fb5bb1 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -62,10 +62,7 @@ import ImprovedTaskFilters from '@/components/task-management/improved-task-filt import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar'; import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal'; import AddTaskRow from './components/AddTaskRow'; -import { - AddCustomColumnButton, - CustomColumnHeader, -} from './components/CustomColumnComponents'; +import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomColumnComponents'; // Hooks and utilities import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; @@ -126,7 +123,10 @@ const TaskListV2Section: React.FC = () => { ); // Custom hooks - const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(allTasks, groups); + const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop( + allTasks, + groups + ); const bulkActions = useBulkActions(); // Enable real-time updates via socket handlers @@ -156,49 +156,51 @@ const TaskListV2Section: React.FC = () => { }); // Add visible custom columns - const visibleCustomColumns = customColumns - ?.filter(column => column.pinned) - ?.map(column => { - // Give selection columns more width for dropdown content - const fieldType = column.custom_column_obj?.fieldType; - let defaultWidth = 160; - if (fieldType === 'selection') { - defaultWidth = 150; // Reduced width for selection dropdowns - } else if (fieldType === 'people') { - defaultWidth = 170; // Extra width for people with avatars - } + const visibleCustomColumns = + customColumns + ?.filter(column => column.pinned) + ?.map(column => { + // Give selection columns more width for dropdown content + const fieldType = column.custom_column_obj?.fieldType; + let defaultWidth = 160; + if (fieldType === 'selection') { + defaultWidth = 150; // Reduced width for selection dropdowns + } else if (fieldType === 'people') { + defaultWidth = 170; // Extra width for people with avatars + } - // Map the configuration data structure to the expected format - const customColumnObj = column.custom_column_obj || (column as any).configuration; + // Map the configuration data structure to the expected format + const customColumnObj = column.custom_column_obj || (column as any).configuration; - // Transform configuration format to custom_column_obj format if needed - let transformedColumnObj = customColumnObj; - if (customColumnObj && !customColumnObj.fieldType && customColumnObj.field_type) { - transformedColumnObj = { - ...customColumnObj, - fieldType: customColumnObj.field_type, - numberType: customColumnObj.number_type, - labelPosition: customColumnObj.label_position, - previewValue: customColumnObj.preview_value, - firstNumericColumn: customColumnObj.first_numeric_column_key, - secondNumericColumn: customColumnObj.second_numeric_column_key, - selectionsList: customColumnObj.selections_list || customColumnObj.selectionsList || [], - labelsList: customColumnObj.labels_list || customColumnObj.labelsList || [], + // Transform configuration format to custom_column_obj format if needed + let transformedColumnObj = customColumnObj; + if (customColumnObj && !customColumnObj.fieldType && customColumnObj.field_type) { + transformedColumnObj = { + ...customColumnObj, + fieldType: customColumnObj.field_type, + numberType: customColumnObj.number_type, + labelPosition: customColumnObj.label_position, + previewValue: customColumnObj.preview_value, + firstNumericColumn: customColumnObj.first_numeric_column_key, + secondNumericColumn: customColumnObj.second_numeric_column_key, + selectionsList: + customColumnObj.selections_list || customColumnObj.selectionsList || [], + labelsList: customColumnObj.labels_list || customColumnObj.labelsList || [], + }; + } + + return { + id: column.key || column.id || 'unknown', + label: column.name || t('customColumns.customColumnHeader'), + width: `${(column as any).width || defaultWidth}px`, + key: column.key || column.id || 'unknown', + custom_column: true, + custom_column_obj: transformedColumnObj, + isCustom: true, + name: column.name, + uuid: column.id, }; - } - - return { - id: column.key || column.id || 'unknown', - label: column.name || t('customColumns.customColumnHeader'), - width: `${(column as any).width || defaultWidth}px`, - key: column.key || column.id || 'unknown', - custom_column: true, - custom_column_obj: transformedColumnObj, - isCustom: true, - name: column.name, - uuid: column.id, - }; - }) || []; + }) || []; return [...baseVisibleColumns, ...visibleCustomColumns]; }, [fields, columns, customColumns, t]); @@ -222,15 +224,15 @@ const TaskListV2Section: React.FC = () => { if (backendColumn) { return { ...field, - visible: backendColumn.pinned ?? field.visible + visible: backendColumn.pinned ?? field.visible, }; } return field; }); // Only update if there are actual changes - const hasChanges = updatedFields.some((field, index) => - field.visible !== fields[index].visible + const hasChanges = updatedFields.some( + (field, index) => field.visible !== fields[index].visible ); if (hasChanges) { @@ -269,65 +271,73 @@ const TaskListV2Section: React.FC = () => { ); // Function to update custom column values - const updateTaskCustomColumnValue = useCallback((taskId: string, columnKey: string, value: string) => { - try { - if (!urlProjectId) { - console.error('Project ID is missing'); - return; - } + const updateTaskCustomColumnValue = useCallback( + (taskId: string, columnKey: string, value: string) => { + try { + if (!urlProjectId) { + console.error('Project ID is missing'); + return; + } - const body = { - task_id: taskId, - column_key: columnKey, - value: value, - project_id: urlProjectId, - }; - - // Update the Redux store immediately for optimistic updates - const currentTask = allTasks.find(task => task.id === taskId); - if (currentTask) { - const updatedTask = { - ...currentTask, - custom_column_values: { - ...currentTask.custom_column_values, - [columnKey]: value, - }, - updated_at: new Date().toISOString(), + const body = { + task_id: taskId, + column_key: columnKey, + value: value, + project_id: urlProjectId, }; - // Import and dispatch the updateTask action - import('@/features/task-management/task-management.slice').then(({ updateTask }) => { - dispatch(updateTask(updatedTask)); - }); - } + // Update the Redux store immediately for optimistic updates + const currentTask = allTasks.find(task => task.id === taskId); + if (currentTask) { + const updatedTask = { + ...currentTask, + custom_column_values: { + ...currentTask.custom_column_values, + [columnKey]: value, + }, + updated_at: new Date().toISOString(), + }; - if (socket && connected) { - socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body)); - } else { - console.warn('Socket not connected, unable to emit TASK_CUSTOM_COLUMN_UPDATE event'); + // Import and dispatch the updateTask action + import('@/features/task-management/task-management.slice').then(({ updateTask }) => { + dispatch(updateTask(updatedTask)); + }); + } + + if (socket && connected) { + socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body)); + } else { + console.warn('Socket not connected, unable to emit TASK_CUSTOM_COLUMN_UPDATE event'); + } + } catch (error) { + console.error('Error updating custom column value:', error); } - } catch (error) { - console.error('Error updating custom column value:', error); - } - }, [urlProjectId, socket, connected, allTasks, dispatch]); + }, + [urlProjectId, socket, connected, allTasks, dispatch] + ); // Custom column settings handler - const handleCustomColumnSettings = useCallback((columnKey: string) => { - if (!columnKey) return; + const handleCustomColumnSettings = useCallback( + (columnKey: string) => { + if (!columnKey) return; - const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey); + const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey); - // Use the UUID for API calls, not the key (nanoid) - // For custom columns, prioritize the uuid field over id field - const columnId = (columnData as any)?.uuid || columnData?.id || columnKey; + // Use the UUID for API calls, not the key (nanoid) + // For custom columns, prioritize the uuid field over id field + const columnId = (columnData as any)?.uuid || columnData?.id || columnKey; - dispatch(setCustomColumnModalAttributes({ - modalType: 'edit', - columnId: columnId, - columnData: columnData - })); - dispatch(toggleCustomColumnModalOpen(true)); - }, [dispatch, visibleColumns]); + dispatch( + setCustomColumnModalAttributes({ + modalType: 'edit', + columnId: columnId, + columnData: columnData, + }) + ); + dispatch(toggleCustomColumnModalOpen(true)); + }, + [dispatch, visibleColumns] + ); // Add callback for task added const handleTaskAdded = useCallback(() => { @@ -350,25 +360,27 @@ const TaskListV2Section: React.FC = () => { const visibleTasksInGroup = isCurrentGroupCollapsed ? [] : group.taskIds - .map(taskId => allTasks.find(task => task.id === taskId)) - .filter((task): task is Task => task !== undefined); + .map(taskId => allTasks.find(task => task.id === taskId)) + .filter((task): task is Task => task !== undefined); const tasksForVirtuoso = visibleTasksInGroup.map(task => ({ ...task, originalIndex: allTasks.indexOf(task), })); - const itemsWithAddTask = !isCurrentGroupCollapsed ? [ - ...tasksForVirtuoso, - { - id: `add-task-${group.id}`, - isAddTaskRow: true, - groupId: group.id, - groupType: currentGrouping || 'status', - groupValue: group.id, // Use the actual database ID from backend - projectId: urlProjectId, - } - ] : tasksForVirtuoso; + const itemsWithAddTask = !isCurrentGroupCollapsed + ? [ + ...tasksForVirtuoso, + { + id: `add-task-${group.id}`, + isAddTaskRow: true, + groupId: group.id, + groupType: currentGrouping || 'status', + groupValue: group.id, // Use the actual database ID from backend + projectId: urlProjectId, + }, + ] + : tasksForVirtuoso; const groupData = { ...group, @@ -398,8 +410,6 @@ const TaskListV2Section: React.FC = () => { const isGroupCollapsed = collapsedGroups.has(group.id); const isGroupEmpty = group.actualCount === 0; - - return (
0 ? 'mt-2' : ''}> { {isGroupEmpty && !isGroupCollapsed && (
- {visibleColumns.map((column, index) => ( -
- ))} + {visibleColumns.map((column, index) => { + const emptyColumnStyle = { + width: column.width, + flexShrink: 0, + ...(column.id === 'labels' && column.width === 'auto' + ? { minWidth: '200px', flexGrow: 1 } + : {}), + }; + return ( +
+ ); + })}
@@ -440,7 +460,6 @@ const TaskListV2Section: React.FC = () => { (taskIndex: number) => { const item = virtuosoItems[taskIndex]; - if (!item || !urlProjectId) return null; if ('isAddTaskRow' in item && item.isAddTaskRow) { @@ -469,70 +488,86 @@ const TaskListV2Section: React.FC = () => { ); // Render column headers - const renderColumnHeaders = useCallback(() => ( -
-
- {visibleColumns.map((column, index) => { - const columnStyle: ColumnStyle = { - width: column.width, - flexShrink: 0, - ...(column.id === 'labels' && column.width === 'auto' - ? { - minWidth: '200px', - flexGrow: 1, - } - : {}), - ...((column as any).minWidth && { minWidth: (column as any).minWidth }), - ...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }), - }; + const renderColumnHeaders = useCallback( + () => ( +
+
+ {visibleColumns.map((column, index) => { + const columnStyle: ColumnStyle = { + width: column.width, + flexShrink: 0, + ...(column.id === 'labels' && column.width === 'auto' + ? { + minWidth: '200px', + flexGrow: 1, + } + : {}), + ...((column as any).minWidth && { minWidth: (column as any).minWidth }), + ...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }), + }; - return ( -
- {column.id === 'dragHandle' || column.id === 'checkbox' ? ( - - ) : (column as any).isCustom ? ( - - ) : ( - t(column.label || '') - )} -
- ); - })} - {/* Add Custom Column Button - positioned at the end and scrolls with content */} -
- + style={columnStyle} + > + {column.id === 'dragHandle' || column.id === 'checkbox' ? ( + + ) : (column as any).isCustom ? ( + + ) : ( + t(column.label || '') + )} +
+ ); + })} + {/* Add Custom Column Button - positioned at the end and scrolls with content */} +
+ +
-
- ), [visibleColumns, t, handleCustomColumnSettings]); - - + ), + [visibleColumns, t, handleCustomColumnSettings] + ); // Loading and error states if (loading || loadingColumns) return ; - if (error) return
{t('emptyStates.errorPrefix')} {error}
; + if (error) + return ( +
+ {t('emptyStates.errorPrefix')} {error} +
+ ); // Show message when no data if (groups.length === 0 && !loading) { @@ -556,58 +591,61 @@ const TaskListV2Section: React.FC = () => { } return ( - -
- - {/* Table Container */} + +
+ {/* Table Container */} +
+ {/* Task List Content with Sticky Header */}
- {/* Task List Content with Sticky Header */} + {/* Sticky Column Headers */}
- {/* Sticky Column Headers */} -
- {renderColumnHeaders()} -
- !('isAddTaskRow' in item) && !item.parent_task_id) - .map(item => item.id) - .filter((id): id is string => id !== undefined)} - strategy={verticalListSortingStrategy} - > -
- {/* Render groups manually for debugging */} - {virtuosoGroups.map((group, groupIndex) => ( -
- {/* Group Header */} - {renderGroup(groupIndex)} + {renderColumnHeaders()} +
+ !('isAddTaskRow' in item) && !item.parent_task_id) + .map(item => item.id) + .filter((id): id is string => id !== undefined)} + strategy={verticalListSortingStrategy} + > +
+ {/* Render groups manually for debugging */} + {virtuosoGroups.map((group, groupIndex) => ( +
+ {/* Group Header */} + {renderGroup(groupIndex)} - {/* Group Tasks */} - {!collapsedGroups.has(group.id) && group.tasks.map((task, taskIndex) => { - const globalTaskIndex = virtuosoGroups - .slice(0, groupIndex) - .reduce((sum, g) => sum + g.count, 0) + taskIndex; + {/* Group Tasks */} + {!collapsedGroups.has(group.id) && + group.tasks.map((task, taskIndex) => { + const globalTaskIndex = + virtuosoGroups.slice(0, groupIndex).reduce((sum, g) => sum + g.count, 0) + + taskIndex; return (
@@ -615,63 +653,73 @@ const TaskListV2Section: React.FC = () => {
); })} -
- ))} -
-
-
+
+ ))} +
+
+
- {/* Drag Overlay */} - - {activeId ? ( -
-
-
- -
-
- {allTasks.find(task => task.id === activeId)?.name || - allTasks.find(task => task.id === activeId)?.title || - t('emptyStates.dragTaskFallback')} -
-
- {allTasks.find(task => task.id === activeId)?.task_key} -
+ {/* Drag Overlay */} + + {activeId ? ( +
+
+
+ +
+
+ {allTasks.find(task => task.id === activeId)?.name || + allTasks.find(task => task.id === activeId)?.title || + t('emptyStates.dragTaskFallback')} +
+
+ {allTasks.find(task => task.id === activeId)?.task_key}
- ) : null} - - - {/* Bulk Action Bar */} - {selectedTaskIds.length > 0 && urlProjectId && ( -
- bulkActions.handleBulkStatusChange(statusId, selectedTaskIds)} - onBulkPriorityChange={(priorityId) => bulkActions.handleBulkPriorityChange(priorityId, selectedTaskIds)} - onBulkPhaseChange={(phaseId) => bulkActions.handleBulkPhaseChange(phaseId, selectedTaskIds)} - onBulkAssignToMe={() => bulkActions.handleBulkAssignToMe(selectedTaskIds)} - onBulkAssignMembers={(memberIds) => bulkActions.handleBulkAssignMembers(memberIds, selectedTaskIds)} - onBulkAddLabels={(labelIds) => bulkActions.handleBulkAddLabels(labelIds, selectedTaskIds)} - onBulkArchive={() => bulkActions.handleBulkArchive(selectedTaskIds)} - onBulkDelete={() => bulkActions.handleBulkDelete(selectedTaskIds)} - onBulkDuplicate={() => bulkActions.handleBulkDuplicate(selectedTaskIds)} - onBulkExport={() => bulkActions.handleBulkExport(selectedTaskIds)} - onBulkSetDueDate={(date) => bulkActions.handleBulkSetDueDate(date, selectedTaskIds)} - />
- )} + ) : null} + - {/* Custom Column Modal */} - {createPortal(, document.body, 'custom-column-modal')} -
- + {/* Bulk Action Bar */} + {selectedTaskIds.length > 0 && urlProjectId && ( +
+ + bulkActions.handleBulkStatusChange(statusId, selectedTaskIds) + } + onBulkPriorityChange={priorityId => + bulkActions.handleBulkPriorityChange(priorityId, selectedTaskIds) + } + onBulkPhaseChange={phaseId => + bulkActions.handleBulkPhaseChange(phaseId, selectedTaskIds) + } + onBulkAssignToMe={() => bulkActions.handleBulkAssignToMe(selectedTaskIds)} + onBulkAssignMembers={memberIds => + bulkActions.handleBulkAssignMembers(memberIds, selectedTaskIds) + } + onBulkAddLabels={labelIds => + bulkActions.handleBulkAddLabels(labelIds, selectedTaskIds) + } + onBulkArchive={() => bulkActions.handleBulkArchive(selectedTaskIds)} + onBulkDelete={() => bulkActions.handleBulkDelete(selectedTaskIds)} + onBulkDuplicate={() => bulkActions.handleBulkDuplicate(selectedTaskIds)} + onBulkExport={() => bulkActions.handleBulkExport(selectedTaskIds)} + onBulkSetDueDate={date => bulkActions.handleBulkSetDueDate(date, selectedTaskIds)} + /> +
+ )} + + {/* Custom Column Modal */} + {createPortal(, document.body, 'custom-column-modal')} +
+ ); }; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index bf2a663c..93510cd0 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -271,7 +271,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'checkbox': return ( -
+
= memo(({ taskId, projectId, visibleColumn case 'taskKey': return ( -
+
{task.task_key || 'N/A'} @@ -291,7 +291,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'title': return ( -
+
{/* Indentation for subtasks - tighter spacing */} {isSubtask &&
} @@ -417,7 +417,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'description': return ( -
+
= memo(({ taskId, projectId, visibleColumn case 'status': return ( -
+
= memo(({ taskId, projectId, visibleColumn case 'assignees': return ( -
+
= memo(({ taskId, projectId, visibleColumn case 'priority': return ( -
+
= memo(({ taskId, projectId, visibleColumn case 'dueDate': return ( -
+
{activeDatePicker === 'dueDate' ? (
= memo(({ taskId, projectId, visibleColumn case 'progress': return ( -
+
{task.progress !== undefined && task.progress >= 0 && (task.progress === 100 ? ( @@ -555,8 +555,13 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn ); case 'labels': + const labelsColumn = visibleColumns.find(col => col.id === 'labels'); + const labelsStyle = { + ...baseStyle, + ...(labelsColumn?.width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {}) + }; return ( -
+
@@ -564,7 +569,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'phase': return ( -
+
= memo(({ taskId, projectId, visibleColumn case 'timeTracking': return ( -
+
); case 'estimation': + // Use timeTracking.estimated which is the converted value from backend's total_minutes + const estimationDisplay = (() => { + const estimatedHours = task.timeTracking?.estimated; + + if (estimatedHours && estimatedHours > 0) { + // Convert decimal hours to hours and minutes for display + const hours = Math.floor(estimatedHours); + const minutes = Math.round((estimatedHours - hours) * 60); + + if (hours > 0 && minutes > 0) { + return `${hours}h ${minutes}m`; + } else if (hours > 0) { + return `${hours}h`; + } else if (minutes > 0) { + return `${minutes}m`; + } + } + + return null; + })(); + return ( -
- {task.timeTracking?.estimated ? ( +
+ {estimationDisplay ? ( - {task.timeTracking.estimated}h + {estimationDisplay} ) : ( - 0 + - )}
@@ -597,7 +623,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'startDate': return ( -
+
{activeDatePicker === 'startDate' ? (
= memo(({ taskId, projectId, visibleColumn case 'completedDate': return ( -
+
{formattedDates.completed ? ( {formattedDates.completed} @@ -668,7 +694,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'createdDate': return ( -
+
{formattedDates.created ? ( {formattedDates.created} @@ -680,9 +706,8 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn ); case 'lastUpdated': - console.log('formattedDates.updated', formattedDates.updated); return ( -
+
{formattedDates.updated ? ( {formattedDates.updated} @@ -695,7 +720,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'reporter': return ( -
+
{task.reporter ? ( {task.reporter} ) : ( @@ -709,7 +734,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn const column = visibleColumns.find(col => col.id === columnId); if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) { return ( -
+
= memo(({ case 'checkbox': case 'taskKey': case 'description': - return
; + return
; + case 'labels': + const labelsStyle = { + ...baseStyle, + ...(width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {}) + }; + return
; case 'title': return ( -
+
@@ -129,7 +135,7 @@ const AddTaskRow: React.FC = memo(({
); default: - return
; + return
; } }, [isAdding, taskName, handleAddTask, handleCancel, t]); diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index c555f6e3..c10805e1 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -281,8 +281,8 @@ export const fetchTasksV3 = createAsyncThunk( dueDate: task.dueDate, startDate: task.startDate, timeTracking: { - estimated: convertTimeValue(task.total_time), - logged: convertTimeValue(task.time_spent), + estimated: task.timeTracking?.estimated || 0, + logged: task.timeTracking?.logged || 0, }, customFields: {}, custom_column_values: task.custom_column_values || {}, diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index cf857653..cc55829b 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -670,15 +670,32 @@ export const useTaskSocketHandlers = () => { const handleEstimationChange = useCallback( - (task: { id: string; parent_task: string | null; estimation: number }) => { - if (!task) return; + (data: { id: string; parent_task: string | null; total_hours: number; total_minutes: number }) => { + if (!data) return; + // Update the old task slice (for backward compatibility) const taskWithProgress = { - ...task, + ...data, manual_progress: false, } as IProjectTask; dispatch(updateTaskEstimation({ task: taskWithProgress })); + + // Update task-management slice for task-list-v2 components + const currentTask = store.getState().taskManagement.entities[data.id]; + if (currentTask) { + const estimatedHours = (data.total_hours || 0) + (data.total_minutes || 0) / 60; + const updatedTask: Task = { + ...currentTask, + timeTracking: { + ...currentTask.timeTracking, + estimated: estimatedHours, + }, + updatedAt: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + dispatch(updateTask(updatedTask)); + } }, [dispatch] ); From 6f63041148864bc291411d9797b96d139a2c2a20 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 15:57:08 +0530 Subject: [PATCH 11/49] refactor(task-list): update status handling and enhance styling in TaskListV2Table - Modified status assignment in useTaskSocketHandlers to utilize actual status_id from the response for improved accuracy. - Simplified status logic by directly using data.status in task creation. - Enhanced styling in TaskListV2Table by adding border styles for better visual separation of elements. --- .../src/components/task-list-v2/TaskListV2Table.tsx | 4 ++-- worklenz-frontend/src/hooks/useTaskSocketHandlers.ts | 10 ++-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 99fb5bb1..172f1b74 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -515,7 +515,7 @@ const TaskListV2Section: React.FC = () => { return (
{ })} {/* Add Custom Column Button - positioned at the end and scrolls with content */}
diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index cc55829b..c00fa14d 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -232,7 +232,7 @@ export const useTaskSocketHandlers = () => { dispatch( updateTask({ ...currentTask, - status: newStatusValue, + status: response.status_id || newStatusValue, // Use actual status_id instead of category progress: response.complete_ratio || currentTask.progress, updatedAt: new Date().toISOString(), }) @@ -806,13 +806,7 @@ export const useTaskSocketHandlers = () => { task_key: data.task_key || '', title: data.name || '', description: data.description || '', - status: (data.status_category?.is_todo - ? 'todo' - : data.status_category?.is_doing - ? 'doing' - : data.status_category?.is_done - ? 'done' - : 'todo') as 'todo' | 'doing' | 'done', + status: data.status || 'todo', priority: (data.priority_value === 3 ? 'critical' : data.priority_value === 2 From 71f168f8fab40d84a01c82c6391f9dd5a2bb8a04 Mon Sep 17 00:00:00 2001 From: shancds Date: Wed, 9 Jul 2025 16:27:48 +0530 Subject: [PATCH 12/49] feat(kanban-board): add subtasks localization and enhance task drawer functionality - Added new localization keys for subtasks in the kanban board JSON file to improve user experience. - Updated the SubTaskTable component to dispatch enhanced kanban subtask updates upon deletion. - Enhanced the TaskDrawerHeader to handle deletion of subtasks with appropriate state updates in the kanban context. --- .../public/locales/en/kanban-board.json | 5 ++++- .../task-drawer/shared/info-tab/subtask-table.tsx | 13 +++++++++---- .../task-drawer-header/task-drawer-header.tsx | 12 ++++++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/worklenz-frontend/public/locales/en/kanban-board.json b/worklenz-frontend/public/locales/en/kanban-board.json index bc9b372a..e295a6c6 100644 --- a/worklenz-frontend/public/locales/en/kanban-board.json +++ b/worklenz-frontend/public/locales/en/kanban-board.json @@ -26,5 +26,8 @@ "noDueDate": "No due date", "save": "Save", "clear": "Clear", - "nextWeek": "Next week" + "nextWeek": "Next week", + "noSubtasks": "No subtasks", + "showSubtasks": "Show subtasks", + "hideSubtasks": "Hide subtasks" } diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx index 35073fab..d8ca806b 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx @@ -30,6 +30,7 @@ import { fetchTask, } from '@/features/task-drawer/task-drawer.slice'; import { updateSubtask } from '@/features/board/board-slice'; +import { updateEnhancedKanbanSubtask } from '@/features/enhanced-kanban/enhanced-kanban.slice'; type SubTaskTableProps = { subTasks: ISubTask[]; @@ -111,6 +112,13 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask try { await tasksApiService.deleteTask(taskId); + dispatch( + updateEnhancedKanbanSubtask({ + sectionId: '', + subtask: { id: taskId, parent_task_id: selectedTaskId || '', manual_progress: false }, + mode: 'delete', + }) + ); dispatch( updateSubtask({ sectionId: '', @@ -118,10 +126,7 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask mode: 'delete', }) ); - - // Note: Enhanced kanban updates are now handled by the global socket handler - // No need to dispatch here as it will be handled by useTaskSocketHandlers - + refreshSubTasks(); } catch (error) { logger.error('Error deleting subtask:', error); diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx index 735544ea..d87732cc 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx @@ -16,7 +16,7 @@ import { SocketEvents } from '@/shared/socket-events'; import useTaskDrawerUrlSync from '@/hooks/useTaskDrawerUrlSync'; import { deleteTask } from '@/features/tasks/tasks.slice'; import { deleteBoardTask, updateTaskName } from '@/features/board/board-slice'; -import { updateEnhancedKanbanTaskName } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { updateEnhancedKanbanTaskName, deleteTask as deleteKanbanTask, updateEnhancedKanbanSubtask } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import useTabSearchParam from '@/hooks/useTabSearchParam'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; @@ -60,7 +60,15 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { dispatch(setSelectedTaskId(null)); dispatch(deleteTask({ taskId: selectedTaskId })); dispatch(deleteBoardTask({ sectionId: '', taskId: selectedTaskId })); - + if (taskFormViewModel?.task?.is_sub_task) { + dispatch(updateEnhancedKanbanSubtask({ + sectionId: '', + subtask: { id: selectedTaskId, parent_task_id: taskFormViewModel?.task?.parent_task_id || '', manual_progress: false }, + mode: 'delete', + })); + } else { + dispatch(deleteKanbanTask(selectedTaskId)); // <-- Add this line + } // Reset the flag after a short delay setTimeout(() => { isDeleting.current = false; From deb0f3f6020284e48ed45757d3b47312842e574f Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 16:32:28 +0530 Subject: [PATCH 13/49] refactor(task-list): enhance task rendering and editing functionality in TaskRow and TaskListV2Table - Updated TaskListV2Table to pass isFirstInGroup prop to renderTask for improved task grouping logic. - Enhanced TaskRow to support inline editing of task names with a new input field and associated state management. - Implemented click outside detection to save task name changes when editing is complete. - Improved layout and styling for better user experience during task editing and display. --- .../task-list-v2/TaskListV2Table.tsx | 8 +- .../src/components/task-list-v2/TaskRow.tsx | 342 +++++++++++------- .../task-list-v2/TaskRowWithSubtasks.tsx | 3 + 3 files changed, 227 insertions(+), 126 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 172f1b74..cc09e29e 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -457,7 +457,7 @@ const TaskListV2Section: React.FC = () => { ); const renderTask = useCallback( - (taskIndex: number) => { + (taskIndex: number, isFirstInGroup: boolean = false) => { const item = virtuosoItems[taskIndex]; if (!item || !urlProjectId) return null; @@ -480,6 +480,7 @@ const TaskListV2Section: React.FC = () => { taskId={item.id} projectId={urlProjectId} visibleColumns={visibleColumns} + isFirstInGroup={isFirstInGroup} updateTaskCustomColumnValue={updateTaskCustomColumnValue} /> ); @@ -647,9 +648,12 @@ const TaskListV2Section: React.FC = () => { virtuosoGroups.slice(0, groupIndex).reduce((sum, g) => sum + g.count, 0) + taskIndex; + // Check if this is the first actual task in the group (not AddTaskRow) + const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task); + return (
- {renderTask(globalTaskIndex)} + {renderTask(globalTaskIndex, isFirstTaskInGroup)}
); })} diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index 93510cd0..ba4e4370 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -1,8 +1,9 @@ -import React, { memo, useMemo, useCallback, useState } from 'react'; +import React, { memo, useMemo, useCallback, useState, useRef, useEffect } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { CheckCircleOutlined, HolderOutlined, CloseOutlined, DownOutlined, RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons'; -import { Checkbox, DatePicker, Tooltip } from 'antd'; +import { Checkbox, DatePicker, Tooltip, Input } from 'antd'; +import type { InputRef } from 'antd'; import { dayjs, taskManagementAntdConfig } from '@/shared/antd-imports'; import { Task } from '@/types/task-management.types'; import { InlineMember } from '@/types/teamMembers/inlineMember.types'; @@ -40,6 +41,7 @@ interface TaskRowProps { isCustom?: boolean; }>; isSubtask?: boolean; + isFirstInGroup?: boolean; updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; } @@ -97,7 +99,7 @@ const formatDate = (dateString: string): string => { } }; -const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumns, isSubtask = false, updateTaskCustomColumnValue }) => { +const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumns, isSubtask = false, isFirstInGroup = false, updateTaskCustomColumnValue }) => { const dispatch = useAppDispatch(); const task = useAppSelector(state => selectTaskById(state, taskId)); const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId)); @@ -106,6 +108,12 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn // State for tracking which date picker is open const [activeDatePicker, setActiveDatePicker] = useState(null); + + // State for editing task name + const [editTaskName, setEditTaskName] = useState(false); + const [taskName, setTaskName] = useState(task.title || task.name || ''); + const inputRef = useRef(null); + const wrapperRef = useRef(null); if (!task) { return null; // Don't render if task is not found in store @@ -153,6 +161,45 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn manual_progress: undefined, }), [task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]); + // Handle task name save + const handleTaskNameSave = useCallback(() => { + const newTaskName = inputRef.current?.input?.value || taskName; + if (newTaskName?.trim() !== '' && connected && newTaskName.trim() !== (task.title || task.name || '').trim()) { + socket?.emit( + SocketEvents.TASK_NAME_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + name: newTaskName.trim(), + parent_task: task.parent_task_id, + }) + ); + } + setEditTaskName(false); + }, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name]); + + // Handle click outside for task name editing + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) { + handleTaskNameSave(); + } + }; + + if (editTaskName) { + document.addEventListener('mousedown', handleClickOutside); + inputRef.current?.focus(); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [editTaskName, handleTaskNameSave]); + + // Update local taskName state when task name changes + useEffect(() => { + setTaskName(task.title || task.name || ''); + }, [task.title, task.name]); + // Memoize formatted dates const formattedDates = useMemo(() => ({ due: (() => { @@ -289,129 +336,169 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn
); - case 'title': + case 'title': return ( -
-
- {/* Indentation for subtasks - tighter spacing */} - {isSubtask &&
} - - {/* Expand/Collapse button - only show for parent tasks */} - {!isSubtask && ( - - )} - - {/* Additional indentation for subtasks after the expand button space */} - {isSubtask &&
} - -
- {/* Task name with dynamic width */} -
- - + {editTaskName ? ( + /* Full cell input when editing */ +
+ setTaskName(e.target.value)} + autoFocus + onPressEnter={handleTaskNameSave} + onBlur={handleTaskNameSave} + className="text-sm" + style={{ + width: '100%', + height: '38px', + margin: '0', + padding: '8px 12px', + border: '1px solid #1677ff', + backgroundColor: 'rgba(22, 119, 255, 0.02)', + borderRadius: '3px', + fontSize: '14px', + lineHeight: '22px', + boxSizing: 'border-box', + outline: 'none', + boxShadow: '0 0 0 2px rgba(22, 119, 255, 0.1)', + }} + /> +
+ ) : ( + /* Normal layout when not editing */ + <> +
+ {/* Indentation for subtasks - tighter spacing */} + {isSubtask &&
} + + {/* Expand/Collapse button - only show for parent tasks */} + {!isSubtask && ( + + )} + + {/* Additional indentation for subtasks after the expand button space */} + {isSubtask &&
} + +
+ {/* Task name with dynamic width */} +
+ { + e.stopPropagation(); + e.preventDefault(); + setEditTaskName(true); + }} + title={taskDisplayName} + > + {taskDisplayName} + +
+ + {/* Indicators container - flex-shrink-0 to prevent compression */} +
+ {/* Subtask count indicator - only show if count > 0 */} + {!isSubtask && task.sub_tasks_count != null && task.sub_tasks_count > 0 && ( + +
+ + {task.sub_tasks_count} + + +
+
+ )} + + {/* Task indicators - compact layout */} + {task.comments_count != null && task.comments_count !== 0 && ( + + + + )} + + {task.has_subscribers && ( + + + + )} + + {task.attachments_count != null && task.attachments_count !== 0 && ( + + + + )} + + {task.has_dependencies && ( + + + + )} + + {task.schedule_id && ( + + + + )} +
+
- {/* Indicators container - flex-shrink-0 to prevent compression */} -
- {/* Subtask count indicator - only show if count > 0 */} - {!isSubtask && task.sub_tasks_count != null && task.sub_tasks_count > 0 && ( - -
- - {task.sub_tasks_count} - - -
-
- )} - - {/* Task indicators - compact layout */} - {task.comments_count != null && task.comments_count !== 0 && ( - - - - )} - - {task.has_subscribers && ( - - - - )} - - {task.attachments_count != null && task.attachments_count !== 0 && ( - - - - )} - - {task.has_dependencies && ( - - - - )} - - {task.schedule_id && ( - - - - )} -
-
-
- - + + + )}
); @@ -755,6 +842,10 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn isDarkMode, projectId, + // Edit task name state - CRITICAL for re-rendering + editTaskName, + taskName, + // Task data - include specific fields that might update via socket task, task.labels, // Explicit dependency for labels updates @@ -775,6 +866,7 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn // Handlers handleDateChange, datePickerHandlers, + handleTaskNameSave, // Translation t, @@ -787,8 +879,10 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn return (
diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx index ae7489fd..f0a95fdb 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx @@ -20,6 +20,7 @@ interface TaskRowWithSubtasksProps { width: string; isSticky?: boolean; }>; + isFirstInGroup?: boolean; updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; } @@ -153,6 +154,7 @@ const TaskRowWithSubtasks: React.FC = memo(({ taskId, projectId, visibleColumns, + isFirstInGroup = false, updateTaskCustomColumnValue }) => { const task = useAppSelector(state => selectTaskById(state, taskId)); @@ -175,6 +177,7 @@ const TaskRowWithSubtasks: React.FC = memo(({ taskId={taskId} projectId={projectId} visibleColumns={visibleColumns} + isFirstInGroup={isFirstInGroup} updateTaskCustomColumnValue={updateTaskCustomColumnValue} /> From 635b5ce8e16a2442a5d24d83078f44dffc515171 Mon Sep 17 00:00:00 2001 From: shancds Date: Wed, 9 Jul 2025 16:55:03 +0530 Subject: [PATCH 14/49] feat(task-drawer): add functionality to hide task drawer on task deletion - Imported setShowTaskDrawer action to manage task drawer visibility. - Updated TaskDrawerHeader to dispatch setShowTaskDrawer(false) after task deletion, improving user experience by closing the drawer automatically. --- .../task-drawer/task-drawer-header/task-drawer-header.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx index 61190bb1..8096a8e8 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx @@ -10,7 +10,7 @@ import { useAuthService } from '@/hooks/useAuth'; import TaskDrawerStatusDropdown from '../task-drawer-status-dropdown/task-drawer-status-dropdown'; import { tasksApiService } from '@/api/tasks/tasks.api.service'; import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { setSelectedTaskId } from '@/features/task-drawer/task-drawer.slice'; +import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import useTaskDrawerUrlSync from '@/hooks/useTaskDrawerUrlSync'; @@ -73,6 +73,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { } else { dispatch(deleteKanbanTask(selectedTaskId)); // <-- Add this line } + dispatch(setShowTaskDrawer(false)); // Reset the flag after a short delay setTimeout(() => { clearTaskFromUrl(); From db9b481e8d2532704c79d49e6b10cc1d855c9b2d Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 17:02:37 +0530 Subject: [PATCH 15/49] refactor(task-list): enhance task addition functionality in TaskListV2Table and AddTaskRow - Introduced state management for dynamic add task rows in TaskListV2Table, allowing real-time updates when tasks are added. - Updated handleTaskAdded to manage new task row creation based on group ID. - Enhanced AddTaskRow to support auto-focus functionality and unique row identification for improved user experience during task addition. - Refactored input handling in AddTaskRow to maintain focus and streamline task creation process. --- .../task-list-v2/TaskListV2Table.tsx | 45 ++++++++- .../task-list-v2/TaskRowWithSubtasks.tsx | 93 ++++++++++++++----- .../task-list-v2/components/AddTaskRow.tsx | 47 +++++++--- 3 files changed, 147 insertions(+), 38 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index cc09e29e..0859270b 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -103,6 +103,7 @@ const TaskListV2Section: React.FC = () => { // State hooks const [initializedFromDatabase, setInitializedFromDatabase] = useState(false); + const [addTaskRows, setAddTaskRows] = useState<{[groupId: string]: string[]}>({}); // Configure sensors for drag and drop const sensors = useSensors( @@ -340,9 +341,22 @@ const TaskListV2Section: React.FC = () => { ); // Add callback for task added - const handleTaskAdded = useCallback(() => { + const handleTaskAdded = useCallback((rowId: string) => { // Task is now added in real-time via socket, no need to refetch // The global socket handler will handle the real-time update + + // Find the group this row belongs to + const groupId = rowId.split('-')[2]; // Extract from rowId format: add-task-{groupId}-{index} + + // Add a new add task row to this group + setAddTaskRows(prev => { + const currentRows = prev[groupId] || []; + const newRowId = `add-task-${groupId}-${currentRows.length + 1}`; + return { + ...prev, + [groupId]: [...currentRows, newRowId] + }; + }); }, []); // Handle scroll synchronization - disabled since header is now sticky inside content @@ -368,18 +382,37 @@ const TaskListV2Section: React.FC = () => { originalIndex: allTasks.indexOf(task), })); - const itemsWithAddTask = !isCurrentGroupCollapsed + // Get add task rows for this group + const groupAddRows = addTaskRows[group.id] || []; + const addTaskItems = !isCurrentGroupCollapsed ? [ - ...tasksForVirtuoso, + // Default add task row { - id: `add-task-${group.id}`, + id: `add-task-${group.id}-0`, isAddTaskRow: true, groupId: group.id, groupType: currentGrouping || 'status', groupValue: group.id, // Use the actual database ID from backend projectId: urlProjectId, + rowId: `add-task-${group.id}-0`, + autoFocus: false, }, + // Additional add task rows + ...groupAddRows.map((rowId, index) => ({ + id: rowId, + isAddTaskRow: true, + groupId: group.id, + groupType: currentGrouping || 'status', + groupValue: group.id, + projectId: urlProjectId, + rowId: rowId, + autoFocus: index === groupAddRows.length - 1, // Auto-focus the latest row + })) ] + : []; + + const itemsWithAddTask = !isCurrentGroupCollapsed + ? [...tasksForVirtuoso, ...addTaskItems] : tasksForVirtuoso; const groupData = { @@ -393,7 +426,7 @@ const TaskListV2Section: React.FC = () => { currentTaskIndex += itemsWithAddTask.length; return groupData; }); - }, [groups, allTasks, collapsedGroups, currentGrouping, urlProjectId]); + }, [groups, allTasks, collapsedGroups, currentGrouping, urlProjectId, addTaskRows]); const virtuosoGroupCounts = useMemo(() => { return virtuosoGroups.map(group => group.count); @@ -471,6 +504,8 @@ const TaskListV2Section: React.FC = () => { projectId={urlProjectId} visibleColumns={visibleColumns} onTaskAdded={handleTaskAdded} + rowId={item.rowId} + autoFocus={item.autoFocus} /> ); } diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx index f0a95fdb..b9a511e6 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx @@ -1,4 +1,4 @@ -import React, { memo, useState, useCallback } from 'react'; +import React, { memo, useState, useCallback, useRef, useEffect } from 'react'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { selectTaskById, createSubtask, selectSubtaskLoading } from '@/features/task-management/task-management.slice'; @@ -32,17 +32,22 @@ interface AddSubtaskRowProps { width: string; isSticky?: boolean; }>; - onSubtaskAdded: () => void; + onSubtaskAdded: (rowId: string) => void; + rowId: string; // Unique identifier for this add subtask row + autoFocus?: boolean; // Whether this row should auto-focus on mount } const AddSubtaskRow: React.FC = memo(({ parentTaskId, projectId, visibleColumns, - onSubtaskAdded + onSubtaskAdded, + rowId, + autoFocus = false }) => { - const [isAdding, setIsAdding] = useState(false); + const [isAdding, setIsAdding] = useState(autoFocus); const [subtaskName, setSubtaskName] = useState(''); + const inputRef = useRef(null); const { socket, connected } = useSocket(); const { t } = useTranslation('task-list-table'); const dispatch = useAppDispatch(); @@ -50,6 +55,16 @@ const AddSubtaskRow: React.FC = memo(({ // Get session data for reporter_id and team_id const currentSession = useAuthService().getCurrentSession(); + // Auto-focus when autoFocus prop is true + useEffect(() => { + if (autoFocus && inputRef.current) { + setIsAdding(true); + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + } + }, [autoFocus]); + const handleAddSubtask = useCallback(() => { if (!subtaskName.trim() || !currentSession) return; @@ -75,14 +90,22 @@ const AddSubtaskRow: React.FC = memo(({ } setSubtaskName(''); - setIsAdding(false); - onSubtaskAdded(); - }, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, currentSession, onSubtaskAdded]); + // Keep the input active and notify parent to create new row + onSubtaskAdded(rowId); + }, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, currentSession, onSubtaskAdded, rowId]); const handleCancel = useCallback(() => { - setSubtaskName(''); - setIsAdding(false); - }, []); + if (subtaskName.trim() === '') { + setSubtaskName(''); + setIsAdding(false); + } + }, [subtaskName]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + handleCancel(); + } + }, [handleCancel]); const renderColumn = useCallback((columnId: string, width: string) => { const baseStyle = { width }; @@ -114,10 +137,12 @@ const AddSubtaskRow: React.FC = memo(({ ) : ( setSubtaskName(e.target.value)} onPressEnter={handleAddSubtask} onBlur={handleCancel} + onKeyDown={handleKeyDown} placeholder="Type subtask name and press Enter to save" className="w-full h-full border-none shadow-none bg-transparent" style={{ @@ -135,7 +160,7 @@ const AddSubtaskRow: React.FC = memo(({ default: return
; } - }, [isAdding, subtaskName, handleAddSubtask, handleCancel, t]); + }, [isAdding, subtaskName, handleAddSubtask, handleCancel, handleKeyDown, t]); return (
@@ -160,11 +185,28 @@ const TaskRowWithSubtasks: React.FC = memo(({ const task = useAppSelector(state => selectTaskById(state, taskId)); const isLoadingSubtasks = useAppSelector(state => selectSubtaskLoading(state, taskId)); const dispatch = useAppDispatch(); + const [addSubtaskRows, setAddSubtaskRows] = useState([`add-subtask-${taskId}-0`]); + const [activeRowId, setActiveRowId] = useState(null); - const handleSubtaskAdded = useCallback(() => { + const handleSubtaskAdded = useCallback((rowId: string) => { // Refresh subtasks after adding a new one // The socket event will handle the real-time update - }, []); + + // Only add a new row if this is the last (most recent) row + setAddSubtaskRows(prev => { + const currentIndex = prev.indexOf(rowId); + const isLastRow = currentIndex === prev.length - 1; + + if (isLastRow) { + const newRowId = `add-subtask-${taskId}-${prev.length}`; + // Set the new row as active + setActiveRowId(newRowId); + return [...prev, newRowId]; + } + + return prev; // Don't add new row if this isn't the last row + }); + }, [taskId]); if (!task) { return null; @@ -204,16 +246,23 @@ const TaskRowWithSubtasks: React.FC = memo(({
))} - {/* Add subtask row - only show when not loading */} + {/* Add subtask rows - only show when not loading */} {!isLoadingSubtasks && ( -
- -
+ <> + {/* Render all add subtask rows */} + {addSubtaskRows.map((rowId, index) => ( +
+ +
+ ))} + )} )} diff --git a/worklenz-frontend/src/components/task-list-v2/components/AddTaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/components/AddTaskRow.tsx index 1107bf22..72ae7e3e 100644 --- a/worklenz-frontend/src/components/task-list-v2/components/AddTaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/components/AddTaskRow.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, memo } from 'react'; +import React, { useState, useCallback, memo, useRef, useEffect } from 'react'; import { Input } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; @@ -16,7 +16,9 @@ interface AddTaskRowProps { width: string; isSticky?: boolean; }>; - onTaskAdded: () => void; + onTaskAdded: (rowId: string) => void; + rowId: string; // Unique identifier for this add task row + autoFocus?: boolean; // Whether this row should auto-focus on mount } const AddTaskRow: React.FC = memo(({ @@ -25,16 +27,29 @@ const AddTaskRow: React.FC = memo(({ groupValue, projectId, visibleColumns, - onTaskAdded + onTaskAdded, + rowId, + autoFocus = false }) => { - const [isAdding, setIsAdding] = useState(false); + const [isAdding, setIsAdding] = useState(autoFocus); const [taskName, setTaskName] = useState(''); + const inputRef = useRef(null); const { socket, connected } = useSocket(); const { t } = useTranslation('task-list-table'); // Get session data for reporter_id and team_id const currentSession = useAuthService().getCurrentSession(); + // Auto-focus when autoFocus prop is true + useEffect(() => { + if (autoFocus && inputRef.current) { + setIsAdding(true); + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + } + }, [autoFocus]); + // The global socket handler (useTaskSocketHandlers) will handle task addition // No need for local socket listener to avoid duplicate additions @@ -67,10 +82,10 @@ const AddTaskRow: React.FC = memo(({ } if (socket && connected) { - socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body)); setTaskName(''); - setIsAdding(false); + // Keep the input active and notify parent to create new row + onTaskAdded(rowId); // Task refresh will be handled by socket response listener } else { console.warn('Socket not connected, unable to create task'); @@ -78,12 +93,20 @@ const AddTaskRow: React.FC = memo(({ } catch (error) { console.error('Error creating task:', error); } - }, [taskName, projectId, groupType, groupValue, socket, connected, currentSession]); + }, [taskName, projectId, groupType, groupValue, socket, connected, currentSession, onTaskAdded, rowId]); const handleCancel = useCallback(() => { - setTaskName(''); - setIsAdding(false); - }, []); + if (taskName.trim() === '') { + setTaskName(''); + setIsAdding(false); + } + }, [taskName]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + handleCancel(); + } + }, [handleCancel]); const renderColumn = useCallback((columnId: string, width: string) => { const baseStyle = { width }; @@ -116,10 +139,12 @@ const AddTaskRow: React.FC = memo(({ ) : ( setTaskName(e.target.value)} onPressEnter={handleAddTask} onBlur={handleCancel} + onKeyDown={handleKeyDown} placeholder="Type task name and press Enter to save" className="w-full h-full border-none shadow-none bg-transparent" style={{ @@ -137,7 +162,7 @@ const AddTaskRow: React.FC = memo(({ default: return
; } - }, [isAdding, taskName, handleAddTask, handleCancel, t]); + }, [isAdding, taskName, handleAddTask, handleCancel, handleKeyDown, t]); return (
From 8f5de8f1a128411cae0ca2071d638a0d298d0d46 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 9 Jul 2025 17:11:15 +0530 Subject: [PATCH 16/49] refactor(task-management): update search handling and improve task filtering - Modified search handling to utilize the taskManagement slice for consistent state management across components. - Enhanced placeholder text in search filters for better user guidance. - Updated task fetching logic to ensure accurate search value retrieval from the correct state slice. --- .../src/controllers/tasks-controller-v2.ts | 2 +- .../task-list-v2/TaskRowWithSubtasks.tsx | 99 ++++++++++++------- .../task-management/improved-task-filters.tsx | 17 ++-- .../task-management/task-management.slice.ts | 4 +- 4 files changed, 73 insertions(+), 49 deletions(-) diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 3c17c974..27df13e7 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -109,7 +109,7 @@ export default class TasksControllerV2 extends TasksControllerBase { } private static getQuery(userId: string, options: ParsedQs) { - const searchField = options.search ? "t.name" : "sort_order"; + const searchField = options.search ? ["t.name", "CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no)"] : "sort_order"; const { searchQuery, sortField } = TasksControllerV2.toPaginationOptions(options, searchField); const isSubTasks = !!options.parent_task; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx index b9a511e6..e38c0b72 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx @@ -35,6 +35,8 @@ interface AddSubtaskRowProps { onSubtaskAdded: (rowId: string) => void; rowId: string; // Unique identifier for this add subtask row autoFocus?: boolean; // Whether this row should auto-focus on mount + isActive?: boolean; // Whether this row should show the input/button + onActivate?: (rowId: string) => void; // Callback when row becomes active } const AddSubtaskRow: React.FC = memo(({ @@ -43,7 +45,9 @@ const AddSubtaskRow: React.FC = memo(({ visibleColumns, onSubtaskAdded, rowId, - autoFocus = false + autoFocus = false, + isActive = true, + onActivate }) => { const [isAdding, setIsAdding] = useState(autoFocus); const [subtaskName, setSubtaskName] = useState(''); @@ -127,32 +131,40 @@ const AddSubtaskRow: React.FC = memo(({
- {!isAdding ? ( - + {isActive ? ( + !isAdding ? ( + + ) : ( + setSubtaskName(e.target.value)} + onPressEnter={handleAddSubtask} + onBlur={handleCancel} + onKeyDown={handleKeyDown} + placeholder="Type subtask name and press Enter to save" + className="w-full h-full border-none shadow-none bg-transparent" + style={{ + height: '100%', + minHeight: '32px', + padding: '0', + fontSize: '14px' + }} + autoFocus + /> + ) ) : ( - setSubtaskName(e.target.value)} - onPressEnter={handleAddSubtask} - onBlur={handleCancel} - onKeyDown={handleKeyDown} - placeholder="Type subtask name and press Enter to save" - className="w-full h-full border-none shadow-none bg-transparent" - style={{ - height: '100%', - minHeight: '32px', - padding: '0', - fontSize: '14px' - }} - autoFocus - /> + // Empty space when not active +
)}
@@ -208,6 +220,10 @@ const TaskRowWithSubtasks: React.FC = memo(({ }); }, [taskId]); + const handleRowActivate = useCallback((rowId: string) => { + setActiveRowId(rowId); + }, []); + if (!task) { return null; } @@ -250,18 +266,25 @@ const TaskRowWithSubtasks: React.FC = memo(({ {!isLoadingSubtasks && ( <> {/* Render all add subtask rows */} - {addSubtaskRows.map((rowId, index) => ( -
- -
- ))} + {addSubtaskRows.map((rowId, index) => { + const isLastRow = index === addSubtaskRows.length - 1; + const isRowActive = activeRowId === null ? isLastRow : activeRowId === rowId; + + return ( +
+ +
+ ); + })} )} diff --git a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx index 807d1280..b14d27e3 100644 --- a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx +++ b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx @@ -660,7 +660,7 @@ const SearchFilter: React.FC<{ type="text" value={localValue} onChange={e => setLocalValue(e.target.value)} - placeholder={placeholder || t('searchTasks')} + placeholder={placeholder || t('searchTasks') || 'Search tasks by name or key...'} className={`w-full pr-4 pl-8 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors duration-150 ${ isDarkMode ? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600' @@ -919,10 +919,10 @@ const ImprovedTaskFilters: React.FC = ({ position, cla useFilterDataLoader(); // Get search value from Redux based on position - const taskReducerSearch = useAppSelector(state => state.taskReducer?.search || ''); + const taskManagementSearch = useAppSelector(state => state.taskManagement?.search || ''); const kanbanSearch = useAppSelector(state => state.enhancedKanbanReducer?.search || ''); - const searchValue = position === 'board' ? kanbanSearch : taskReducerSearch; + const searchValue = position === 'board' ? kanbanSearch : taskManagementSearch; // Local state for filter sections const [filterSections, setFilterSections] = useState([]); @@ -1001,8 +1001,8 @@ const ImprovedTaskFilters: React.FC = ({ position, cla // Debounced search change function debouncedSearchChangeRef.current = createDebouncedFunction( (projectId: string, value: string) => { - // Always use taskReducer search for list view since that's what we read from - dispatch(setSearch(value)); + // Use taskManagement search for list view + dispatch(setTaskManagementSearch(value)); // Trigger task refetch with new search value dispatch(fetchTasksV3(projectId)); @@ -1142,6 +1142,7 @@ const ImprovedTaskFilters: React.FC = ({ position, cla } } else { // Use debounced search for list view + dispatch(setTaskManagementSearch(value)); if (projectId) { debouncedSearchChangeRef.current?.(projectId, value); } @@ -1177,8 +1178,8 @@ const ImprovedTaskFilters: React.FC = ({ position, cla // Prepare all Redux actions to be dispatched together const reduxUpdates = () => { - // Clear search - always use taskReducer for list view - dispatch(setSearch('')); + // Clear search - use taskManagementSearch for list view + dispatch(setTaskManagementSearch('')); // Clear label filters const clearedLabels = currentTaskLabels.map(label => ({ @@ -1249,7 +1250,7 @@ const ImprovedTaskFilters: React.FC = ({ position, cla diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index c10805e1..ef2f34f9 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -227,8 +227,8 @@ export const fetchTasksV3 = createAsyncThunk( // Get selected priorities from taskReducer const selectedPriorities = state.taskReducer.priorities.join(' '); - // Get search value from taskReducer - const searchValue = state.taskReducer.search || ''; + // Get search value from taskManagement slice + const searchValue = state.taskManagement.search || ''; // Get archived state from task management slice const archivedState = state.taskManagement.archived; From 75c55fff21daebe6cbe4cf3562b706eafc2b73fb Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 9 Jul 2025 22:38:58 +0530 Subject: [PATCH 17/49] refactor(search): improve SQL search handling and optimize project list component - Enhanced search handling in WorklenzControllerBase to properly escape single quotes, preventing SQL syntax errors. - Refactored search logic in ProjectList to maintain reference stability and improve performance during debounced searches. - Removed unnecessary console logs and optimized loading state management for better user experience. --- .../controllers/worklenz-controller-base.ts | 31 +++--- .../src/pages/projects/project-list.tsx | 105 ++++++++---------- 2 files changed, 62 insertions(+), 74 deletions(-) diff --git a/worklenz-backend/src/controllers/worklenz-controller-base.ts b/worklenz-backend/src/controllers/worklenz-controller-base.ts index 60d0c998..c494f47b 100644 --- a/worklenz-backend/src/controllers/worklenz-controller-base.ts +++ b/worklenz-backend/src/controllers/worklenz-controller-base.ts @@ -34,29 +34,24 @@ export default abstract class WorklenzControllerBase { const offset = queryParams.search ? 0 : (index - 1) * size; const paging = queryParams.paging || "true"; - // let s = ""; - // if (typeof searchField === "string") { - // s = `${searchField} || ' ' || id::TEXT`; - // } else if (Array.isArray(searchField)) { - // s = searchField.join(" || ' ' || "); - // } - - // const search = (queryParams.search as string || "").trim(); - // const searchQuery = search ? `AND TO_TSVECTOR(${s}) @@ TO_TSQUERY('${toTsQuery(search)}')` : ""; - const search = (queryParams.search as string || "").trim(); - let s = ""; - if (typeof searchField === "string") { - s = ` ${searchField} ILIKE '%${search}%'`; - } else if (Array.isArray(searchField)) { - s = searchField.map(index => ` ${index} ILIKE '%${search}%'`).join(" OR "); - } - let searchQuery = ""; if (search) { - searchQuery = isMemberFilter ? ` (${s}) AND ` : ` AND (${s}) `; + // Properly escape single quotes to prevent SQL syntax errors + const escapedSearch = search.replace(/'/g, "''"); + + let s = ""; + if (typeof searchField === "string") { + s = ` ${searchField} ILIKE '%${escapedSearch}%'`; + } else if (Array.isArray(searchField)) { + s = searchField.map(field => ` ${field} ILIKE '%${escapedSearch}%'`).join(" OR "); + } + + if (s) { + searchQuery = isMemberFilter ? ` (${s}) AND ` : ` AND (${s}) `; + } } // Sort diff --git a/worklenz-frontend/src/pages/projects/project-list.tsx b/worklenz-frontend/src/pages/projects/project-list.tsx index e6df7839..38729c17 100644 --- a/worklenz-frontend/src/pages/projects/project-list.tsx +++ b/worklenz-frontend/src/pages/projects/project-list.tsx @@ -13,7 +13,6 @@ import { Pagination, Segmented, Select, - Skeleton, Table, TablePaginationConfig, Tooltip, @@ -77,7 +76,6 @@ import { } from '@/shared/worklenz-analytics-events'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import ProjectGroupList from '@/components/project-list/project-group/project-group-list'; -import { groupProjects } from '@/utils/project-group'; const createFilters = (items: { id: string; name: string }[]) => items.map(item => ({ text: item.name, value: item.id })) as ColumnFilterItem[]; @@ -129,7 +127,8 @@ const ProjectList: React.FC = () => { return params; } - return params; + // Return the previous params to maintain reference stability + return JSON.parse(lastQueryParamsRef.current || '{}'); }, [requestParams]); // Use the optimized query with better error handling and caching @@ -148,6 +147,8 @@ const ProjectList: React.FC = () => { skip: viewMode === ProjectViewType.GROUP, }); + + // Add performance monitoring const performanceRef = useRef<{ startTime: number | null }>({ startTime: null }); @@ -156,17 +157,13 @@ const ProjectList: React.FC = () => { if (loadingProjects && !performanceRef.current.startTime) { performanceRef.current.startTime = performance.now(); } else if (!loadingProjects && performanceRef.current.startTime) { - const duration = performance.now() - performanceRef.current.startTime; - console.log(`Projects query completed in ${duration.toFixed(2)}ms`); performanceRef.current.startTime = null; } }, [loadingProjects]); // Optimized debounced search with better cleanup and performance const debouncedSearch = useCallback( - debounce((searchTerm: string) => { - console.log('Executing debounced search:', searchTerm); - + debounce((searchTerm: string) => { // Clear any error messages when starting a new search setErrorMessage(null); @@ -372,7 +369,6 @@ const ProjectList: React.FC = () => { // Handle query errors useEffect(() => { if (projectsError) { - console.error('Projects query error:', projectsError); setErrorMessage('Failed to load projects. Please try again.'); } else { setErrorMessage(null); @@ -392,7 +388,6 @@ const ProjectList: React.FC = () => { await dispatch(fetchGroupedProjects(groupedRequestParams)).unwrap(); } } catch (error) { - console.error('Error refreshing projects:', error); setErrorMessage('Failed to refresh projects. Please try again.'); } finally { setIsLoading(false); @@ -632,7 +627,7 @@ const ProjectList: React.FC = () => { }, { title: t('category'), - dataIndex: 'category', + dataIndex: 'category_name', key: 'category_id', filters: categoryFilters, filteredValue: filteredInfo.category_id || filteredCategories || [], @@ -647,7 +642,7 @@ const ProjectList: React.FC = () => { dataIndex: 'status', key: 'status_id', filters: statusFilters, - filteredValue: filteredInfo.status_id || [], + filteredValue: filteredInfo.status_id || filteredStatuses || [], filterMultiple: true, sorter: true, }, @@ -785,10 +780,10 @@ const ProjectList: React.FC = () => { // Sync search input value with Redux state useEffect(() => { const currentSearch = viewMode === ProjectViewType.LIST ? requestParams.search : groupedRequestParams.search; - if (searchValue !== currentSearch) { + if (searchValue !== (currentSearch || '')) { setSearchValue(currentSearch || ''); } - }, [requestParams.search, groupedRequestParams.search, viewMode, searchValue]); + }, [requestParams.search, groupedRequestParams.search, viewMode]); // Remove searchValue from deps to prevent loops // Optimize loading state management useEffect(() => { @@ -854,49 +849,47 @@ const ProjectList: React.FC = () => { } /> - - {viewMode === ProjectViewType.LIST ? ( - - columns={tableColumns} - dataSource={tableDataSource} - rowKey={record => record.id || ''} - loading={loadingProjects} - size="small" - onChange={handleTableChange} - pagination={paginationConfig} - locale={{ emptyText: emptyContent }} - onRow={record => ({ - onClick: () => navigateToProject(record.id, record.team_member_default_view), - onMouseEnter: () => handleProjectHover(record.id), - })} + {viewMode === ProjectViewType.LIST ? ( + + columns={tableColumns} + dataSource={tableDataSource} + rowKey={record => record.id || ''} + loading={loadingProjects || isFetchingProjects} + size="small" + onChange={handleTableChange} + pagination={paginationConfig} + locale={{ emptyText: emptyContent }} + onRow={record => ({ + onClick: () => navigateToProject(record.id, record.team_member_default_view), + onMouseEnter: () => handleProjectHover(record.id), + })} + /> + ) : ( +
+ navigateToProject(id, undefined)} + onArchive={() => {}} + isOwnerOrAdmin={isOwnerOrAdmin} + loading={groupedProjects.loading} + t={t} /> - ) : ( -
- navigateToProject(id, undefined)} - onArchive={() => {}} - isOwnerOrAdmin={isOwnerOrAdmin} - loading={groupedProjects.loading} - t={t} - /> - {!groupedProjects.loading && - groupedProjects.data?.data && - groupedProjects.data.data.length > 0 && ( -
- - handleGroupedTableChange({ current: page, pageSize }) - } - showTotal={paginationShowTotal} - /> -
- )} -
- )} - + {!groupedProjects.loading && + groupedProjects.data?.data && + groupedProjects.data.data.length > 0 && ( +
+ + handleGroupedTableChange({ current: page, pageSize }) + } + showTotal={paginationShowTotal} + /> +
+ )} +
+ )}
{createPortal(, document.body, 'project-drawer')} From 5fb2633bc577620e3f9428a6004b476f56ad6ae9 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 9 Jul 2025 22:51:20 +0530 Subject: [PATCH 18/49] refactor(task-drawer): update localization keys for created and updated timestamps - Modified localization JSON files for multiple languages to use double curly braces for variable interpolation in the createdBy and updatedTime fields. - Ensured consistency across English, German, Spanish, Portuguese, Albanian, and Chinese translations for better formatting of dynamic content. --- .../locales/alb/task-drawer/task-drawer.json | 4 +- .../locales/de/task-drawer/task-drawer.json | 4 +- .../locales/en/task-drawer/task-drawer.json | 4 +- .../locales/es/task-drawer/task-drawer.json | 4 +- .../locales/pt/task-drawer/task-drawer.json | 4 +- .../locales/zh/task-drawer/task-drawer.json | 4 +- .../shared/info-tab/info-tab-footer.tsx | 44 +++++++++++++++---- .../src/types/tasks/task.types.ts | 2 + 8 files changed, 49 insertions(+), 21 deletions(-) diff --git a/worklenz-frontend/public/locales/alb/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/alb/task-drawer/task-drawer.json index df525752..9d6c022f 100644 --- a/worklenz-frontend/public/locales/alb/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/alb/task-drawer/task-drawer.json @@ -79,8 +79,8 @@ "maxFilesError": "Mund të ngarkoni maksimum {count} skedarë", "processFilesError": "Dështoi përpunimi i skedarëve", "addCommentError": "Ju lutemi shtoni një koment ose bashkëngjitni skedarë", - "createdBy": "Krijuar {time} nga {user}", - "updatedTime": "Përditësuar {time}" + "createdBy": "Krijuar {{time}} nga {{user}}", + "updatedTime": "Përditësuar {{time}}" }, "searchInputPlaceholder": "Kërko sipas emrit", "pendingInvitation": "Ftesë në Pritje" diff --git a/worklenz-frontend/public/locales/de/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/de/task-drawer/task-drawer.json index 5d4b4275..62e3f881 100644 --- a/worklenz-frontend/public/locales/de/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/de/task-drawer/task-drawer.json @@ -79,8 +79,8 @@ "maxFilesError": "Sie können maximal {count} Dateien hochladen", "processFilesError": "Fehler beim Verarbeiten der Dateien", "addCommentError": "Bitte fügen Sie einen Kommentar hinzu oder hängen Sie Dateien an", - "createdBy": "Erstellt {time} von {user}", - "updatedTime": "Aktualisiert {time}" + "createdBy": "Erstellt {{time}} von {{user}}", + "updatedTime": "Aktualisiert {{time}}" }, "searchInputPlaceholder": "Nach Name suchen", "pendingInvitation": "Ausstehende Einladung" diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json index 83955de7..b5147324 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json @@ -79,8 +79,8 @@ "maxFilesError": "You can only upload a maximum of {count} files", "processFilesError": "Failed to process files", "addCommentError": "Please add a comment or attach files", - "createdBy": "Created {time} by {user}", - "updatedTime": "Updated {time}" + "createdBy": "Created {{time}} by {{user}}", + "updatedTime": "Updated {{time}}" }, "searchInputPlaceholder": "Search by name", "pendingInvitation": "Pending Invitation" diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json index 0e92e24f..8e438716 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json @@ -79,8 +79,8 @@ "maxFilesError": "Solo puede subir un máximo de {count} archivos", "processFilesError": "Error al procesar archivos", "addCommentError": "Por favor agregue un comentario o adjunte archivos", - "createdBy": "Creado {time} por {user}", - "updatedTime": "Actualizado {time}" + "createdBy": "Creado {{time}} por {{user}}", + "updatedTime": "Actualizado {{time}}" }, "searchInputPlaceholder": "Buscar por nombre", "pendingInvitation": "Invitación Pendiente" diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json index 1b06e543..c24e943e 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json @@ -79,8 +79,8 @@ "maxFilesError": "Você pode fazer upload de no máximo {count} arquivos", "processFilesError": "Falha ao processar arquivos", "addCommentError": "Por favor adicione um comentário ou anexe arquivos", - "createdBy": "Criado {time} por {user}", - "updatedTime": "Atualizado {time}" + "createdBy": "Criado {{time}} por {{user}}", + "updatedTime": "Atualizado {{time}}" }, "searchInputPlaceholder": "Pesquisar por nome", "pendingInvitation": "Convite Pendente" diff --git a/worklenz-frontend/public/locales/zh/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/zh/task-drawer/task-drawer.json index 66240131..dfe304fe 100644 --- a/worklenz-frontend/public/locales/zh/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/zh/task-drawer/task-drawer.json @@ -79,8 +79,8 @@ "maxFilesError": "您最多只能上传{count}个文件", "processFilesError": "处理文件失败", "addCommentError": "请添加评论或附加文件", - "createdBy": "{time}由{user}创建", - "updatedTime": "更新于{time}" + "createdBy": "{{time}}由{{user}}创建", + "updatedTime": "更新于{{time}}" }, "searchInputPlaceholder": "按名称搜索", "pendingInvitation": "待处理邀请" diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx index 3ab435d8..0bfabd28 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx @@ -1,5 +1,5 @@ import { Button, Flex, Form, Mentions, Space, Tooltip, Typography, message } from 'antd'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PaperClipOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import { useAppSelector } from '@/hooks/useAppSelector'; @@ -15,6 +15,7 @@ import logger from '@/utils/errorLogger'; import taskCommentsApiService from '@/api/tasks/task-comments.api.service'; import { teamMembersApiService } from '@/api/team-members/teamMembers.api.service'; import { ITeamMember } from '@/types/teamMembers/teamMember.types'; +import { fromNow } from '@/utils/dateUtils'; // Utility function to convert file to base64 const getBase64 = (file: File): Promise => { @@ -66,6 +67,31 @@ const InfoTabFooter = () => { // get member list from project members slice const projectMembersList = useAppSelector(state => state.projectMemberReducer.membersList); + // Calculate relative time values + const createdFromNow = useMemo(() => { + const createdAt = taskFormViewModel?.task?.created_at; + if (!createdAt) return 'N/A'; + + try { + return fromNow(createdAt); + } catch (error) { + console.error('Error formatting created_at:', error, createdAt); + return 'N/A'; + } + }, [taskFormViewModel?.task?.created_at]); + + const updatedFromNow = useMemo(() => { + const updatedAt = taskFormViewModel?.task?.updated_at; + if (!updatedAt) return 'N/A'; + + try { + return fromNow(updatedAt); + } catch (error) { + console.error('Error formatting updated_at:', error, updatedAt); + return 'N/A'; + } + }, [taskFormViewModel?.task?.updated_at]); + // function to handle cancel const handleCancel = () => { form.resetFields(['comment']); @@ -454,28 +480,28 @@ const InfoTabFooter = () => { -{t('taskInfoTab.comments.createdBy', { - time: taskFormViewModel?.task?.created_from_now || 'N/A', + {t('taskInfoTab.comments.createdBy', { + time: createdFromNow, user: taskFormViewModel?.task?.reporter || '' })} -{t('taskInfoTab.comments.updatedTime', { - time: taskFormViewModel?.task?.updated_from_now || 'N/A' + {t('taskInfoTab.comments.updatedTime', { + time: updatedFromNow })} diff --git a/worklenz-frontend/src/types/tasks/task.types.ts b/worklenz-frontend/src/types/tasks/task.types.ts index d155490c..204d1418 100644 --- a/worklenz-frontend/src/types/tasks/task.types.ts +++ b/worklenz-frontend/src/types/tasks/task.types.ts @@ -39,6 +39,8 @@ export interface ITask { manual_progress: boolean; progress_value: number | null; weight: number | null; + created_at?: string; + updated_at?: string; } export interface IProjectMemberViewModel extends IProjectMember { From 6448d24e2036a576c2e9e70db37c076464b655f7 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 10 Jul 2025 12:00:56 +0530 Subject: [PATCH 19/49] refactor(task-list): simplify subtask handling in AddSubtaskRow and TaskRowWithSubtasks - Updated AddSubtaskRow to remove rowId dependency from onSubtaskAdded and onActivate callbacks, streamlining the subtask addition process. - Enhanced input handling to maintain focus and visibility after adding a subtask. - Refactored TaskRowWithSubtasks to consolidate add subtask row management, ensuring a single add subtask row is displayed when not loading. --- .../task-list-v2/TaskRowWithSubtasks.tsx | 103 ++++++++---------- .../task-list-v2/components/AddTaskRow.tsx | 2 +- 2 files changed, 45 insertions(+), 60 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx index e38c0b72..10226d03 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx @@ -32,11 +32,11 @@ interface AddSubtaskRowProps { width: string; isSticky?: boolean; }>; - onSubtaskAdded: (rowId: string) => void; + onSubtaskAdded: () => void; // Simplified - no rowId needed rowId: string; // Unique identifier for this add subtask row autoFocus?: boolean; // Whether this row should auto-focus on mount isActive?: boolean; // Whether this row should show the input/button - onActivate?: (rowId: string) => void; // Callback when row becomes active + onActivate?: () => void; // Simplified - no rowId needed } const AddSubtaskRow: React.FC = memo(({ @@ -93,17 +93,31 @@ const AddSubtaskRow: React.FC = memo(({ ); } + // Clear the input but keep it focused for the next subtask setSubtaskName(''); - // Keep the input active and notify parent to create new row - onSubtaskAdded(rowId); - }, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, currentSession, onSubtaskAdded, rowId]); + // Keep isAdding as true so the input stays visible + // Focus the input again after a short delay to ensure it's ready + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, 50); + + // Notify parent that subtask was added + onSubtaskAdded(); + }, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, currentSession, onSubtaskAdded]); const handleCancel = useCallback(() => { + setSubtaskName(''); + setIsAdding(false); + }, []); + + const handleBlur = useCallback(() => { + // Only cancel if the input is empty, otherwise keep it active if (subtaskName.trim() === '') { - setSubtaskName(''); - setIsAdding(false); + handleCancel(); } - }, [subtaskName]); + }, [subtaskName, handleCancel]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Escape') { @@ -135,7 +149,9 @@ const AddSubtaskRow: React.FC = memo(({ !isAdding ? ( - )} - - {/* Additional indentation for subtasks after the expand button space */} - {isSubtask &&
} - -
- {/* Task name with dynamic width */} -
- { - e.stopPropagation(); - e.preventDefault(); - setEditTaskName(true); - }} - title={taskDisplayName} - > - {taskDisplayName} - -
- - {/* Indicators container - flex-shrink-0 to prevent compression */} -
- {/* Subtask count indicator - only show if count > 0 */} - {!isSubtask && task.sub_tasks_count != null && task.sub_tasks_count > 0 && ( - -
- - {task.sub_tasks_count} - - -
-
- )} - - {/* Task indicators - compact layout */} - {task.comments_count != null && task.comments_count !== 0 && ( - - - - )} - - {task.has_subscribers && ( - - - - )} - - {task.attachments_count != null && task.attachments_count !== 0 && ( - - - - )} - - {task.has_dependencies && ( - - - - )} - - {task.schedule_id && ( - - - - )} -
-
-
- - - - )} -
- ); - - case 'description': - return ( -
-
-
- ); - - case 'status': - return ( -
- -
- ); - - case 'assignees': - return ( -
- - -
- ); - - case 'priority': - return ( -
- -
- ); - - case 'dueDate': - return ( -
- {activeDatePicker === 'dueDate' ? ( -
- handleDateChange(date, 'dueDate')} - placeholder={t('dueDatePlaceholder')} - allowClear={false} - suffixIcon={null} - open={true} - onOpenChange={(open) => { - if (!open) { - setActiveDatePicker(null); - } - }} - autoFocus - /> - {/* Custom clear button */} - {dateValues.due && ( - - )} -
- ) : ( -
{ - e.stopPropagation(); - datePickerHandlers.setDueDate(); - }} - > - {formattedDates.due ? ( - - {formattedDates.due} - - ) : ( - - {t('setDueDate')} - - )} -
- )} -
- ); - - case 'progress': - return ( -
- {task.progress !== undefined && - task.progress >= 0 && - (task.progress === 100 ? ( -
- -
- ) : ( - - ))} -
- ); - - case 'labels': - const labelsColumn = visibleColumns.find(col => col.id === 'labels'); - const labelsStyle = { - ...baseStyle, - ...(labelsColumn?.width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {}) - }; - return ( -
- - -
- ); - - case 'phase': - return ( -
- -
- ); - - case 'timeTracking': - return ( -
- -
- ); - - case 'estimation': - // Use timeTracking.estimated which is the converted value from backend's total_minutes - const estimationDisplay = (() => { - const estimatedHours = task.timeTracking?.estimated; - - if (estimatedHours && estimatedHours > 0) { - // Convert decimal hours to hours and minutes for display - const hours = Math.floor(estimatedHours); - const minutes = Math.round((estimatedHours - hours) * 60); - - if (hours > 0 && minutes > 0) { - return `${hours}h ${minutes}m`; - } else if (hours > 0) { - return `${hours}h`; - } else if (minutes > 0) { - return `${minutes}m`; - } - } - - return null; - })(); - - return ( -
- {estimationDisplay ? ( - - {estimationDisplay} - - ) : ( - - - - - )} -
- ); - - case 'startDate': - return ( -
- {activeDatePicker === 'startDate' ? ( -
- handleDateChange(date, 'startDate')} - placeholder={t('startDatePlaceholder')} - allowClear={false} - suffixIcon={null} - open={true} - onOpenChange={(open) => { - if (!open) { - setActiveDatePicker(null); - } - }} - autoFocus - /> - {/* Custom clear button */} - {dateValues.start && ( - - )} -
- ) : ( -
{ - e.stopPropagation(); - datePickerHandlers.setStartDate(); - }} - > - {formattedDates.start ? ( - - {formattedDates.start} - - ) : ( - - {t('setStartDate')} - - )} -
- )} -
- ); - - case 'completedDate': - return ( -
- {formattedDates.completed ? ( - - {formattedDates.completed} - - ) : ( - - - )} -
- ); - - case 'createdDate': - return ( -
- {formattedDates.created ? ( - - {formattedDates.created} - - ) : ( - - - )} -
- ); - - case 'lastUpdated': - return ( -
- {formattedDates.updated ? ( - - {formattedDates.updated} - - ) : ( - - - )} -
- ); - - case 'reporter': - return ( -
- {task.reporter ? ( - {task.reporter} - ) : ( - - - )} -
- ); - - default: - // Handle custom columns - const column = visibleColumns.find(col => col.id === columnId); - if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) { - return ( -
- -
- ); - } - return null; - } - }, [ - // Essential props and state - attributes, - listeners, - isSelected, - handleCheckboxChange, - activeDatePicker, - isDarkMode, - projectId, - - // Edit task name state - CRITICAL for re-rendering - editTaskName, - taskName, - - // Task data - include specific fields that might update via socket - task, - task.labels, // Explicit dependency for labels updates - task.phase, // Explicit dependency for phase updates - task.comments_count, // Explicit dependency for comments count updates - task.has_subscribers, // Explicit dependency for subscribers updates - task.attachments_count, // Explicit dependency for attachments count updates - task.has_dependencies, // Explicit dependency for dependencies updates - task.schedule_id, // Explicit dependency for recurring task updates - taskDisplayName, - convertedTask, - - // Memoized values - dateValues, - formattedDates, - labelsAdapter, - - // Handlers - handleDateChange, - datePickerHandlers, - handleTaskNameSave, - - // Translation - t, - - // Custom columns - visibleColumns, - updateTaskCustomColumnValue, - ]); - return (
void; +} + +export const DatePickerColumn: React.FC = memo(({ + width, + task, + field, + formattedDate, + dateValue, + isDarkMode, + activeDatePicker, + onActiveDatePickerChange +}) => { + const { socket, connected } = useSocket(); + const { t } = useTranslation('task-list-table'); + + // Handle date change + const handleDateChange = useCallback( + (date: dayjs.Dayjs | null) => { + if (!connected || !socket) return; + + const eventType = + field === 'startDate' + ? SocketEvents.TASK_START_DATE_CHANGE + : SocketEvents.TASK_END_DATE_CHANGE; + const dateField = field === 'startDate' ? 'start_date' : 'end_date'; + + socket.emit( + eventType.toString(), + JSON.stringify({ + task_id: task.id, + [dateField]: date?.format('YYYY-MM-DD'), + parent_task: null, + time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }) + ); + + // Close the date picker after selection + onActiveDatePickerChange(null); + }, + [connected, socket, task.id, field, onActiveDatePickerChange] + ); + + // Handle clear date + const handleClearDate = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + handleDateChange(null); + }, [handleDateChange]); + + // Handle open date picker + const handleOpenDatePicker = useCallback(() => { + onActiveDatePickerChange(field); + }, [field, onActiveDatePickerChange]); + + const isActive = activeDatePicker === field; + const placeholder = field === 'dueDate' ? t('dueDatePlaceholder') : t('startDatePlaceholder'); + const clearTitle = field === 'dueDate' ? t('clearDueDate') : t('clearStartDate'); + const setTitle = field === 'dueDate' ? t('setDueDate') : t('setStartDate'); + + return ( +
+ {isActive ? ( +
+ { + if (!open) { + onActiveDatePickerChange(null); + } + }} + autoFocus + /> + {/* Custom clear button */} + {dateValue && ( + + )} +
+ ) : ( +
{ + e.stopPropagation(); + handleOpenDatePicker(); + }} + > + {formattedDate ? ( + + {formattedDate} + + ) : ( + + {setTitle} + + )} +
+ )} +
+ ); +}); + +DatePickerColumn.displayName = 'DatePickerColumn'; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/components/TaskRowColumns.tsx b/worklenz-frontend/src/components/task-list-v2/components/TaskRowColumns.tsx new file mode 100644 index 00000000..b22690ca --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/components/TaskRowColumns.tsx @@ -0,0 +1,404 @@ +import React, { memo, useCallback, useState, useRef } from 'react'; +import { CheckCircleOutlined, HolderOutlined, CloseOutlined, DownOutlined, RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons'; +import { Checkbox, DatePicker, Tooltip, Input } from 'antd'; +import type { InputRef } from 'antd'; +import { dayjs, taskManagementAntdConfig } from '@/shared/antd-imports'; +import { Task } from '@/types/task-management.types'; +import { InlineMember } from '@/types/teamMembers/inlineMember.types'; +import AssigneeSelector from '@/components/AssigneeSelector'; +import { format } from 'date-fns'; +import AvatarGroup from '../../AvatarGroup'; +import { DEFAULT_TASK_NAME } from '@/shared/constants'; +import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress'; +import TaskStatusDropdown from '@/components/task-management/task-status-dropdown'; +import TaskPriorityDropdown from '@/components/task-management/task-priority-dropdown'; +import TaskPhaseDropdown from '@/components/task-management/task-phase-dropdown'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice'; +import { useTranslation } from 'react-i18next'; +import TaskTimeTracking from '../TaskTimeTracking'; +import { CustomNumberLabel, CustomColordLabel } from '@/components'; +import LabelsSelector from '@/components/LabelsSelector'; +import { CustomColumnCell } from './CustomColumnComponents'; + +// Utility function to get task display name with fallbacks +export const getTaskDisplayName = (task: Task): string => { + if (task.title && task.title.trim()) return task.title.trim(); + if (task.name && task.name.trim()) return task.name.trim(); + if (task.task_key && task.task_key.trim()) return task.task_key.trim(); + return DEFAULT_TASK_NAME; +}; + +// Memoized date formatter to avoid repeated date parsing +export const formatDate = (dateString: string): string => { + try { + return format(new Date(dateString), 'MMM d, yyyy'); + } catch { + return ''; + } +}; + +interface TaskLabelsCellProps { + labels: Task['labels']; + isDarkMode: boolean; +} + +export const TaskLabelsCell: React.FC = memo(({ labels, isDarkMode }) => { + if (!labels) { + return null; + } + + return ( +
+ {labels.map((label, index) => { + const extendedLabel = label as any; + return extendedLabel.end && extendedLabel.names && extendedLabel.name ? ( + + ) : ( + + ); + })} +
+ ); +}); + +TaskLabelsCell.displayName = 'TaskLabelsCell'; + +interface DragHandleColumnProps { + width: string; + isSubtask: boolean; + attributes: any; + listeners: any; +} + +export const DragHandleColumn: React.FC = memo(({ width, isSubtask, attributes, listeners }) => ( +
+ {!isSubtask && } +
+)); + +DragHandleColumn.displayName = 'DragHandleColumn'; + +interface CheckboxColumnProps { + width: string; + isSelected: boolean; + onCheckboxChange: (e: any) => void; +} + +export const CheckboxColumn: React.FC = memo(({ width, isSelected, onCheckboxChange }) => ( +
+ e.stopPropagation()} + /> +
+)); + +CheckboxColumn.displayName = 'CheckboxColumn'; + +interface TaskKeyColumnProps { + width: string; + taskKey: string; +} + +export const TaskKeyColumn: React.FC = memo(({ width, taskKey }) => ( +
+ + {taskKey || 'N/A'} + +
+)); + +TaskKeyColumn.displayName = 'TaskKeyColumn'; + +interface DescriptionColumnProps { + width: string; + description: string; +} + +export const DescriptionColumn: React.FC = memo(({ width, description }) => ( +
+
+
+)); + +DescriptionColumn.displayName = 'DescriptionColumn'; + +interface StatusColumnProps { + width: string; + task: Task; + projectId: string; + isDarkMode: boolean; +} + +export const StatusColumn: React.FC = memo(({ width, task, projectId, isDarkMode }) => ( +
+ +
+)); + +StatusColumn.displayName = 'StatusColumn'; + +interface AssigneesColumnProps { + width: string; + task: Task; + convertedTask: any; + isDarkMode: boolean; +} + +export const AssigneesColumn: React.FC = memo(({ width, task, convertedTask, isDarkMode }) => ( +
+ + +
+)); + +AssigneesColumn.displayName = 'AssigneesColumn'; + +interface PriorityColumnProps { + width: string; + task: Task; + projectId: string; + isDarkMode: boolean; +} + +export const PriorityColumn: React.FC = memo(({ width, task, projectId, isDarkMode }) => ( +
+ +
+)); + +PriorityColumn.displayName = 'PriorityColumn'; + +interface ProgressColumnProps { + width: string; + task: Task; +} + +export const ProgressColumn: React.FC = memo(({ width, task }) => ( +
+ {task.progress !== undefined && + task.progress >= 0 && + (task.progress === 100 ? ( +
+ +
+ ) : ( + + ))} +
+)); + +ProgressColumn.displayName = 'ProgressColumn'; + +interface LabelsColumnProps { + width: string; + task: Task; + labelsAdapter: any; + isDarkMode: boolean; + visibleColumns: any[]; +} + +export const LabelsColumn: React.FC = memo(({ width, task, labelsAdapter, isDarkMode, visibleColumns }) => { + const labelsColumn = visibleColumns.find(col => col.id === 'labels'); + const labelsStyle = { + width, + ...(labelsColumn?.width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {}) + }; + + return ( +
+ + +
+ ); +}); + +LabelsColumn.displayName = 'LabelsColumn'; + +interface PhaseColumnProps { + width: string; + task: Task; + projectId: string; + isDarkMode: boolean; +} + +export const PhaseColumn: React.FC = memo(({ width, task, projectId, isDarkMode }) => ( +
+ +
+)); + +PhaseColumn.displayName = 'PhaseColumn'; + +interface TimeTrackingColumnProps { + width: string; + taskId: string; + isDarkMode: boolean; +} + +export const TimeTrackingColumn: React.FC = memo(({ width, taskId, isDarkMode }) => ( +
+ +
+)); + +TimeTrackingColumn.displayName = 'TimeTrackingColumn'; + +interface EstimationColumnProps { + width: string; + task: Task; +} + +export const EstimationColumn: React.FC = memo(({ width, task }) => { + const estimationDisplay = (() => { + const estimatedHours = task.timeTracking?.estimated; + + if (estimatedHours && estimatedHours > 0) { + const hours = Math.floor(estimatedHours); + const minutes = Math.round((estimatedHours - hours) * 60); + + if (hours > 0 && minutes > 0) { + return `${hours}h ${minutes}m`; + } else if (hours > 0) { + return `${hours}h`; + } else if (minutes > 0) { + return `${minutes}m`; + } + } + + return null; + })(); + + return ( +
+ {estimationDisplay ? ( + + {estimationDisplay} + + ) : ( + + - + + )} +
+ ); +}); + +EstimationColumn.displayName = 'EstimationColumn'; + +interface DateColumnProps { + width: string; + formattedDate: string | null; + placeholder?: string; +} + +export const DateColumn: React.FC = memo(({ width, formattedDate, placeholder = '-' }) => ( +
+ {formattedDate ? ( + + {formattedDate} + + ) : ( + {placeholder} + )} +
+)); + +DateColumn.displayName = 'DateColumn'; + +interface ReporterColumnProps { + width: string; + reporter: string; +} + +export const ReporterColumn: React.FC = memo(({ width, reporter }) => ( +
+ {reporter ? ( + {reporter} + ) : ( + - + )} +
+)); + +ReporterColumn.displayName = 'ReporterColumn'; + +interface CustomColumnProps { + width: string; + column: any; + task: Task; + updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; +} + +export const CustomColumn: React.FC = memo(({ width, column, task, updateTaskCustomColumnValue }) => { + if (!updateTaskCustomColumnValue) return null; + + return ( +
+ +
+ ); +}); + +CustomColumn.displayName = 'CustomColumn'; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx b/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx new file mode 100644 index 00000000..a005a1c4 --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx @@ -0,0 +1,258 @@ +import React, { memo, useCallback, useState, useRef, useEffect } from 'react'; +import { RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons'; +import { Input, Tooltip } from 'antd'; +import type { InputRef } from 'antd'; +import { Task } from '@/types/task-management.types'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { toggleTaskExpansion, fetchSubTasks } from '@/features/task-management/task-management.slice'; +import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { useTranslation } from 'react-i18next'; +import { getTaskDisplayName } from './TaskRowColumns'; + +interface TitleColumnProps { + width: string; + task: Task; + projectId: string; + isSubtask: boolean; + taskDisplayName: string; + editTaskName: boolean; + taskName: string; + onEditTaskName: (editing: boolean) => void; + onTaskNameChange: (name: string) => void; + onTaskNameSave: () => void; +} + +export const TitleColumn: React.FC = memo(({ + width, + task, + projectId, + isSubtask, + taskDisplayName, + editTaskName, + taskName, + onEditTaskName, + onTaskNameChange, + onTaskNameSave +}) => { + const dispatch = useAppDispatch(); + const { socket, connected } = useSocket(); + const { t } = useTranslation('task-list-table'); + const inputRef = useRef(null); + const wrapperRef = useRef(null); + + // Handle task expansion toggle + const handleToggleExpansion = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + + // Always try to fetch subtasks when expanding, regardless of count + if (!task.show_sub_tasks && (!task.sub_tasks || task.sub_tasks.length === 0)) { + dispatch(fetchSubTasks({ taskId: task.id, projectId })); + } + + // Toggle expansion state + dispatch(toggleTaskExpansion(task.id)); + }, [dispatch, task.id, task.sub_tasks, task.show_sub_tasks, projectId]); + + // Handle task name save + const handleTaskNameSave = useCallback(() => { + const newTaskName = inputRef.current?.input?.value || taskName; + if (newTaskName?.trim() !== '' && connected && newTaskName.trim() !== (task.title || task.name || '').trim()) { + socket?.emit( + SocketEvents.TASK_NAME_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + name: newTaskName.trim(), + parent_task: task.parent_task_id, + }) + ); + } + onEditTaskName(false); + }, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, onEditTaskName]); + + // Handle click outside for task name editing + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) { + handleTaskNameSave(); + } + }; + + if (editTaskName) { + document.addEventListener('mousedown', handleClickOutside); + inputRef.current?.focus(); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [editTaskName, handleTaskNameSave]); + + return ( +
+ {editTaskName ? ( + /* Full cell input when editing */ +
+ onTaskNameChange(e.target.value)} + autoFocus + onPressEnter={handleTaskNameSave} + onBlur={handleTaskNameSave} + className="text-sm" + style={{ + width: '100%', + height: '38px', + margin: '0', + padding: '8px 12px', + border: '1px solid #1677ff', + backgroundColor: 'rgba(22, 119, 255, 0.02)', + borderRadius: '3px', + fontSize: '14px', + lineHeight: '22px', + boxSizing: 'border-box', + outline: 'none', + boxShadow: '0 0 0 2px rgba(22, 119, 255, 0.1)', + }} + /> +
+ ) : ( + /* Normal layout when not editing */ + <> +
+ {/* Indentation for subtasks - tighter spacing */} + {isSubtask &&
} + + {/* Expand/Collapse button - only show for parent tasks */} + {!isSubtask && ( + + )} + + {/* Additional indentation for subtasks after the expand button space */} + {isSubtask &&
} + +
+ {/* Task name with dynamic width */} +
+ { + e.stopPropagation(); + e.preventDefault(); + onEditTaskName(true); + }} + title={taskDisplayName} + > + {taskDisplayName} + +
+ + {/* Indicators container - flex-shrink-0 to prevent compression */} +
+ {/* Subtask count indicator - only show if count > 0 */} + {!isSubtask && task.sub_tasks_count != null && task.sub_tasks_count > 0 && ( + +
+ + {task.sub_tasks_count} + + +
+
+ )} + + {/* Task indicators - compact layout */} + {task.comments_count != null && task.comments_count !== 0 && ( + + + + )} + + {task.has_subscribers && ( + + + + )} + + {task.attachments_count != null && task.attachments_count !== 0 && ( + + + + )} + + {task.has_dependencies && ( + + + + )} + + {task.schedule_id && ( + + + + )} +
+
+
+ + + + )} +
+ ); +}); + +TitleColumn.displayName = 'TitleColumn'; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowActions.ts b/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowActions.ts new file mode 100644 index 00000000..a5a816b6 --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowActions.ts @@ -0,0 +1,63 @@ +import { useCallback } from 'react'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { toggleTaskSelection } from '@/features/task-management/selection.slice'; +import { Task } from '@/types/task-management.types'; + +interface UseTaskRowActionsProps { + task: Task; + taskId: string; + taskName: string; + editTaskName: boolean; + setEditTaskName: (editing: boolean) => void; +} + +export const useTaskRowActions = ({ + task, + taskId, + taskName, + editTaskName, + setEditTaskName, +}: UseTaskRowActionsProps) => { + const dispatch = useAppDispatch(); + const { socket, connected } = useSocket(); + + // Handle checkbox change + const handleCheckboxChange = useCallback((e: any) => { + e.stopPropagation(); // Prevent row click when clicking checkbox + dispatch(toggleTaskSelection(taskId)); + }, [dispatch, taskId]); + + // Handle task name save + const handleTaskNameSave = useCallback(() => { + if (taskName?.trim() !== '' && connected && taskName.trim() !== (task.title || task.name || '').trim()) { + socket?.emit( + SocketEvents.TASK_NAME_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + name: taskName.trim(), + parent_task: task.parent_task_id, + }) + ); + } + setEditTaskName(false); + }, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, setEditTaskName]); + + // Handle task name edit start + const handleTaskNameEdit = useCallback(() => { + setEditTaskName(true); + }, [setEditTaskName]); + + // Handle task name change + const handleTaskNameChange = useCallback((name: string) => { + // This will be handled by the parent component's state setter + }, []); + + return { + handleCheckboxChange, + handleTaskNameSave, + handleTaskNameEdit, + handleTaskNameChange, + }; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowColumns.tsx b/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowColumns.tsx new file mode 100644 index 00000000..344f4080 --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowColumns.tsx @@ -0,0 +1,320 @@ +import React, { useCallback } from 'react'; +import { Task } from '@/types/task-management.types'; +import { + DragHandleColumn, + CheckboxColumn, + TaskKeyColumn, + DescriptionColumn, + StatusColumn, + AssigneesColumn, + PriorityColumn, + ProgressColumn, + LabelsColumn, + PhaseColumn, + TimeTrackingColumn, + EstimationColumn, + DateColumn, + ReporterColumn, + CustomColumn, +} from '../components/TaskRowColumns'; +import { TitleColumn } from '../components/TitleColumn'; +import { DatePickerColumn } from '../components/DatePickerColumn'; + +interface UseTaskRowColumnsProps { + task: Task; + projectId: string; + isSubtask: boolean; + isSelected: boolean; + isDarkMode: boolean; + visibleColumns: Array<{ + id: string; + width: string; + isSticky?: boolean; + key?: string; + custom_column?: boolean; + custom_column_obj?: any; + isCustom?: boolean; + }>; + updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; + + // From useTaskRowState + taskDisplayName: string; + convertedTask: any; + formattedDates: any; + dateValues: any; + labelsAdapter: any; + activeDatePicker: string | null; + setActiveDatePicker: (field: string | null) => void; + editTaskName: boolean; + taskName: string; + setEditTaskName: (editing: boolean) => void; + setTaskName: (name: string) => void; + + // From useTaskRowActions + handleCheckboxChange: (e: any) => void; + handleTaskNameSave: () => void; + handleTaskNameEdit: () => void; + + // Drag and drop + attributes: any; + listeners: any; +} + +export const useTaskRowColumns = ({ + task, + projectId, + isSubtask, + isSelected, + isDarkMode, + visibleColumns, + updateTaskCustomColumnValue, + taskDisplayName, + convertedTask, + formattedDates, + dateValues, + labelsAdapter, + activeDatePicker, + setActiveDatePicker, + editTaskName, + taskName, + setEditTaskName, + setTaskName, + handleCheckboxChange, + handleTaskNameSave, + handleTaskNameEdit, + attributes, + listeners, +}: UseTaskRowColumnsProps) => { + + const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => { + switch (columnId) { + case 'dragHandle': + return ( + + ); + + case 'checkbox': + return ( + + ); + + case 'taskKey': + return ( + + ); + + case 'title': + return ( + + ); + + case 'description': + return ( + + ); + + case 'status': + return ( + + ); + + case 'assignees': + return ( + + ); + + case 'priority': + return ( + + ); + + case 'dueDate': + return ( + + ); + + case 'startDate': + return ( + + ); + + case 'progress': + return ( + + ); + + case 'labels': + return ( + + ); + + case 'phase': + return ( + + ); + + case 'timeTracking': + return ( + + ); + + case 'estimation': + return ( + + ); + + case 'completedDate': + return ( + + ); + + case 'createdDate': + return ( + + ); + + case 'lastUpdated': + return ( + + ); + + case 'reporter': + return ( + + ); + + default: + // Handle custom columns + const column = visibleColumns.find(col => col.id === columnId); + if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) { + return ( + + ); + } + return null; + } + }, [ + task, + projectId, + isSubtask, + isSelected, + isDarkMode, + visibleColumns, + updateTaskCustomColumnValue, + taskDisplayName, + convertedTask, + formattedDates, + dateValues, + labelsAdapter, + activeDatePicker, + setActiveDatePicker, + editTaskName, + taskName, + setEditTaskName, + setTaskName, + handleCheckboxChange, + handleTaskNameSave, + handleTaskNameEdit, + attributes, + listeners, + ]); + + return { renderColumn }; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowState.ts b/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowState.ts new file mode 100644 index 00000000..2919caa3 --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowState.ts @@ -0,0 +1,96 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Task } from '@/types/task-management.types'; +import { InlineMember } from '@/types/teamMembers/inlineMember.types'; +import { dayjs } from '@/shared/antd-imports'; +import { getTaskDisplayName, formatDate } from '../components/TaskRowColumns'; + +export const useTaskRowState = (task: Task) => { + // State for tracking which date picker is open + const [activeDatePicker, setActiveDatePicker] = useState(null); + + // State for editing task name + const [editTaskName, setEditTaskName] = useState(false); + const [taskName, setTaskName] = useState(task.title || task.name || ''); + + // Update local taskName state when task name changes + useEffect(() => { + setTaskName(task.title || task.name || ''); + }, [task.title, task.name]); + + // Memoize task display name + const taskDisplayName = useMemo(() => getTaskDisplayName(task), [task.title, task.name, task.task_key]); + + // Memoize converted task for AssigneeSelector to prevent recreation + const convertedTask = useMemo(() => ({ + id: task.id, + name: taskDisplayName, + task_key: task.task_key || taskDisplayName, + assignees: + task.assignee_names?.map((assignee: InlineMember, index: number) => ({ + team_member_id: assignee.team_member_id || `assignee-${index}`, + id: assignee.team_member_id || `assignee-${index}`, + project_member_id: assignee.team_member_id || `assignee-${index}`, + name: assignee.name || '', + })) || [], + parent_task_id: task.parent_task_id, + status_id: undefined, + project_id: undefined, + manual_progress: undefined, + }), [task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]); + + // Memoize formatted dates + const formattedDates = useMemo(() => ({ + due: (() => { + const dateValue = task.dueDate || task.due_date; + return dateValue ? formatDate(dateValue) : null; + })(), + start: task.startDate ? formatDate(task.startDate) : null, + completed: task.completedAt ? formatDate(task.completedAt) : null, + created: (task.createdAt || task.created_at) ? formatDate(task.createdAt || task.created_at) : null, + updated: task.updatedAt ? formatDate(task.updatedAt) : null, + }), [task.dueDate, task.due_date, task.startDate, task.completedAt, task.createdAt, task.created_at, task.updatedAt]); + + // Memoize date values for DatePicker + const dateValues = useMemo( + () => ({ + start: task.startDate ? dayjs(task.startDate) : undefined, + due: (task.dueDate || task.due_date) ? dayjs(task.dueDate || task.due_date) : undefined, + }), + [task.startDate, task.dueDate, task.due_date] + ); + + // Create labels adapter for LabelsSelector + const labelsAdapter = useMemo(() => ({ + id: task.id, + name: task.title || task.name, + parent_task_id: task.parent_task_id, + manual_progress: false, + all_labels: task.all_labels?.map(label => ({ + id: label.id, + name: label.name, + color_code: label.color_code, + })) || [], + labels: task.labels?.map(label => ({ + id: label.id, + name: label.name, + color_code: label.color, + })) || [], + }), [task.id, task.title, task.name, task.parent_task_id, task.all_labels, task.labels, task.all_labels?.length, task.labels?.length]); + + return { + // State + activeDatePicker, + setActiveDatePicker, + editTaskName, + setEditTaskName, + taskName, + setTaskName, + + // Computed values + taskDisplayName, + convertedTask, + formattedDates, + dateValues, + labelsAdapter, + }; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/types/TaskRowTypes.ts b/worklenz-frontend/src/components/task-list-v2/types/TaskRowTypes.ts new file mode 100644 index 00000000..b9149229 --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/types/TaskRowTypes.ts @@ -0,0 +1,245 @@ +import { Task } from '@/types/task-management.types'; +import { dayjs } from '@/shared/antd-imports'; + +export interface TaskRowColumn { + id: string; + width: string; + isSticky?: boolean; + key?: string; + custom_column?: boolean; + custom_column_obj?: any; + isCustom?: boolean; +} + +export interface TaskRowProps { + taskId: string; + projectId: string; + visibleColumns: TaskRowColumn[]; + isSubtask?: boolean; + isFirstInGroup?: boolean; + updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; +} + +export interface TaskRowState { + activeDatePicker: string | null; + editTaskName: boolean; + taskName: string; +} + +export interface TaskRowComputedValues { + taskDisplayName: string; + convertedTask: ConvertedTask; + formattedDates: FormattedDates; + dateValues: DateValues; + labelsAdapter: LabelsAdapter; +} + +export interface ConvertedTask { + id: string; + name: string; + task_key: string; + assignees: ConvertedAssignee[]; + parent_task_id?: string; + status_id?: string; + project_id?: string; + manual_progress?: boolean; +} + +export interface ConvertedAssignee { + team_member_id: string; + id: string; + project_member_id: string; + name: string; +} + +export interface FormattedDates { + due: string | null; + start: string | null; + completed: string | null; + created: string | null; + updated: string | null; +} + +export interface DateValues { + start: dayjs.Dayjs | undefined; + due: dayjs.Dayjs | undefined; +} + +export interface LabelsAdapter { + id: string; + name: string; + parent_task_id?: string; + manual_progress: boolean; + all_labels: LabelInfo[]; + labels: LabelInfo[]; +} + +export interface LabelInfo { + id: string; + name: string; + color_code: string; +} + +export interface TaskRowActions { + handleCheckboxChange: (e: any) => void; + handleTaskNameSave: () => void; + handleTaskNameEdit: () => void; + handleTaskNameChange: (name: string) => void; +} + +export interface ColumnRendererProps { + task: Task; + projectId: string; + isSubtask: boolean; + isSelected: boolean; + isDarkMode: boolean; + visibleColumns: TaskRowColumn[]; + updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; + taskDisplayName: string; + convertedTask: ConvertedTask; + formattedDates: FormattedDates; + dateValues: DateValues; + labelsAdapter: LabelsAdapter; + activeDatePicker: string | null; + setActiveDatePicker: (field: string | null) => void; + editTaskName: boolean; + taskName: string; + setEditTaskName: (editing: boolean) => void; + setTaskName: (name: string) => void; + handleCheckboxChange: (e: any) => void; + handleTaskNameSave: () => void; + handleTaskNameEdit: () => void; + attributes: any; + listeners: any; +} + +export type ColumnId = + | 'dragHandle' + | 'checkbox' + | 'taskKey' + | 'title' + | 'description' + | 'status' + | 'assignees' + | 'priority' + | 'dueDate' + | 'startDate' + | 'progress' + | 'labels' + | 'phase' + | 'timeTracking' + | 'estimation' + | 'completedDate' + | 'createdDate' + | 'lastUpdated' + | 'reporter' + | string; // Allow custom column IDs + +export interface BaseColumnProps { + width: string; +} + +export interface DragHandleColumnProps extends BaseColumnProps { + isSubtask: boolean; + attributes: any; + listeners: any; +} + +export interface CheckboxColumnProps extends BaseColumnProps { + isSelected: boolean; + onCheckboxChange: (e: any) => void; +} + +export interface TaskKeyColumnProps extends BaseColumnProps { + taskKey: string; +} + +export interface TitleColumnProps extends BaseColumnProps { + task: Task; + projectId: string; + isSubtask: boolean; + taskDisplayName: string; + editTaskName: boolean; + taskName: string; + onEditTaskName: (editing: boolean) => void; + onTaskNameChange: (name: string) => void; + onTaskNameSave: () => void; +} + +export interface DescriptionColumnProps extends BaseColumnProps { + description: string; +} + +export interface StatusColumnProps extends BaseColumnProps { + task: Task; + projectId: string; + isDarkMode: boolean; +} + +export interface AssigneesColumnProps extends BaseColumnProps { + task: Task; + convertedTask: ConvertedTask; + isDarkMode: boolean; +} + +export interface PriorityColumnProps extends BaseColumnProps { + task: Task; + projectId: string; + isDarkMode: boolean; +} + +export interface DatePickerColumnProps extends BaseColumnProps { + task: Task; + field: 'dueDate' | 'startDate'; + formattedDate: string | null; + dateValue: dayjs.Dayjs | undefined; + isDarkMode: boolean; + activeDatePicker: string | null; + onActiveDatePickerChange: (field: string | null) => void; +} + +export interface ProgressColumnProps extends BaseColumnProps { + task: Task; +} + +export interface LabelsColumnProps extends BaseColumnProps { + task: Task; + labelsAdapter: LabelsAdapter; + isDarkMode: boolean; + visibleColumns: TaskRowColumn[]; +} + +export interface PhaseColumnProps extends BaseColumnProps { + task: Task; + projectId: string; + isDarkMode: boolean; +} + +export interface TimeTrackingColumnProps extends BaseColumnProps { + taskId: string; + isDarkMode: boolean; +} + +export interface EstimationColumnProps extends BaseColumnProps { + task: Task; +} + +export interface DateColumnProps extends BaseColumnProps { + formattedDate: string | null; + placeholder?: string; +} + +export interface ReporterColumnProps extends BaseColumnProps { + reporter: string; +} + +export interface CustomColumnProps extends BaseColumnProps { + column: TaskRowColumn; + task: Task; + updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; +} + +export interface TaskLabelsCellProps { + labels: Task['labels']; + isDarkMode: boolean; +} \ No newline at end of file From 6ebdd788552e3b5a2c000fe49d407f8c8a138090 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 10 Jul 2025 12:27:15 +0530 Subject: [PATCH 21/49] feat(task-timer): add timer start and stop handlers for task management - Implemented handleTimerStart and handleTimerStop functions to manage task timer state via socket events. - Updated useTaskSocketHandlers to register new socket event listeners for timer actions. - Enhanced useTaskTimer to retrieve active timer state from both the old and new task management slices. - Added activeTimer property to Task type for tracking the start timestamp of active timers. --- .../src/hooks/useTaskSocketHandlers.ts | 54 +++++++++++++++++++ worklenz-frontend/src/hooks/useTaskTimer.ts | 6 ++- .../src/types/task-management.types.ts | 1 + 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index c00fa14d..7cf88cfc 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -930,6 +930,56 @@ export const useTaskSocketHandlers = () => { // console.log('🔄 Task assignees change (limited data):', data); }, []); + // Handler for timer start events + const handleTimerStart = useCallback((data: string) => { + try { + const { task_id, start_time } = typeof data === 'string' ? JSON.parse(data) : data; + if (!task_id) return; + + // Update the task-management slice to include timer state + const currentTask = store.getState().taskManagement.entities[task_id]; + if (currentTask) { + const updatedTask: Task = { + ...currentTask, + timeTracking: { + ...currentTask.timeTracking, + activeTimer: start_time ? (typeof start_time === 'number' ? start_time : parseInt(start_time)) : Date.now(), + }, + updatedAt: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + dispatch(updateTask(updatedTask)); + } + } catch (error) { + logger.error('Error handling timer start event:', error); + } + }, [dispatch]); + + // Handler for timer stop events + const handleTimerStop = useCallback((data: string) => { + try { + const { task_id } = typeof data === 'string' ? JSON.parse(data) : data; + if (!task_id) return; + + // Update the task-management slice to remove timer state + const currentTask = store.getState().taskManagement.entities[task_id]; + if (currentTask) { + const updatedTask: Task = { + ...currentTask, + timeTracking: { + ...currentTask.timeTracking, + activeTimer: undefined, + }, + updatedAt: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + dispatch(updateTask(updatedTask)); + } + } catch (error) { + logger.error('Error handling timer stop event:', error); + } + }, [dispatch]); + // Register socket event listeners useEffect(() => { if (!socket) return; @@ -961,6 +1011,8 @@ export const useTaskSocketHandlers = () => { { event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived }, { event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated }, { event: SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), handler: handleCustomColumnUpdate }, + { event: SocketEvents.TASK_TIMER_START.toString(), handler: handleTimerStart }, + { event: SocketEvents.TASK_TIMER_STOP.toString(), handler: handleTimerStop }, ]; @@ -993,6 +1045,8 @@ export const useTaskSocketHandlers = () => { handleNewTaskReceived, handleTaskProgressUpdated, handleCustomColumnUpdate, + handleTimerStart, + handleTimerStop, ]); }; diff --git a/worklenz-frontend/src/hooks/useTaskTimer.ts b/worklenz-frontend/src/hooks/useTaskTimer.ts index cdb89ba1..0e7f7885 100644 --- a/worklenz-frontend/src/hooks/useTaskTimer.ts +++ b/worklenz-frontend/src/hooks/useTaskTimer.ts @@ -6,6 +6,7 @@ import logger from '@/utils/errorLogger'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice'; import { useAppSelector } from '@/hooks/useAppSelector'; +import { selectTaskById } from '@/features/task-management/task-management.slice'; export const useTaskTimer = (taskId: string, initialStartTime: number | null) => { const dispatch = useAppDispatch(); @@ -15,7 +16,10 @@ export const useTaskTimer = (taskId: string, initialStartTime: number | null) => const hasInitialized = useRef(false); // Track if we've initialized const activeTimers = useAppSelector(state => state.taskReducer.activeTimers); - const reduxStartTime = activeTimers[taskId]; + const task = useAppSelector(state => selectTaskById(state, taskId)); + + // Check both the old slice (activeTimers) and new slice (task.timeTracking.activeTimer) + const reduxStartTime = activeTimers[taskId] || task?.timeTracking?.activeTimer; const started = Boolean(reduxStartTime); const [timeString, setTimeString] = useState(DEFAULT_TIME_LEFT); diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index cb0e749d..87847125 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -45,6 +45,7 @@ export interface Task { timeTracking?: { // Time tracking information logged?: number; estimated?: number; + activeTimer?: number; // Active timer start timestamp }; custom_column_values?: Record; // Custom column values isTemporary?: boolean; // Temporary task indicator From bb8e6ee60f69372cd7d5e8436e1e8dd99c83c570 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 10 Jul 2025 12:28:52 +0530 Subject: [PATCH 22/49] refactor(email): enhance email validation and improve bounced email handling - Added isValidateEmail utility function to validate email addresses before sending. - Updated email filtering logic to remove empty, null, undefined, and invalid emails from the recipient list. - Reversed the iteration order in removeMails function to prevent index issues while splicing bounced emails. - Ensured that valid emails are present after filtering before proceeding with the email sending process. --- worklenz-backend/src/shared/email.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/worklenz-backend/src/shared/email.ts b/worklenz-backend/src/shared/email.ts index 83e93651..e0a0f679 100644 --- a/worklenz-backend/src/shared/email.ts +++ b/worklenz-backend/src/shared/email.ts @@ -1,7 +1,7 @@ import {SendEmailCommand, SESClient} from "@aws-sdk/client-ses"; import {Validator} from "jsonschema"; import {QueryResult} from "pg"; -import {log_error} from "./utils"; +import {log_error, isValidateEmail} from "./utils"; import emailRequestSchema from "../json_schemas/email-request-schema"; import db from "../config/db"; @@ -33,7 +33,7 @@ function isValidMailBody(body: IEmail) { async function removeMails(query: string, emails: string[]) { const result: QueryResult<{ email: string; }> = await db.query(query, []); const bouncedEmails = result.rows.map(e => e.email); - for (let i = 0; i < emails.length; i++) { + for (let i = emails.length - 1; i >= 0; i--) { const email = emails[i]; if (bouncedEmails.includes(email)) { emails.splice(i, 1); @@ -54,11 +54,20 @@ export async function sendEmail(email: IEmail): Promise { const options = {...email} as IEmail; options.to = Array.isArray(options.to) ? Array.from(new Set(options.to)) : []; + // Filter out empty, null, undefined, and invalid emails + options.to = options.to + .filter(email => email && typeof email === 'string' && email.trim().length > 0) + .map(email => email.trim()) + .filter(email => isValidateEmail(email)); + if (options.to.length) { await filterBouncedEmails(options.to); await filterSpamEmails(options.to); } + // Double-check that we still have valid emails after filtering + if (!options.to.length) return null; + if (!isValidMailBody(options)) return null; const charset = "UTF-8"; From bcfa18b1e80ba3301eb9f2ab1db6b88c07373d9e Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 10 Jul 2025 14:07:03 +0530 Subject: [PATCH 23/49] feat(pwa): implement service worker and PWA enhancements - Added service worker (sw.js) for offline functionality, caching strategies, and performance improvements. - Registered service worker in App component to manage updates and offline readiness. - Introduced ServiceWorkerStatus component to display connection status and provide cache management controls. - Created manifest.json for PWA configuration, including app name, icons, and display settings. - Updated index.html with PWA meta tags and links to support mobile web app capabilities. - Refactored authentication guards to utilize useAuthStatus hook for improved user state management. - Removed deprecated unregister-sw.js file to streamline service worker management. --- worklenz-frontend/index.html | 21 ++ worklenz-frontend/public/manifest.json | 78 ++++ worklenz-frontend/public/sw.js | 345 ++++++++++++++++++ worklenz-frontend/public/unregister-sw.js | 23 -- worklenz-frontend/src/App.tsx | 22 ++ worklenz-frontend/src/app/routes/index.tsx | 154 +------- worklenz-frontend/src/app/routes/utils.ts | 17 + .../ServiceWorkerStatus.tsx | 140 +++++++ worklenz-frontend/src/hooks/useAuthStatus.ts | 52 +++ worklenz-frontend/src/layouts/MainLayout.tsx | 19 +- .../src/layouts/ReportingLayout.tsx | 6 +- .../src/layouts/SettingsLayout.tsx | 6 +- .../src/layouts/admin-center-layout.tsx | 7 +- .../src/utils/README-ServiceWorker.md | 259 +++++++++++++ .../src/utils/serviceWorkerRegistration.ts | 273 ++++++++++++++ worklenz-frontend/vite.config.ts | 3 + 16 files changed, 1238 insertions(+), 187 deletions(-) create mode 100644 worklenz-frontend/public/manifest.json create mode 100644 worklenz-frontend/public/sw.js delete mode 100644 worklenz-frontend/public/unregister-sw.js create mode 100644 worklenz-frontend/src/app/routes/utils.ts create mode 100644 worklenz-frontend/src/components/service-worker-status/ServiceWorkerStatus.tsx create mode 100644 worklenz-frontend/src/hooks/useAuthStatus.ts create mode 100644 worklenz-frontend/src/utils/README-ServiceWorker.md create mode 100644 worklenz-frontend/src/utils/serviceWorkerRegistration.ts diff --git a/worklenz-frontend/index.html b/worklenz-frontend/index.html index faeccff7..5ac671f0 100644 --- a/worklenz-frontend/index.html +++ b/worklenz-frontend/index.html @@ -6,6 +6,27 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/worklenz-frontend/public/manifest.json b/worklenz-frontend/public/manifest.json new file mode 100644 index 00000000..08214a45 --- /dev/null +++ b/worklenz-frontend/public/manifest.json @@ -0,0 +1,78 @@ +{ + "name": "Worklenz - Project Management", + "short_name": "Worklenz", + "description": "A comprehensive project management application for teams", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#2b2b2b", + "orientation": "portrait-primary", + "categories": ["productivity", "business"], + "lang": "en", + "scope": "/", + "icons": [ + { + "src": "/favicon.ico", + "sizes": "16x16 32x32 48x48", + "type": "image/x-icon", + "purpose": "any maskable" + }, + { + "src": "/assets/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/assets/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "Dashboard", + "short_name": "Dashboard", + "description": "View your project dashboard", + "url": "/worklenz", + "icons": [ + { + "src": "/favicon.ico", + "sizes": "16x16 32x32 48x48" + } + ] + }, + { + "name": "Tasks", + "short_name": "Tasks", + "description": "Manage your tasks", + "url": "/worklenz/tasks", + "icons": [ + { + "src": "/favicon.ico", + "sizes": "16x16 32x32 48x48" + } + ] + } + ], + "screenshots": [ + { + "src": "/assets/images/screenshot-desktop.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide" + }, + { + "src": "/assets/images/screenshot-mobile.png", + "sizes": "480x854", + "type": "image/png", + "form_factor": "narrow" + } + ], + "prefer_related_applications": false, + "related_applications": [], + "launch_handler": { + "client_mode": "focus-existing" + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/sw.js b/worklenz-frontend/public/sw.js new file mode 100644 index 00000000..526541b0 --- /dev/null +++ b/worklenz-frontend/public/sw.js @@ -0,0 +1,345 @@ +// Worklenz Service Worker +// Provides offline functionality, caching, and performance improvements + +const CACHE_VERSION = 'v1.0.0'; +const CACHE_NAMES = { + STATIC: `worklenz-static-${CACHE_VERSION}`, + DYNAMIC: `worklenz-dynamic-${CACHE_VERSION}`, + API: `worklenz-api-${CACHE_VERSION}`, + IMAGES: `worklenz-images-${CACHE_VERSION}` +}; + +// Resources to cache immediately on install +const STATIC_CACHE_URLS = [ + '/', + '/index.html', + '/favicon.ico', + '/env-config.js', + '/manifest.json', + // Ant Design and other critical CSS/JS will be cached as they're requested +]; + +// API endpoints that can be cached +const CACHEABLE_API_PATTERNS = [ + /\/api\/project-categories/, + /\/api\/project-statuses/, + /\/api\/task-priorities/, + /\/api\/task-statuses/, + /\/api\/job-titles/, + /\/api\/teams\/\d+\/members/, + /\/api\/auth\/user/, // Cache user info for offline access +]; + +// Resources that should never be cached +const NEVER_CACHE_PATTERNS = [ + /\/api\/auth\/login/, + /\/api\/auth\/logout/, + /\/api\/notifications/, + /\/socket\.io/, + /\.hot-update\./, + /sw\.js$/, + /chrome-extension/, + /moz-extension/, +]; + +// Install event - Cache static resources +self.addEventListener('install', event => { + console.log('Service Worker: Installing...'); + + event.waitUntil( + (async () => { + try { + const cache = await caches.open(CACHE_NAMES.STATIC); + await cache.addAll(STATIC_CACHE_URLS); + console.log('Service Worker: Static resources cached'); + + // Skip waiting to activate immediately + await self.skipWaiting(); + } catch (error) { + console.error('Service Worker: Installation failed', error); + } + })() + ); +}); + +// Activate event - Clean up old caches +self.addEventListener('activate', event => { + console.log('Service Worker: Activating...'); + + event.waitUntil( + (async () => { + try { + // Clean up old caches + const cacheNames = await caches.keys(); + const oldCaches = cacheNames.filter(name => + Object.values(CACHE_NAMES).every(currentCache => currentCache !== name) + ); + + await Promise.all( + oldCaches.map(cacheName => caches.delete(cacheName)) + ); + + console.log('Service Worker: Old caches cleaned up'); + + // Take control of all pages + await self.clients.claim(); + } catch (error) { + console.error('Service Worker: Activation failed', error); + } + })() + ); +}); + +// Fetch event - Handle all network requests +self.addEventListener('fetch', event => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests and browser extensions + if (request.method !== 'GET' || NEVER_CACHE_PATTERNS.some(pattern => pattern.test(url.href))) { + return; + } + + event.respondWith(handleFetchRequest(request)); +}); + +// Main fetch handler with different strategies based on resource type +async function handleFetchRequest(request) { + const url = new URL(request.url); + + try { + // Static assets - Cache First strategy + if (isStaticAsset(url)) { + return await cacheFirstStrategy(request, CACHE_NAMES.STATIC); + } + + // Images - Cache First with long-term storage + if (isImageRequest(url)) { + return await cacheFirstStrategy(request, CACHE_NAMES.IMAGES); + } + + // API requests - Network First with fallback + if (isAPIRequest(url)) { + return await networkFirstStrategy(request, CACHE_NAMES.API); + } + + // HTML pages - Stale While Revalidate + if (isHTMLRequest(request)) { + return await staleWhileRevalidateStrategy(request, CACHE_NAMES.DYNAMIC); + } + + // Everything else - Network First + return await networkFirstStrategy(request, CACHE_NAMES.DYNAMIC); + + } catch (error) { + console.error('Service Worker: Fetch failed', error); + return createOfflineResponse(request); + } +} + +// Cache First Strategy - Try cache first, fallback to network +async function cacheFirstStrategy(request, cacheName) { + const cache = await caches.open(cacheName); + const cachedResponse = await cache.match(request); + + if (cachedResponse) { + return cachedResponse; + } + + try { + const networkResponse = await fetch(request); + if (networkResponse.status === 200) { + // Clone before caching as response can only be used once + const responseClone = networkResponse.clone(); + await cache.put(request, responseClone); + } + return networkResponse; + } catch (error) { + console.error('Cache First: Network failed', error); + throw error; + } +} + +// Network First Strategy - Try network first, fallback to cache +async function networkFirstStrategy(request, cacheName) { + const cache = await caches.open(cacheName); + + try { + const networkResponse = await fetch(request); + + if (networkResponse.status === 200) { + // Cache successful responses + const responseClone = networkResponse.clone(); + await cache.put(request, responseClone); + } + + return networkResponse; + } catch (error) { + console.warn('Network First: Network failed, trying cache', error); + const cachedResponse = await cache.match(request); + + if (cachedResponse) { + return cachedResponse; + } + + throw error; + } +} + +// Stale While Revalidate Strategy - Return cached version while updating in background +async function staleWhileRevalidateStrategy(request, cacheName) { + const cache = await caches.open(cacheName); + const cachedResponse = await cache.match(request); + + // Fetch from network in background + const networkResponsePromise = fetch(request).then(async networkResponse => { + if (networkResponse.status === 200) { + const responseClone = networkResponse.clone(); + await cache.put(request, responseClone); + } + return networkResponse; + }).catch(error => { + console.warn('Stale While Revalidate: Background update failed', error); + }); + + // Return cached version immediately if available + if (cachedResponse) { + return cachedResponse; + } + + // If no cached version, wait for network + return await networkResponsePromise; +} + +// Helper functions to identify resource types +function isStaticAsset(url) { + return /\.(js|css|woff2?|ttf|eot)$/.test(url.pathname) || + url.pathname.includes('/assets/') || + url.pathname === '/' || + url.pathname === '/index.html' || + url.pathname === '/favicon.ico' || + url.pathname === '/env-config.js'; +} + +function isImageRequest(url) { + return /\.(png|jpg|jpeg|gif|svg|webp|ico)$/.test(url.pathname) || + url.pathname.includes('/file-types/'); +} + +function isAPIRequest(url) { + return url.pathname.startsWith('/api/') || + CACHEABLE_API_PATTERNS.some(pattern => pattern.test(url.pathname)); +} + +function isHTMLRequest(request) { + return request.headers.get('accept')?.includes('text/html'); +} + +// Create offline fallback response +function createOfflineResponse(request) { + if (isImageRequest(new URL(request.url))) { + // Return a simple SVG placeholder for images + const svg = ` + + + Offline + + `; + + return new Response(svg, { + headers: { 'Content-Type': 'image/svg+xml' } + }); + } + + if (isAPIRequest(new URL(request.url))) { + // Return empty array or error for API requests + return new Response(JSON.stringify({ + error: 'Offline', + message: 'This feature requires an internet connection' + }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } + + // For HTML requests, try to return cached index.html + return caches.match('/') || new Response('Offline', { status: 503 }); +} + +// Handle background sync events (for future implementation) +self.addEventListener('sync', event => { + console.log('Service Worker: Background sync', event.tag); + + if (event.tag === 'background-sync') { + event.waitUntil(handleBackgroundSync()); + } +}); + +async function handleBackgroundSync() { + // This is where you would handle queued actions when coming back online + console.log('Service Worker: Handling background sync'); + + // Example: Send queued task updates, sync offline changes, etc. + // Implementation would depend on your app's specific needs +} + +// Handle push notification events (for future implementation) +self.addEventListener('push', event => { + if (!event.data) return; + + const options = { + body: event.data.text(), + icon: '/favicon.ico', + badge: '/favicon.ico', + vibrate: [200, 100, 200], + data: { + dateOfArrival: Date.now(), + primaryKey: 1 + } + }; + + event.waitUntil( + self.registration.showNotification('Worklenz', options) + ); +}); + +// Handle notification click events +self.addEventListener('notificationclick', event => { + event.notification.close(); + + event.waitUntil( + self.clients.openWindow('/') + ); +}); + +// Message handling for communication with main thread +self.addEventListener('message', event => { + const { type, payload } = event.data; + + switch (type) { + case 'SKIP_WAITING': + self.skipWaiting(); + break; + + case 'GET_VERSION': + event.ports[0].postMessage({ version: CACHE_VERSION }); + break; + + case 'CLEAR_CACHE': + clearAllCaches().then(() => { + event.ports[0].postMessage({ success: true }); + }); + break; + + default: + console.log('Service Worker: Unknown message type', type); + } +}); + +async function clearAllCaches() { + const cacheNames = await caches.keys(); + await Promise.all(cacheNames.map(name => caches.delete(name))); + console.log('Service Worker: All caches cleared'); +} + +console.log('Service Worker: Loaded successfully'); \ No newline at end of file diff --git a/worklenz-frontend/public/unregister-sw.js b/worklenz-frontend/public/unregister-sw.js deleted file mode 100644 index 4fbd8774..00000000 --- a/worklenz-frontend/public/unregister-sw.js +++ /dev/null @@ -1,23 +0,0 @@ -if ('serviceWorker' in navigator) { - // Check if we've already attempted to unregister in this session - if (!sessionStorage.getItem('swUnregisterAttempted')) { - navigator.serviceWorker.getRegistrations().then(function (registrations) { - const ngswWorker = registrations.find(reg => reg.active?.scriptURL.includes('ngsw-worker')); - - if (ngswWorker) { - // Mark that we've attempted to unregister - sessionStorage.setItem('swUnregisterAttempted', 'true'); - // Unregister the ngsw-worker - ngswWorker.unregister().then(() => { - // Reload the page after unregistering - window.location.reload(true); - }); - } else { - // If no ngsw-worker is found, unregister any other service workers - for (let registration of registrations) { - registration.unregister(); - } - } - }); - } -} diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 9fdd1605..3ed779c4 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -19,6 +19,9 @@ import { Language } from './features/i18n/localesSlice'; import logger from './utils/errorLogger'; import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback'; +// Service Worker +import { registerSW } from './utils/serviceWorkerRegistration'; + /** * Main App Component - Performance Optimized * @@ -96,6 +99,25 @@ const App: React.FC = memo(() => { }; }, []); + // Register service worker + useEffect(() => { + registerSW({ + onSuccess: (registration) => { + console.log('Service Worker registered successfully', registration); + }, + onUpdate: (registration) => { + console.log('New content is available and will be used when all tabs for this page are closed.'); + // You could show a toast notification here for user to refresh + }, + onOfflineReady: () => { + console.log('This web app has been cached for offline use.'); + }, + onError: (error) => { + logger.error('Service Worker registration failed:', error); + } + }); + }, []); + // Defer non-critical initialization useEffect(() => { const initializeNonCriticalApp = () => { diff --git a/worklenz-frontend/src/app/routes/index.tsx b/worklenz-frontend/src/app/routes/index.tsx index eb9148b7..a7f75760 100644 --- a/worklenz-frontend/src/app/routes/index.tsx +++ b/worklenz-frontend/src/app/routes/index.tsx @@ -29,24 +29,12 @@ const withCodeSplitting = (Component: React.LazyExoticComponent { - const authService = useAuthService(); - const location = useLocation(); + const { isAuthenticated, location } = useAuthStatus(); - const shouldRedirect = useMemo(() => { - try { - // Defensive check to ensure authService and its methods exist - if (!authService || typeof authService.isAuthenticated !== 'function') { - return false; // Don't redirect if auth service is not ready - } - return !authService.isAuthenticated(); - } catch (error) { - console.error('Error in AuthGuard:', error); - return false; // Don't redirect on error, let the app handle it - } - }, [authService]); - - if (shouldRedirect) { + if (!isAuthenticated) { return ; } @@ -56,41 +44,14 @@ export const AuthGuard = memo(({ children }: GuardProps) => { AuthGuard.displayName = 'AuthGuard'; export const AdminGuard = memo(({ children }: GuardProps) => { - const authService = useAuthService(); - const location = useLocation(); + const { isAuthenticated, isAdmin, location } = useAuthStatus(); - const guardResult = useMemo(() => { - try { - // Defensive checks to ensure authService and its methods exist - if ( - !authService || - typeof authService.isAuthenticated !== 'function' || - typeof authService.isOwnerOrAdmin !== 'function' || - typeof authService.getCurrentSession !== 'function' - ) { - return null; // Don't redirect if auth service is not ready - } + if (!isAuthenticated) { + return ; + } - if (!authService.isAuthenticated()) { - return { redirect: '/auth', state: { from: location } }; - } - - const currentSession = authService.getCurrentSession(); - const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE; - - if (!authService.isOwnerOrAdmin() || isFreePlan) { - return { redirect: '/worklenz/unauthorized' }; - } - - return null; - } catch (error) { - console.error('Error in AdminGuard:', error); - return null; // Don't redirect on error - } - }, [authService, location]); - - if (guardResult) { - return ; + if (!isAdmin) { + return ; } return <>{children}; @@ -99,77 +60,12 @@ export const AdminGuard = memo(({ children }: GuardProps) => { AdminGuard.displayName = 'AdminGuard'; export const LicenseExpiryGuard = memo(({ children }: GuardProps) => { - const authService = useAuthService(); - const location = useLocation(); + const { isLicenseExpired, location } = useAuthStatus(); - const shouldRedirect = useMemo(() => { - try { - // Defensive checks to ensure authService and its methods exist - if ( - !authService || - typeof authService.isAuthenticated !== 'function' || - typeof authService.getCurrentSession !== 'function' - ) { - return false; // Don't redirect if auth service is not ready - } + const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center'); + const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired'; - if (!authService.isAuthenticated()) return false; - - const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center'); - const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired'; - - // Don't check or redirect if we're already on the license-expired page - if (isLicenseExpiredRoute) return false; - - const currentSession = authService.getCurrentSession(); - - // Check if trial is expired more than 7 days or if is_expired flag is set - const isLicenseExpiredMoreThan7Days = () => { - // Quick bail if no session data is available - if (!currentSession) return false; - - // Check is_expired flag first - if (currentSession.is_expired) { - // If no trial_expire_date exists but is_expired is true, defer to backend check - if (!currentSession.trial_expire_date) return true; - - // If there is a trial_expire_date, check if it's more than 7 days past - const today = new Date(); - const expiryDate = new Date(currentSession.trial_expire_date); - const diffTime = today.getTime() - expiryDate.getTime(); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - // Redirect if more than 7 days past expiration - return diffDays > 7; - } - - // If not marked as expired but has trial_expire_date, do a date check - if ( - currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && - currentSession.trial_expire_date - ) { - const today = new Date(); - const expiryDate = new Date(currentSession.trial_expire_date); - - const diffTime = today.getTime() - expiryDate.getTime(); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - // If expired more than 7 days, redirect - return diffDays > 7; - } - - // No expiration data found - return false; - }; - - return isLicenseExpiredMoreThan7Days() && !isAdminCenterRoute; - } catch (error) { - console.error('Error in LicenseExpiryGuard:', error); - return false; // Don't redirect on error - } - }, [authService, location.pathname]); - - if (shouldRedirect) { + if (isLicenseExpired && !isAdminCenterRoute && !isLicenseExpiredRoute) { return ; } @@ -179,26 +75,16 @@ export const LicenseExpiryGuard = memo(({ children }: GuardProps) => { LicenseExpiryGuard.displayName = 'LicenseExpiryGuard'; export const SetupGuard = memo(({ children }: GuardProps) => { - const authService = useAuthService(); - const location = useLocation(); + const { isAuthenticated, isSetupComplete, location } = useAuthStatus(); - const shouldRedirect = useMemo(() => { - try { - // Defensive check to ensure authService and its methods exist - if (!authService || typeof authService.isAuthenticated !== 'function') { - return false; // Don't redirect if auth service is not ready - } - return !authService.isAuthenticated(); - } catch (error) { - console.error('Error in SetupGuard:', error); - return false; // Don't redirect on error - } - }, [authService]); - - if (shouldRedirect) { + if (!isAuthenticated) { return ; } + if (!isSetupComplete) { + return ; + } + return <>{children}; }); diff --git a/worklenz-frontend/src/app/routes/utils.ts b/worklenz-frontend/src/app/routes/utils.ts new file mode 100644 index 00000000..ca1d81d6 --- /dev/null +++ b/worklenz-frontend/src/app/routes/utils.ts @@ -0,0 +1,17 @@ +import { redirect } from 'react-router-dom'; +import { store } from '../store'; +import { verifyAuthentication } from '@/features/auth/authSlice'; + +export const authLoader = async () => { + const session = await store.dispatch(verifyAuthentication()).unwrap(); + + if (!session.user) { + return redirect('/auth/login'); + } + + if (session.user.is_expired) { + return redirect('/worklenz/license-expired'); + } + + return session; +}; diff --git a/worklenz-frontend/src/components/service-worker-status/ServiceWorkerStatus.tsx b/worklenz-frontend/src/components/service-worker-status/ServiceWorkerStatus.tsx new file mode 100644 index 00000000..90c7f589 --- /dev/null +++ b/worklenz-frontend/src/components/service-worker-status/ServiceWorkerStatus.tsx @@ -0,0 +1,140 @@ +// Service Worker Status Component +// Shows offline status and provides cache management controls + +import React from 'react'; +import { Badge, Button, Space, Tooltip, message } from 'antd'; +import { WifiOutlined, DisconnectOutlined, ReloadOutlined, DeleteOutlined } from '@ant-design/icons'; +import { useServiceWorker } from '../../utils/serviceWorkerRegistration'; + +interface ServiceWorkerStatusProps { + minimal?: boolean; // Show only basic offline indicator + showControls?: boolean; // Show cache management controls +} + +const ServiceWorkerStatus: React.FC = ({ + minimal = false, + showControls = false +}) => { + const { isOffline, swManager, clearCache, forceUpdate, getVersion } = useServiceWorker(); + const [swVersion, setSwVersion] = React.useState(''); + const [isClearing, setIsClearing] = React.useState(false); + const [isUpdating, setIsUpdating] = React.useState(false); + + // Get service worker version on mount + React.useEffect(() => { + if (getVersion) { + const versionPromise = getVersion(); + if (versionPromise) { + versionPromise.then(version => { + setSwVersion(version); + }).catch(() => { + // Ignore errors when getting version + }); + } + } + }, [getVersion]); + + const handleClearCache = async () => { + if (!clearCache) return; + + setIsClearing(true); + try { + const success = await clearCache(); + if (success) { + message.success('Cache cleared successfully'); + } else { + message.error('Failed to clear cache'); + } + } catch (error) { + message.error('Error clearing cache'); + } finally { + setIsClearing(false); + } + }; + + const handleForceUpdate = async () => { + if (!forceUpdate) return; + + setIsUpdating(true); + try { + await forceUpdate(); + message.success('Application will reload with updates'); + } catch (error) { + message.error('Failed to update application'); + setIsUpdating(false); + } + }; + + // Minimal version - just show offline status + if (minimal) { + return ( + + + + ); + } + + return ( +
+ + {/* Connection Status */} +
+ {isOffline ? ( + + ) : ( + + )} + + {isOffline ? 'Offline Mode' : 'Online'} + + {swVersion && ( + + v{swVersion} + + )} +
+ + {/* Information */} +
+ {isOffline ? ( + 'App is running from cache. Changes will sync when online.' + ) : ( + 'App is cached for offline use. Ready to work anywhere!' + )} +
+ + {/* Controls */} + {showControls && swManager && ( + + + + + + + + + + )} +
+
+ ); +}; + +export default ServiceWorkerStatus; \ No newline at end of file diff --git a/worklenz-frontend/src/hooks/useAuthStatus.ts b/worklenz-frontend/src/hooks/useAuthStatus.ts new file mode 100644 index 00000000..2158d5fe --- /dev/null +++ b/worklenz-frontend/src/hooks/useAuthStatus.ts @@ -0,0 +1,52 @@ +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useAuthService } from '@/hooks/useAuth'; +import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; + +export const useAuthStatus = () => { + const authService = useAuthService(); + const location = useLocation(); + + const status = useMemo(() => { + try { + if (!authService || typeof authService.isAuthenticated !== 'function') { + return { isAuthenticated: false, isLicenseExpired: false, isAdmin: false, isSetupComplete: false }; + } + + const isAuthenticated = authService.isAuthenticated(); + if (!isAuthenticated) { + return { isAuthenticated: false, isLicenseExpired: false, isAdmin: false, isSetupComplete: false }; + } + + const currentSession = authService.getCurrentSession(); + const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE; + const isAdmin = authService.isOwnerOrAdmin() && !isFreePlan; + const isSetupComplete = currentSession?.setup_completed ?? false; + + const isLicenseExpired = () => { + if (!currentSession) return false; + if (currentSession.is_expired) return true; + + if ( + currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && + currentSession.trial_expire_date + ) { + const today = new Date(); + const expiryDate = new Date(currentSession.trial_expire_date); + const diffTime = today.getTime() - expiryDate.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return diffDays > 7; + } + + return false; + }; + + return { isAuthenticated, isLicenseExpired: isLicenseExpired(), isAdmin, isSetupComplete }; + } catch (error) { + console.error('Error in useAuthStatus:', error); + return { isAuthenticated: false, isLicenseExpired: false, isAdmin: false, isSetupComplete: false }; + } + }, [authService]); + + return { ...status, location }; +}; diff --git a/worklenz-frontend/src/layouts/MainLayout.tsx b/worklenz-frontend/src/layouts/MainLayout.tsx index 0d4c97c3..53f31bc7 100644 --- a/worklenz-frontend/src/layouts/MainLayout.tsx +++ b/worklenz-frontend/src/layouts/MainLayout.tsx @@ -1,36 +1,25 @@ import { Col, ConfigProvider, Layout } from 'antd'; import { Outlet, useNavigate } from 'react-router-dom'; -import { useEffect, memo, useMemo, useCallback } from 'react'; +import { memo, useMemo } from 'react'; import { useMediaQuery } from 'react-responsive'; import Navbar from '../features/navbar/navbar'; import { useAppSelector } from '../hooks/useAppSelector'; import { useAppDispatch } from '../hooks/useAppDispatch'; import { colors } from '../styles/colors'; -import { verifyAuthentication } from '@/features/auth/authSlice'; + import { useRenderPerformance } from '@/utils/performance'; import HubSpot from '@/components/HubSpot'; const MainLayout = memo(() => { const themeMode = useAppSelector(state => state.themeReducer.mode); const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' }); - const dispatch = useAppDispatch(); - const navigate = useNavigate(); + // Performance monitoring in development useRenderPerformance('MainLayout'); - // Memoize auth verification function - const verifyAuthStatus = useCallback(async () => { - const session = await dispatch(verifyAuthentication()).unwrap(); - if (!session.user.setup_completed) { - navigate('/worklenz/setup'); - } - }, [dispatch, navigate]); - - useEffect(() => { - void verifyAuthStatus(); - }, [verifyAuthStatus]); + // Memoize styles to prevent object recreation on every render const headerStyles = useMemo( diff --git a/worklenz-frontend/src/layouts/ReportingLayout.tsx b/worklenz-frontend/src/layouts/ReportingLayout.tsx index a95807cc..09f8c6b2 100644 --- a/worklenz-frontend/src/layouts/ReportingLayout.tsx +++ b/worklenz-frontend/src/layouts/ReportingLayout.tsx @@ -22,11 +22,7 @@ const ReportingLayout = () => { const currentSession = getCurrentSession(); const navigate = useNavigate(); - useEffect(() => { - if (currentSession?.is_expired) { - navigate('/worklenz/license-expired'); - } - }, [currentSession, navigate]); + // function to handle collapse const handleCollapsedToggler = () => { diff --git a/worklenz-frontend/src/layouts/SettingsLayout.tsx b/worklenz-frontend/src/layouts/SettingsLayout.tsx index 450bda31..87f0f525 100644 --- a/worklenz-frontend/src/layouts/SettingsLayout.tsx +++ b/worklenz-frontend/src/layouts/SettingsLayout.tsx @@ -11,11 +11,7 @@ const SettingsLayout = () => { const currentSession = getCurrentSession(); const navigate = useNavigate(); - useEffect(() => { - if (currentSession?.is_expired) { - navigate('/worklenz/license-expired'); - } - }, [currentSession, navigate]); + return (
diff --git a/worklenz-frontend/src/layouts/admin-center-layout.tsx b/worklenz-frontend/src/layouts/admin-center-layout.tsx index 2ce73c63..4addfd3c 100644 --- a/worklenz-frontend/src/layouts/admin-center-layout.tsx +++ b/worklenz-frontend/src/layouts/admin-center-layout.tsx @@ -1,10 +1,9 @@ import { Flex, Typography } from 'antd'; -import React, { useEffect } from 'react'; +import React from 'react'; import { Outlet } from 'react-router-dom'; import { useMediaQuery } from 'react-responsive'; import AdminCenterSidebar from '@/pages/admin-center/sidebar/sidebar'; import { useTranslation } from 'react-i18next'; -import { verifyAuthentication } from '@/features/auth/authSlice'; import { useAppDispatch } from '@/hooks/useAppDispatch'; const AdminCenterLayout: React.FC = () => { @@ -13,9 +12,7 @@ const AdminCenterLayout: React.FC = () => { const isMarginAvailable = useMediaQuery({ query: '(min-width: 1000px)' }); const { t } = useTranslation('admin-center/sidebar'); - useEffect(() => { - void dispatch(verifyAuthentication()); - }, [dispatch]); + return (
{ + registerSW({ + onSuccess: (registration) => { + console.log('SW registered successfully'); + }, + onUpdate: (registration) => { + // Show update notification to user + }, + onOfflineReady: () => { + console.log('App ready for offline use'); + } + }); +}, []); +``` + +### Using the Hook + +```tsx +import { useServiceWorker } from '../utils/serviceWorkerRegistration'; + +const MyComponent = () => { + const { isOffline, swManager, clearCache, forceUpdate } = useServiceWorker(); + + return ( +
+

Status: {isOffline ? 'Offline' : 'Online'}

+ + +
+ ); +}; +``` + +### Status Component + +```tsx +import ServiceWorkerStatus from '../components/service-worker-status/ServiceWorkerStatus'; + +// Minimal offline indicator + + +// Full status with controls + +``` + +## Configuration + +### Cacheable Resources + +Edit the patterns in `sw.js`: + +```javascript +// API endpoints that can be cached +const CACHEABLE_API_PATTERNS = [ + /\/api\/project-categories/, + /\/api\/task-statuses/, + // Add more patterns... +]; + +// Resources that should never be cached +const NEVER_CACHE_PATTERNS = [ + /\/api\/auth\/login/, + /\/socket\.io/, + // Add more patterns... +]; +``` + +### Cache Names + +Update version to force cache refresh: + +```javascript +const CACHE_VERSION = 'v1.0.1'; // Increment when deploying +``` + +## Development + +### Testing Offline + +1. Open DevTools → Application → Service Workers +2. Check "Offline" to simulate offline mode +3. Verify app still functions + +### Debugging + +```javascript +// Check service worker status +navigator.serviceWorker.ready.then(registration => { + console.log('SW ready:', registration); +}); + +// Check cache contents +caches.keys().then(names => { + console.log('Cache names:', names); +}); +``` + +### Cache Management + +```javascript +// Clear all caches +caches.keys().then(names => + Promise.all(names.map(name => caches.delete(name))) +); + +// Clear specific cache +caches.delete('worklenz-api-v1.0.0'); +``` + +## Best Practices + +### 1. Cache Strategy Selection + +- **Static Assets**: Cache First (fast loading) +- **API Data**: Network First (fresh data) +- **User Content**: Network Only (always fresh) +- **App Shell**: Cache First (instant loading) + +### 2. Cache Invalidation + +- Increment `CACHE_VERSION` when deploying +- Use versioned URLs for assets +- Set appropriate cache headers + +### 3. Offline UX + +- Show offline indicators +- Queue actions for later sync +- Provide meaningful offline messages +- Cache critical user data + +### 4. Performance + +- Cache only necessary resources +- Set cache size limits +- Clean up old caches regularly +- Monitor cache usage + +## Monitoring + +### Storage Usage + +```javascript +// Check storage quota +navigator.storage.estimate().then(estimate => { + console.log('Used:', estimate.usage); + console.log('Quota:', estimate.quota); +}); +``` + +### Cache Hit Rate + +Monitor in DevTools → Network: +- Look for "from ServiceWorker" requests +- Check cache effectiveness + +## Troubleshooting + +### Common Issues + +1. **SW not updating** + - Hard refresh (Ctrl+Shift+R) + - Clear browser cache + - Check CACHE_VERSION + +2. **Resources not caching** + - Verify URL patterns + - Check NEVER_CACHE_PATTERNS + - Ensure HTTPS in production + +3. **Offline features not working** + - Verify SW registration + - Check browser support + - Test cache strategies + +### Reset Service Worker + +```javascript +// Unregister and reload +navigator.serviceWorker.getRegistrations().then(registrations => { + registrations.forEach(registration => registration.unregister()); + window.location.reload(); +}); +``` + +## Browser Support + +- ✅ Chrome 45+ +- ✅ Firefox 44+ +- ✅ Safari 11.1+ +- ✅ Edge 17+ +- ❌ Internet Explorer + +## Future Enhancements + +1. **Background Sync** + - Queue offline actions + - Sync when online + +2. **Push Notifications** + - Task assignments + - Project updates + - Deadline reminders + +3. **Advanced Caching** + - Intelligent prefetching + - ML-based cache eviction + - Compression + +4. **Offline Analytics** + - Track offline usage + - Cache hit rates + - Performance metrics + +--- + +*Last updated: January 2025* \ No newline at end of file diff --git a/worklenz-frontend/src/utils/serviceWorkerRegistration.ts b/worklenz-frontend/src/utils/serviceWorkerRegistration.ts new file mode 100644 index 00000000..1f70f785 --- /dev/null +++ b/worklenz-frontend/src/utils/serviceWorkerRegistration.ts @@ -0,0 +1,273 @@ +// Service Worker Registration Utility +// Handles registration, updates, and error handling + +import React from 'react'; + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + window.location.hostname === '[::1]' || + window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) +); + +type Config = { + onSuccess?: (registration: ServiceWorkerRegistration) => void; + onUpdate?: (registration: ServiceWorkerRegistration) => void; + onOfflineReady?: () => void; + onError?: (error: Error) => void; +}; + +export function registerSW(config?: Config) { + if ('serviceWorker' in navigator) { + // Only register in production or when explicitly testing + const swUrl = '/sw.js'; + + if (isLocalhost) { + // This is running on localhost. Let's check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config); + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + 'This web app is being served cache-first by a service ' + + 'worker. To learn more, visit https://bit.ly/CRA-PWA' + ); + }); + } else { + // Is not localhost. Just register service worker + registerValidSW(swUrl, config); + } + } else { + console.log('Service workers are not supported in this browser.'); + } +} + +function registerValidSW(swUrl: string, config?: Config) { + navigator.serviceWorker + .register(swUrl) + .then(registration => { + console.log('Service Worker registered successfully:', registration); + + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker == null) { + return; + } + + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the updated precached content has been fetched, + // but the previous service worker will still serve the older + // content until all client tabs are closed. + console.log( + 'New content is available and will be used when all ' + + 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' + ); + + // Execute callback + if (config && config.onUpdate) { + config.onUpdate(registration); + } + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.log('Content is cached for offline use.'); + + // Execute callback + if (config && config.onSuccess) { + config.onSuccess(registration); + } + + if (config && config.onOfflineReady) { + config.onOfflineReady(); + } + } + } + }; + }; + }) + .catch(error => { + console.error('Error during service worker registration:', error); + if (config && config.onError) { + config.onError(error); + } + }); +} + +function checkValidServiceWorker(swUrl: string, config?: Config) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl, { + headers: { 'Service-Worker': 'script' }, + }) + .then(response => { + // Ensure service worker exists, and that we really are getting a JS file. + const contentType = response.headers.get('content-type'); + if ( + response.status === 404 || + (contentType != null && contentType.indexOf('javascript') === -1) + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then(registration => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl, config); + } + }) + .catch(() => { + console.log( + 'No internet connection found. App is running in offline mode.' + ); + }); +} + +export function unregisterSW() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.unregister(); + console.log('Service Worker unregistered successfully'); + }) + .catch(error => { + console.error('Error during service worker unregistration:', error); + }); + } +} + +// Utility to communicate with service worker +export class ServiceWorkerManager { + private registration: ServiceWorkerRegistration | null = null; + + constructor(registration?: ServiceWorkerRegistration) { + this.registration = registration || null; + } + + // Send message to service worker + async sendMessage(type: string, payload?: any): Promise { + if (!this.registration || !this.registration.active) { + throw new Error('Service Worker not available'); + } + + return new Promise((resolve, reject) => { + const messageChannel = new MessageChannel(); + + messageChannel.port1.onmessage = (event) => { + if (event.data.error) { + reject(event.data.error); + } else { + resolve(event.data); + } + }; + + this.registration!.active!.postMessage( + { type, payload }, + [messageChannel.port2] + ); + + // Timeout after 5 seconds + setTimeout(() => { + reject(new Error('Service Worker message timeout')); + }, 5000); + }); + } + + // Get service worker version + async getVersion(): Promise { + try { + const response = await this.sendMessage('GET_VERSION'); + return response.version; + } catch (error) { + console.error('Failed to get service worker version:', error); + return 'unknown'; + } + } + + // Clear all caches + async clearCache(): Promise { + try { + await this.sendMessage('CLEAR_CACHE'); + return true; + } catch (error) { + console.error('Failed to clear cache:', error); + return false; + } + } + + // Force update service worker + async forceUpdate(): Promise { + if (!this.registration) return; + + try { + await this.registration.update(); + await this.sendMessage('SKIP_WAITING'); + window.location.reload(); + } catch (error) { + console.error('Failed to force update service worker:', error); + throw error; + } + } + + // Check if app is running offline + isOffline(): boolean { + return !navigator.onLine; + } + + // Get cache storage estimate + async getCacheSize(): Promise { + if ('storage' in navigator && 'estimate' in navigator.storage) { + try { + return await navigator.storage.estimate(); + } catch (error) { + console.error('Failed to get storage estimate:', error); + } + } + return null; + } +} + +// Hook to use service worker in React components +export function useServiceWorker() { + const [isOffline, setIsOffline] = React.useState(!navigator.onLine); + const [swManager, setSWManager] = React.useState(null); + + React.useEffect(() => { + const handleOnline = () => setIsOffline(false); + const handleOffline = () => setIsOffline(true); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + // Register service worker + registerSW({ + onSuccess: (registration) => { + setSWManager(new ServiceWorkerManager(registration)); + }, + onUpdate: (registration) => { + // You could show a toast here asking user to refresh + console.log('New version available'); + setSWManager(new ServiceWorkerManager(registration)); + }, + onOfflineReady: () => { + console.log('App ready for offline use'); + } + }); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + return { + isOffline, + swManager, + clearCache: () => swManager?.clearCache(), + forceUpdate: () => swManager?.forceUpdate(), + getVersion: () => swManager?.getVersion(), + }; +} \ No newline at end of file diff --git a/worklenz-frontend/vite.config.ts b/worklenz-frontend/vite.config.ts index d0ba2dac..6bca483d 100644 --- a/worklenz-frontend/vite.config.ts +++ b/worklenz-frontend/vite.config.ts @@ -138,5 +138,8 @@ export default defineConfig(({ command, mode }) => { define: { __DEV__: !isProduction, }, + + // **Public Directory** - sw.js will be automatically copied from public/ to build/ + publicDir: 'public', }; }); \ No newline at end of file From f846230d596e2068b02c9b536d9817e2aa8eea35 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 10 Jul 2025 14:16:51 +0530 Subject: [PATCH 24/49] feat(localization): update project list translations and add new keys - Enhanced localization JSON files for multiple languages, including Albanian, German, English, Spanish, Portuguese, and Chinese. - Updated existing translation keys for consistency and clarity, particularly for task progress, archive confirmations, and filtering instructions. - Introduced new keys for improved user experience, such as "yes", "no", "list", "group", and "noPermission". - Ensured all translations align with the latest UI changes for better internationalization support. --- .../public/locales/alb/all-project-list.json | 31 ++- .../public/locales/de/all-project-list.json | 13 +- .../public/locales/en/all-project-list.json | 5 +- .../public/locales/es/all-project-list.json | 19 +- .../public/locales/pt/all-project-list.json | 11 +- .../public/locales/zh/all-project-list.json | 15 +- .../src/app/routes/admin-center-routes.tsx | 2 +- ...enter-layout.tsx => AdminCenterLayout.tsx} | 0 .../src/utils/README-ServiceWorker.md | 259 ------------------ 9 files changed, 69 insertions(+), 286 deletions(-) rename worklenz-frontend/src/layouts/{admin-center-layout.tsx => AdminCenterLayout.tsx} (100%) delete mode 100644 worklenz-frontend/src/utils/README-ServiceWorker.md diff --git a/worklenz-frontend/public/locales/alb/all-project-list.json b/worklenz-frontend/public/locales/alb/all-project-list.json index 0e090edb..8079f13d 100644 --- a/worklenz-frontend/public/locales/alb/all-project-list.json +++ b/worklenz-frontend/public/locales/alb/all-project-list.json @@ -3,21 +3,32 @@ "client": "Klienti", "category": "Kategoria", "status": "Statusi", - "tasksProgress": "Progresi i Detyrave", - "updated_at": "Përditësuar Së Fundi", + "tasksProgress": "Përparimi i Detyrave", + "updated_at": "E Përditësuar së Fundi", "members": "Anëtarët", "setting": "Cilësimet", "projects": "Projektet", "refreshProjects": "Rifresko projektet", - "all": "Të Gjitha", - "favorites": "Të Preferuarat", - "archived": "Të Arkivuara", + "all": "Të gjitha", + "favorites": "Të preferuarit", + "archived": "E arkivuar", "placeholder": "Kërko sipas emrit", "archive": "Arkivo", - "unarchive": "Ç'arkivo", - "archiveConfirm": "Jeni i sigurt që doni ta arkivoni këtë projekt?", - "unarchiveConfirm": "Jeni i sigurt që doni ta çarkivoni këtë projekt?", - "clickToFilter": "Klikoni për të filtruar sipas", + "unarchive": "Çarkivo", + "archiveConfirm": "Jeni i sigurt që dëshironi të arkivoni këtë projekt?", + "unarchiveConfirm": "Jeni i sigurt që dëshironi të çarkivoni këtë projekt?", + "yes": "Po", + "no": "Jo", + "clickToFilter": "Kliko për të filtruar sipas", "noProjects": "Nuk u gjetën projekte", - "addToFavourites": "Shto në të preferuarat" + "addToFavourites": "Shto te të preferuarit", + "list": "Lista", + "group": "Grupi", + "listView": "Pamja e Listës", + "groupView": "Pamja e Grupit", + "groupBy": { + "category": "Kategoria", + "client": "Klienti" + }, + "noPermission": "Nuk keni leje për të kryer këtë veprim" } diff --git a/worklenz-frontend/public/locales/de/all-project-list.json b/worklenz-frontend/public/locales/de/all-project-list.json index b11fbbcd..89a9803d 100644 --- a/worklenz-frontend/public/locales/de/all-project-list.json +++ b/worklenz-frontend/public/locales/de/all-project-list.json @@ -17,7 +17,18 @@ "unarchive": "Dearchivieren", "archiveConfirm": "Sind Sie sicher, dass Sie dieses Projekt archivieren möchten?", "unarchiveConfirm": "Sind Sie sicher, dass Sie dieses Projekt dearchivieren möchten?", + "yes": "Ja", + "no": "Nein", "clickToFilter": "Zum Filtern klicken nach", "noProjects": "Keine Projekte gefunden", - "addToFavourites": "Zu Favoriten hinzufügen" + "addToFavourites": "Zu Favoriten hinzufügen", + "list": "Liste", + "group": "Gruppe", + "listView": "Listenansicht", + "groupView": "Gruppenansicht", + "groupBy": { + "category": "Kategorie", + "client": "Kunde" + }, + "noPermission": "Sie haben keine Berechtigung, diese Aktion durchzuführen" } diff --git a/worklenz-frontend/public/locales/en/all-project-list.json b/worklenz-frontend/public/locales/en/all-project-list.json index 86aae0d3..ab98cb6b 100644 --- a/worklenz-frontend/public/locales/en/all-project-list.json +++ b/worklenz-frontend/public/locales/en/all-project-list.json @@ -17,6 +17,8 @@ "unarchive": "Unarchive", "archiveConfirm": "Are you sure you want to archive this project?", "unarchiveConfirm": "Are you sure you want to unarchive this project?", + "yes": "Yes", + "no": "No", "clickToFilter": "Click to filter by", "noProjects": "No projects found", "addToFavourites": "Add to favourites", @@ -27,5 +29,6 @@ "groupBy": { "category": "Category", "client": "Client" - } + }, + "noPermission": "You don't have permission to perform this action" } diff --git a/worklenz-frontend/public/locales/es/all-project-list.json b/worklenz-frontend/public/locales/es/all-project-list.json index 3a0971d1..4a72d9c7 100644 --- a/worklenz-frontend/public/locales/es/all-project-list.json +++ b/worklenz-frontend/public/locales/es/all-project-list.json @@ -3,23 +3,25 @@ "client": "Cliente", "category": "Categoría", "status": "Estado", - "tasksProgress": "Progreso de tareas", - "updated_at": "Última actualización", + "tasksProgress": "Progreso de Tareas", + "updated_at": "Última Actualización", "members": "Miembros", "setting": "Configuración", - "archive": "Archivar", "projects": "Proyectos", "refreshProjects": "Actualizar proyectos", "all": "Todos", "favorites": "Favoritos", "archived": "Archivados", "placeholder": "Buscar por nombre", + "archive": "Archivar", "unarchive": "Desarchivar", - "archiveConfirm": "¿Estás seguro de que deseas archivar este proyecto?", - "unarchiveConfirm": "¿Estás seguro de que deseas desarchivar este proyecto?", - "clickToFilter": "Clique para filtrar por", + "archiveConfirm": "¿Está seguro de que desea archivar este proyecto?", + "unarchiveConfirm": "¿Está seguro de que desea desarchivar este proyecto?", + "yes": "Sí", + "no": "No", + "clickToFilter": "Haga clic para filtrar por", "noProjects": "No se encontraron proyectos", - "addToFavourites": "Añadir a favoritos", + "addToFavourites": "Agregar a favoritos", "list": "Lista", "group": "Grupo", "listView": "Vista de Lista", @@ -27,5 +29,6 @@ "groupBy": { "category": "Categoría", "client": "Cliente" - } + }, + "noPermission": "No tienes permiso para realizar esta acción" } diff --git a/worklenz-frontend/public/locales/pt/all-project-list.json b/worklenz-frontend/public/locales/pt/all-project-list.json index 8d7832fb..482132eb 100644 --- a/worklenz-frontend/public/locales/pt/all-project-list.json +++ b/worklenz-frontend/public/locales/pt/all-project-list.json @@ -6,17 +6,19 @@ "tasksProgress": "Progresso das Tarefas", "updated_at": "Última Atualização", "members": "Membros", - "setting": "Configuração", - "archive": "Arquivar", + "setting": "Configurações", "projects": "Projetos", "refreshProjects": "Atualizar projetos", "all": "Todos", "favorites": "Favoritos", "archived": "Arquivados", "placeholder": "Pesquisar por nome", - "archiveConfirm": "Tem certeza de que deseja arquivar este projeto?", + "archive": "Arquivar", "unarchive": "Desarquivar", + "archiveConfirm": "Tem certeza de que deseja arquivar este projeto?", "unarchiveConfirm": "Tem certeza de que deseja desarquivar este projeto?", + "yes": "Sim", + "no": "Não", "clickToFilter": "Clique para filtrar por", "noProjects": "Nenhum projeto encontrado", "addToFavourites": "Adicionar aos favoritos", @@ -27,5 +29,6 @@ "groupBy": { "category": "Categoria", "client": "Cliente" - } + }, + "noPermission": "Você não tem permissão para realizar esta ação" } diff --git a/worklenz-frontend/public/locales/zh/all-project-list.json b/worklenz-frontend/public/locales/zh/all-project-list.json index 9ff1a707..a6c72c06 100644 --- a/worklenz-frontend/public/locales/zh/all-project-list.json +++ b/worklenz-frontend/public/locales/zh/all-project-list.json @@ -17,7 +17,18 @@ "unarchive": "取消归档", "archiveConfirm": "您确定要归档此项目吗?", "unarchiveConfirm": "您确定要取消归档此项目吗?", - "clickToFilter": "点击以筛选", + "yes": "是", + "no": "否", + "clickToFilter": "点击筛选", "noProjects": "未找到项目", - "addToFavourites": "添加到收藏" + "addToFavourites": "添加到收藏", + "list": "列表", + "group": "分组", + "listView": "列表视图", + "groupView": "分组视图", + "groupBy": { + "category": "类别", + "client": "客户" + }, + "noPermission": "您没有权限执行此操作" } \ No newline at end of file diff --git a/worklenz-frontend/src/app/routes/admin-center-routes.tsx b/worklenz-frontend/src/app/routes/admin-center-routes.tsx index 67ad5b26..6d41ae6c 100644 --- a/worklenz-frontend/src/app/routes/admin-center-routes.tsx +++ b/worklenz-frontend/src/app/routes/admin-center-routes.tsx @@ -1,10 +1,10 @@ import { RouteObject } from 'react-router-dom'; import { Suspense } from 'react'; -import AdminCenterLayout from '@/layouts/admin-center-layout'; import { adminCenterItems } from '@/pages/admin-center/admin-center-constants'; import { Navigate } from 'react-router-dom'; import { useAuthService } from '@/hooks/useAuth'; import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; +import AdminCenterLayout from '@/layouts/AdminCenterLayout'; const AdminCenterGuard = ({ children }: { children: React.ReactNode }) => { const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); diff --git a/worklenz-frontend/src/layouts/admin-center-layout.tsx b/worklenz-frontend/src/layouts/AdminCenterLayout.tsx similarity index 100% rename from worklenz-frontend/src/layouts/admin-center-layout.tsx rename to worklenz-frontend/src/layouts/AdminCenterLayout.tsx diff --git a/worklenz-frontend/src/utils/README-ServiceWorker.md b/worklenz-frontend/src/utils/README-ServiceWorker.md deleted file mode 100644 index 49976316..00000000 --- a/worklenz-frontend/src/utils/README-ServiceWorker.md +++ /dev/null @@ -1,259 +0,0 @@ -# Service Worker Implementation - -This directory contains the service worker implementation for Worklenz, providing offline functionality, caching, and performance improvements. - -## Files Overview - -- **`sw.js`** (in `/public/`) - The main service worker file -- **`serviceWorkerRegistration.ts`** - Registration and management utilities -- **`ServiceWorkerStatus.tsx`** (in `/components/`) - React component for SW status - -## Features - -### 🔄 Caching Strategies - -1. **Cache First** - Static assets (JS, CSS, images) - - Serves from cache first, falls back to network - - Perfect for unchanging resources - -2. **Network First** - API requests - - Tries network first, falls back to cache - - Ensures fresh data when online - -3. **Stale While Revalidate** - HTML pages - - Serves cached version immediately - - Updates cache in background - -### 📱 PWA Features - -- **Offline Support** - App works without internet -- **Installable** - Can be installed on devices -- **Background Sync** - Sync data when online (framework ready) -- **Push Notifications** - Real-time notifications (framework ready) - -## Usage - -### Basic Integration - -The service worker is automatically registered in `App.tsx`: - -```tsx -import { registerSW } from './utils/serviceWorkerRegistration'; - -useEffect(() => { - registerSW({ - onSuccess: (registration) => { - console.log('SW registered successfully'); - }, - onUpdate: (registration) => { - // Show update notification to user - }, - onOfflineReady: () => { - console.log('App ready for offline use'); - } - }); -}, []); -``` - -### Using the Hook - -```tsx -import { useServiceWorker } from '../utils/serviceWorkerRegistration'; - -const MyComponent = () => { - const { isOffline, swManager, clearCache, forceUpdate } = useServiceWorker(); - - return ( -
-

Status: {isOffline ? 'Offline' : 'Online'}

- - -
- ); -}; -``` - -### Status Component - -```tsx -import ServiceWorkerStatus from '../components/service-worker-status/ServiceWorkerStatus'; - -// Minimal offline indicator - - -// Full status with controls - -``` - -## Configuration - -### Cacheable Resources - -Edit the patterns in `sw.js`: - -```javascript -// API endpoints that can be cached -const CACHEABLE_API_PATTERNS = [ - /\/api\/project-categories/, - /\/api\/task-statuses/, - // Add more patterns... -]; - -// Resources that should never be cached -const NEVER_CACHE_PATTERNS = [ - /\/api\/auth\/login/, - /\/socket\.io/, - // Add more patterns... -]; -``` - -### Cache Names - -Update version to force cache refresh: - -```javascript -const CACHE_VERSION = 'v1.0.1'; // Increment when deploying -``` - -## Development - -### Testing Offline - -1. Open DevTools → Application → Service Workers -2. Check "Offline" to simulate offline mode -3. Verify app still functions - -### Debugging - -```javascript -// Check service worker status -navigator.serviceWorker.ready.then(registration => { - console.log('SW ready:', registration); -}); - -// Check cache contents -caches.keys().then(names => { - console.log('Cache names:', names); -}); -``` - -### Cache Management - -```javascript -// Clear all caches -caches.keys().then(names => - Promise.all(names.map(name => caches.delete(name))) -); - -// Clear specific cache -caches.delete('worklenz-api-v1.0.0'); -``` - -## Best Practices - -### 1. Cache Strategy Selection - -- **Static Assets**: Cache First (fast loading) -- **API Data**: Network First (fresh data) -- **User Content**: Network Only (always fresh) -- **App Shell**: Cache First (instant loading) - -### 2. Cache Invalidation - -- Increment `CACHE_VERSION` when deploying -- Use versioned URLs for assets -- Set appropriate cache headers - -### 3. Offline UX - -- Show offline indicators -- Queue actions for later sync -- Provide meaningful offline messages -- Cache critical user data - -### 4. Performance - -- Cache only necessary resources -- Set cache size limits -- Clean up old caches regularly -- Monitor cache usage - -## Monitoring - -### Storage Usage - -```javascript -// Check storage quota -navigator.storage.estimate().then(estimate => { - console.log('Used:', estimate.usage); - console.log('Quota:', estimate.quota); -}); -``` - -### Cache Hit Rate - -Monitor in DevTools → Network: -- Look for "from ServiceWorker" requests -- Check cache effectiveness - -## Troubleshooting - -### Common Issues - -1. **SW not updating** - - Hard refresh (Ctrl+Shift+R) - - Clear browser cache - - Check CACHE_VERSION - -2. **Resources not caching** - - Verify URL patterns - - Check NEVER_CACHE_PATTERNS - - Ensure HTTPS in production - -3. **Offline features not working** - - Verify SW registration - - Check browser support - - Test cache strategies - -### Reset Service Worker - -```javascript -// Unregister and reload -navigator.serviceWorker.getRegistrations().then(registrations => { - registrations.forEach(registration => registration.unregister()); - window.location.reload(); -}); -``` - -## Browser Support - -- ✅ Chrome 45+ -- ✅ Firefox 44+ -- ✅ Safari 11.1+ -- ✅ Edge 17+ -- ❌ Internet Explorer - -## Future Enhancements - -1. **Background Sync** - - Queue offline actions - - Sync when online - -2. **Push Notifications** - - Task assignments - - Project updates - - Deadline reminders - -3. **Advanced Caching** - - Intelligent prefetching - - ML-based cache eviction - - Compression - -4. **Offline Analytics** - - Track offline usage - - Cache hit rates - - Performance metrics - ---- - -*Last updated: January 2025* \ No newline at end of file From 857b48e2253f2a672b053443bcfa863d53b2e61c Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 10 Jul 2025 16:25:13 +0530 Subject: [PATCH 25/49] feat(localization): add new translation keys for task management - Updated localization JSON files for Albanian, German, English, Spanish, Portuguese, and Chinese to include new keys for managing statuses and phases. - Enhanced existing translations for clarity and consistency across multiple languages. - Ensured that new keys align with recent UI changes to improve user experience in task management features. --- .../public/locales/alb/task-list-filters.json | 4 +- .../public/locales/de/task-list-filters.json | 8 ++-- .../public/locales/en/task-list-filters.json | 4 +- .../public/locales/es/task-list-filters.json | 4 +- .../public/locales/pt/task-list-filters.json | 4 +- .../public/locales/zh/task-list-filters.json | 4 +- .../create-status-drawer.tsx | 4 +- .../task-list-v2/TaskListV2Table.tsx | 4 +- .../task-list-v2/constants/columns.ts | 8 ++-- .../task-management/improved-task-filters.tsx | 37 +++++++++++++++++-- .../task-templates/import-task-template.tsx | 4 +- .../src/hooks/useFilterDataLoader.ts | 11 +++++- .../projectView/project-view-header.tsx | 3 +- 13 files changed, 78 insertions(+), 21 deletions(-) diff --git a/worklenz-frontend/public/locales/alb/task-list-filters.json b/worklenz-frontend/public/locales/alb/task-list-filters.json index 3ce3c704..bfb76191 100644 --- a/worklenz-frontend/public/locales/alb/task-list-filters.json +++ b/worklenz-frontend/public/locales/alb/task-list-filters.json @@ -68,5 +68,7 @@ "clearing": "Duke pastruar...", "cancel": "Anulo", "search": "Kërko", - "groupedBy": "Grupuar sipas" + "groupedBy": "Grupuar sipas", + "manageStatuses": "Menaxho Statuset", + "managePhases": "Menaxho Fazat" } diff --git a/worklenz-frontend/public/locales/de/task-list-filters.json b/worklenz-frontend/public/locales/de/task-list-filters.json index 9197cb97..8c08037f 100644 --- a/worklenz-frontend/public/locales/de/task-list-filters.json +++ b/worklenz-frontend/public/locales/de/task-list-filters.json @@ -65,8 +65,10 @@ "filtersActive": "Filter aktiv", "filterActive": "Filter aktiv", "clearAll": "Alle löschen", - "clearing": "Löschen...", - "cancel": "Abbrechen", + "clearing": "Wird gelöscht...", + "cancel": "Stornieren", "search": "Suchen", - "groupedBy": "Gruppiert nach" + "groupedBy": "Gruppiert nach", + "manageStatuses": "Status verwalten", + "managePhases": "Phasen verwalten" } diff --git a/worklenz-frontend/public/locales/en/task-list-filters.json b/worklenz-frontend/public/locales/en/task-list-filters.json index 3941b0e0..cdfa00e9 100644 --- a/worklenz-frontend/public/locales/en/task-list-filters.json +++ b/worklenz-frontend/public/locales/en/task-list-filters.json @@ -68,5 +68,7 @@ "clearing": "Clearing...", "cancel": "Cancel", "search": "Search", - "groupedBy": "Grouped by" + "groupedBy": "Grouped by", + "manageStatuses": "Manage Statuses", + "managePhases": "Manage Phases" } diff --git a/worklenz-frontend/public/locales/es/task-list-filters.json b/worklenz-frontend/public/locales/es/task-list-filters.json index 5a1941e0..39aeadb0 100644 --- a/worklenz-frontend/public/locales/es/task-list-filters.json +++ b/worklenz-frontend/public/locales/es/task-list-filters.json @@ -64,5 +64,7 @@ "clearing": "Limpiando...", "cancel": "Cancelar", "search": "Buscar", - "groupedBy": "Agrupado por" + "groupedBy": "Agrupado por", + "manageStatuses": "Gestionar Estados", + "managePhases": "Gestionar Fases" } diff --git a/worklenz-frontend/public/locales/pt/task-list-filters.json b/worklenz-frontend/public/locales/pt/task-list-filters.json index 76e9287f..0bd8bc31 100644 --- a/worklenz-frontend/public/locales/pt/task-list-filters.json +++ b/worklenz-frontend/public/locales/pt/task-list-filters.json @@ -65,5 +65,7 @@ "clearing": "Limpando...", "cancel": "Cancelar", "search": "Pesquisar", - "groupedBy": "Agrupado por" + "groupedBy": "Agrupado por", + "manageStatuses": "Gerenciar Status", + "managePhases": "Gerenciar Fases" } diff --git a/worklenz-frontend/public/locales/zh/task-list-filters.json b/worklenz-frontend/public/locales/zh/task-list-filters.json index a3354305..f6a50e1e 100644 --- a/worklenz-frontend/public/locales/zh/task-list-filters.json +++ b/worklenz-frontend/public/locales/zh/task-list-filters.json @@ -62,5 +62,7 @@ "clearing": "清除中...", "cancel": "取消", "search": "搜索", - "groupedBy": "分组依据" + "groupedBy": "分组依据", + "manageStatuses": "管理状态", + "managePhases": "管理阶段" } \ No newline at end of file diff --git a/worklenz-frontend/src/components/project-task-filters/create-status-drawer/create-status-drawer.tsx b/worklenz-frontend/src/components/project-task-filters/create-status-drawer/create-status-drawer.tsx index 6945e578..f51925cf 100644 --- a/worklenz-frontend/src/components/project-task-filters/create-status-drawer/create-status-drawer.tsx +++ b/worklenz-frontend/src/components/project-task-filters/create-status-drawer/create-status-drawer.tsx @@ -14,7 +14,7 @@ import { toggleDrawer } from '@/features/projects/status/StatusSlice'; import './create-status-drawer.css'; -import { createStatus, fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; +import { createStatus, fetchStatusesCategories, fetchStatuses } from '@/features/taskAttributes/taskStatusSlice'; import { ITaskStatusCategory } from '@/types/status.types'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import useTabSearchParam from '@/hooks/useTabSearchParam'; @@ -56,6 +56,8 @@ const StatusDrawer: React.FC = () => { dispatch(toggleDrawer()); refreshTasks(); dispatch(fetchStatusesCategories()); + // Refetch task statuses to ensure UI reflects the new status + dispatch(fetchStatuses(projectId)); } }; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 0859270b..ac5227fe 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -476,8 +476,8 @@ const TaskListV2Section: React.FC = () => { ); })}
-
-
+
+
{t('noTasksInGroup')}
diff --git a/worklenz-frontend/src/components/task-list-v2/constants/columns.ts b/worklenz-frontend/src/components/task-list-v2/constants/columns.ts index d8b229fe..5f8add14 100644 --- a/worklenz-frontend/src/components/task-list-v2/constants/columns.ts +++ b/worklenz-frontend/src/components/task-list-v2/constants/columns.ts @@ -18,16 +18,16 @@ export const BASE_COLUMNS = [ { id: 'taskKey', label: 'keyColumn', width: '100px', key: COLUMN_KEYS.KEY, minWidth: '100px', maxWidth: '150px' }, { id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME }, { id: 'description', label: 'descriptionColumn', width: '260px', key: COLUMN_KEYS.DESCRIPTION }, - { id: 'status', label: 'statusColumn', width: '120px', key: COLUMN_KEYS.STATUS }, - { id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES }, - { id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY }, - { id: 'dueDate', label: 'dueDateColumn', width: '140px', key: COLUMN_KEYS.DUE_DATE }, { id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS }, + { id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES }, { id: 'labels', label: 'labelsColumn', width: 'auto', key: COLUMN_KEYS.LABELS }, { id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE }, + { id: 'status', label: 'statusColumn', width: '120px', key: COLUMN_KEYS.STATUS }, + { id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY }, { id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING }, { id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION }, { id: 'startDate', label: 'startDateColumn', width: '140px', key: COLUMN_KEYS.START_DATE }, + { id: 'dueDate', label: 'dueDateColumn', width: '140px', key: COLUMN_KEYS.DUE_DATE }, { id: 'dueTime', label: 'dueTimeColumn', width: '120px', key: COLUMN_KEYS.DUE_TIME }, { id: 'completedDate', label: 'completedDateColumn', width: '140px', key: COLUMN_KEYS.COMPLETED_DATE }, { id: 'createdDate', label: 'createdDateColumn', width: '140px', key: COLUMN_KEYS.CREATED_DATE }, diff --git a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx index b14d27e3..6e6669c0 100644 --- a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx +++ b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx @@ -82,7 +82,9 @@ import useIsProjectManager from '@/hooks/useIsProjectManager'; // Performance constants const FILTER_DEBOUNCE_DELAY = 300; // ms const SEARCH_DEBOUNCE_DELAY = 500; // ms -const MAX_FILTER_OPTIONS = 100; // Limit options to prevent UI lag +const MAX_FILTER_OPTIONS = 100; + + // Limit options to prevent UI lag // Optimized selectors with proper transformation logic const selectFilterData = createSelector( @@ -364,6 +366,7 @@ const FilterDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean; className?: string; + dispatch?: any; }> = ({ section, onSelectionChange, @@ -372,6 +375,7 @@ const FilterDropdown: React.FC<{ themeClasses, isDarkMode, className = '', + dispatch, }) => { const { t } = useTranslation('task-list-filters'); // Add permission checks for groupBy section @@ -480,8 +484,34 @@ const FilterDropdown: React.FC<{ {/* Configuration Buttons for GroupBy section */} {section.id === 'groupBy' && canConfigure && (
- {section.selectedValues[0] === 'phase' && } - {section.selectedValues[0] === 'status' && } + {section.selectedValues[0] === 'phase' && ( + + )} + {section.selectedValues[0] === 'status' && ( + + )}
)} @@ -1265,6 +1295,7 @@ const ImprovedTaskFilters: React.FC = ({ position, cla onToggle={() => handleDropdownToggle(section.id)} themeClasses={themeClasses} isDarkMode={isDarkMode} + dispatch={dispatch} /> )) ) : ( diff --git a/worklenz-frontend/src/components/task-templates/import-task-template.tsx b/worklenz-frontend/src/components/task-templates/import-task-template.tsx index 6bfef851..5fb8729a 100644 --- a/worklenz-frontend/src/components/task-templates/import-task-template.tsx +++ b/worklenz-frontend/src/components/task-templates/import-task-template.tsx @@ -23,6 +23,7 @@ import { fetchBoardTaskGroups } from '@/features/board/board-slice'; import { setImportTaskTemplateDrawerOpen } from '@/features/project/project.slice'; import useTabSearchParam from '@/hooks/useTabSearchParam'; import { fetchTaskGroups } from '@/features/tasks/tasks.slice'; +import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; const ImportTaskTemplate = () => { const dispatch = useAppDispatch(); @@ -90,7 +91,8 @@ const ImportTaskTemplate = () => { const res = await taskTemplatesApiService.importTemplate(projectId, tasks); if (res.done) { if (tab === 'board') dispatch(fetchBoardTaskGroups(projectId)); - if (tab === 'tasks-list') dispatch(fetchTaskGroups(projectId)); + if (tab === 'tasks-list') dispatch(fetchTasksV3(projectId)); + dispatch(setImportTaskTemplateDrawerOpen(false)); } } catch (error) { diff --git a/worklenz-frontend/src/hooks/useFilterDataLoader.ts b/worklenz-frontend/src/hooks/useFilterDataLoader.ts index 13a05f38..6165e018 100644 --- a/worklenz-frontend/src/hooks/useFilterDataLoader.ts +++ b/worklenz-frontend/src/hooks/useFilterDataLoader.ts @@ -4,6 +4,7 @@ import { useAppSelector } from '@/hooks/useAppSelector'; import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; import { fetchLabelsByProject, fetchTaskAssignees } from '@/features/tasks/tasks.slice'; import { getTeamMembers } from '@/features/team-members/team-members.slice'; +import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice'; /** * Hook to manage filter data loading independently of main task list loading @@ -15,6 +16,9 @@ export const useFilterDataLoader = () => { // Memoize the priorities selector to prevent unnecessary re-renders const priorities = useAppSelector(state => state.priorityReducer.priorities); + // Memoize the statuses selector to prevent unnecessary re-renders + const statuses = useAppSelector(state => state.taskStatusReducer.status); + // Memoize the projectId selector to prevent unnecessary re-renders const projectId = useAppSelector(state => state.projectReducer.projectId); @@ -32,6 +36,11 @@ export const useFilterDataLoader = () => { // They will update the UI when ready, but won't block initial render dispatch(fetchLabelsByProject(projectId)); dispatch(fetchTaskAssignees(projectId)); + + // Load statuses if not already loaded + if (!statuses.length) { + dispatch(fetchStatuses(projectId)); + } } // Load team members for member filters @@ -49,7 +58,7 @@ export const useFilterDataLoader = () => { console.error('Error loading filter data:', error); // Don't throw - filter loading errors shouldn't break the main UI } - }, [dispatch, priorities.length, projectId]); + }, [dispatch, priorities.length, statuses.length, projectId]); // Load filter data on mount and when dependencies change useEffect(() => { diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx index 4132f0c7..cb4fabae 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx @@ -66,6 +66,7 @@ import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/ import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; import { ShareAltOutlined } from '@ant-design/icons'; +import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice'; const ProjectViewHeader = memo(() => { const navigate = useNavigate(); @@ -101,9 +102,9 @@ const ProjectViewHeader = memo(() => { switch (tab) { case 'tasks-list': + dispatch(fetchStatuses(projectId)); dispatch(fetchTaskListColumns(projectId)); dispatch(fetchPhasesByProjectId(projectId)); - dispatch(fetchTaskGroups(projectId)); dispatch(fetchTasksV3(projectId)); break; case 'board': From cf686ef8c5ba64b8d38d217e14f42486bf92183f Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 10 Jul 2025 18:13:41 +0530 Subject: [PATCH 26/49] feat(task-management): introduce modals for managing phases and statuses - Added CreateTaskModal for task creation with integrated status management. - Implemented ManagePhaseModal and ManageStatusModal for phase and status management, respectively, featuring drag-and-drop functionality. - Enhanced UI with new CSS styles for dark mode and improved accessibility. - Updated filter data loading to include phases and statuses for better task management experience. - Ensured all new components are responsive and align with existing design patterns. --- .../public/locales/alb/phases-drawer.json | 14 +- .../public/locales/alb/task-list-filters.json | 13 +- .../public/locales/de/phases-drawer.json | 14 +- .../public/locales/de/task-list-filters.json | 13 +- .../public/locales/en/phases-drawer.json | 14 +- .../public/locales/en/task-list-filters.json | 13 +- .../public/locales/es/phases-drawer.json | 14 +- .../public/locales/es/task-list-filters.json | 13 +- .../public/locales/pt/phases-drawer.json | 18 +- .../public/locales/pt/task-list-filters.json | 13 +- .../public/locales/zh/phases-drawer.json | 14 +- .../public/locales/zh/task-list-filters.json | 13 +- .../task-management/CreateTaskModal.css | 188 ++++++ .../task-management/CreateTaskModal.tsx | 553 ++++++++++++++++++ .../task-management/ManagePhaseModal.css | 277 +++++++++ .../task-management/ManagePhaseModal.tsx | 519 ++++++++++++++++ .../task-management/ManageStatusModal.css | 249 ++++++++ .../task-management/ManageStatusModal.tsx | 469 +++++++++++++++ .../task-management/improved-task-filters.tsx | 51 +- .../src/hooks/useFilterDataLoader.ts | 23 + 20 files changed, 2467 insertions(+), 28 deletions(-) create mode 100644 worklenz-frontend/src/components/task-management/CreateTaskModal.css create mode 100644 worklenz-frontend/src/components/task-management/CreateTaskModal.tsx create mode 100644 worklenz-frontend/src/components/task-management/ManagePhaseModal.css create mode 100644 worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx create mode 100644 worklenz-frontend/src/components/task-management/ManageStatusModal.css create mode 100644 worklenz-frontend/src/components/task-management/ManageStatusModal.tsx diff --git a/worklenz-frontend/public/locales/alb/phases-drawer.json b/worklenz-frontend/public/locales/alb/phases-drawer.json index de34c740..cccda7d2 100644 --- a/worklenz-frontend/public/locales/alb/phases-drawer.json +++ b/worklenz-frontend/public/locales/alb/phases-drawer.json @@ -3,5 +3,17 @@ "phaseLabel": "Etiketa e Fazës", "enterPhaseName": "Vendosni një emër për etiketën e fazës", "addOption": "Shto Opsion", - "phaseOptions": "Opsionet e Fazës:" + "phaseOptions": "Opsionet e Fazës:", + "dragToReorderPhases": "Zvarrit fazat për t'i rirenditur. Çdo fazë mund të ketë një ngjyrë të ndryshme.", + "enterNewPhaseName": "Shkruani emrin e fazës së re...", + "addPhase": "Shto Fazë", + "noPhasesFound": "Nuk u gjetën faza. Krijoni fazën tuaj të parë më sipër.", + "deletePhase": "Fshi Fazën", + "deletePhaseConfirm": "Jeni të sigurt që doni të fshini këtë fazë? Ky veprim nuk mund të zhbëhet.", + "rename": "Riemëro", + "delete": "Fshi", + "enterPhaseName": "Shkruani emrin e fazës", + "selectColor": "Zgjidh ngjyrën", + "managePhases": "Menaxho Fazat", + "close": "Mbyll" } diff --git a/worklenz-frontend/public/locales/alb/task-list-filters.json b/worklenz-frontend/public/locales/alb/task-list-filters.json index bfb76191..c3156498 100644 --- a/worklenz-frontend/public/locales/alb/task-list-filters.json +++ b/worklenz-frontend/public/locales/alb/task-list-filters.json @@ -70,5 +70,16 @@ "search": "Kërko", "groupedBy": "Grupuar sipas", "manageStatuses": "Menaxho Statuset", - "managePhases": "Menaxho Fazat" + "managePhases": "Menaxho Fazat", + "dragToReorderStatuses": "Zvarrit statuset për t'i rirenditur. Çdo status mund të ketë një kategori të ndryshme.", + "enterNewStatusName": "Shkruani emrin e statusit të ri...", + "addStatus": "Shto Status", + "noStatusesFound": "Nuk u gjetën statuse. Krijoni statusin tuaj të parë më sipër.", + "deleteStatus": "Fshi Statusin", + "deleteStatusConfirm": "Jeni të sigurt që doni të fshini këtë status? Ky veprim nuk mund të zhbëhet.", + "rename": "Riemëro", + "delete": "Fshi", + "enterStatusName": "Shkruani emrin e statusit", + "selectCategory": "Zgjidh kategorinë", + "close": "Mbyll" } diff --git a/worklenz-frontend/public/locales/de/phases-drawer.json b/worklenz-frontend/public/locales/de/phases-drawer.json index d06e3d05..c9e41e09 100644 --- a/worklenz-frontend/public/locales/de/phases-drawer.json +++ b/worklenz-frontend/public/locales/de/phases-drawer.json @@ -3,5 +3,17 @@ "phaseLabel": "Phasenbezeichnung", "enterPhaseName": "Namen für Phasenbezeichnung eingeben", "addOption": "Option hinzufügen", - "phaseOptions": "Phasenoptionen:" + "phaseOptions": "Phasenoptionen:", + "dragToReorderPhases": "Ziehen Sie Phasen, um sie neu zu ordnen. Jede Phase kann eine andere Farbe haben.", + "enterNewPhaseName": "Neuen Phasennamen eingeben...", + "addPhase": "Phase hinzufügen", + "noPhasesFound": "Keine Phasen gefunden. Erstellen Sie Ihre erste Phase oben.", + "deletePhase": "Phase löschen", + "deletePhaseConfirm": "Sind Sie sicher, dass Sie diese Phase löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "rename": "Umbenennen", + "delete": "Löschen", + "enterPhaseName": "Phasennamen eingeben", + "selectColor": "Farbe auswählen", + "managePhases": "Phasen verwalten", + "close": "Schließen" } diff --git a/worklenz-frontend/public/locales/de/task-list-filters.json b/worklenz-frontend/public/locales/de/task-list-filters.json index 8c08037f..0854c34f 100644 --- a/worklenz-frontend/public/locales/de/task-list-filters.json +++ b/worklenz-frontend/public/locales/de/task-list-filters.json @@ -70,5 +70,16 @@ "search": "Suchen", "groupedBy": "Gruppiert nach", "manageStatuses": "Status verwalten", - "managePhases": "Phasen verwalten" + "managePhases": "Phasen verwalten", + "dragToReorderStatuses": "Ziehen Sie Status, um sie neu zu ordnen. Jeder Status kann eine andere Kategorie haben.", + "enterNewStatusName": "Neuen Statusnamen eingeben...", + "addStatus": "Status hinzufügen", + "noStatusesFound": "Keine Status gefunden. Erstellen Sie Ihren ersten Status oben.", + "deleteStatus": "Status löschen", + "deleteStatusConfirm": "Sind Sie sicher, dass Sie diesen Status löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "rename": "Umbenennen", + "delete": "Löschen", + "enterStatusName": "Statusnamen eingeben", + "selectCategory": "Kategorie auswählen", + "close": "Schließen" } diff --git a/worklenz-frontend/public/locales/en/phases-drawer.json b/worklenz-frontend/public/locales/en/phases-drawer.json index ca870b8f..10ad78a4 100644 --- a/worklenz-frontend/public/locales/en/phases-drawer.json +++ b/worklenz-frontend/public/locales/en/phases-drawer.json @@ -3,5 +3,17 @@ "phaseLabel": "Phase Label", "enterPhaseName": "Enter a name for phase label", "addOption": "Add Option", - "phaseOptions": "Phase Options:" + "phaseOptions": "Phase Options:", + "dragToReorderPhases": "Drag phases to reorder them. Each phase can have a different color.", + "enterNewPhaseName": "Enter new phase name...", + "addPhase": "Add Phase", + "noPhasesFound": "No phases found. Create your first phase above.", + "deletePhase": "Delete Phase", + "deletePhaseConfirm": "Are you sure you want to delete this phase? This action cannot be undone.", + "rename": "Rename", + "delete": "Delete", + "enterPhaseName": "Enter phase name", + "selectColor": "Select color", + "managePhases": "Manage Phases", + "close": "Close" } diff --git a/worklenz-frontend/public/locales/en/task-list-filters.json b/worklenz-frontend/public/locales/en/task-list-filters.json index cdfa00e9..a38356c6 100644 --- a/worklenz-frontend/public/locales/en/task-list-filters.json +++ b/worklenz-frontend/public/locales/en/task-list-filters.json @@ -70,5 +70,16 @@ "search": "Search", "groupedBy": "Grouped by", "manageStatuses": "Manage Statuses", - "managePhases": "Manage Phases" + "managePhases": "Manage Phases", + "dragToReorderStatuses": "Drag statuses to reorder them. Each status can have a different category.", + "enterNewStatusName": "Enter new status name...", + "addStatus": "Add Status", + "noStatusesFound": "No statuses found. Create your first status above.", + "deleteStatus": "Delete Status", + "deleteStatusConfirm": "Are you sure you want to delete this status? This action cannot be undone.", + "rename": "Rename", + "delete": "Delete", + "enterStatusName": "Enter status name", + "selectCategory": "Select category", + "close": "Close" } diff --git a/worklenz-frontend/public/locales/es/phases-drawer.json b/worklenz-frontend/public/locales/es/phases-drawer.json index 6339389a..e961b068 100644 --- a/worklenz-frontend/public/locales/es/phases-drawer.json +++ b/worklenz-frontend/public/locales/es/phases-drawer.json @@ -3,5 +3,17 @@ "phaseLabel": "Etiqueta de fase", "enterPhaseName": "Ingrese un nombre para la etiqueta de fase", "addOption": "Agregar opción", - "phaseOptions": "Opciones de fase:" + "phaseOptions": "Opciones de fase:", + "dragToReorderPhases": "Arrastra las fases para reordenarlas. Cada fase puede tener un color diferente.", + "enterNewPhaseName": "Introducir nuevo nombre de fase...", + "addPhase": "Añadir Fase", + "noPhasesFound": "No se encontraron fases. Crea tu primera fase arriba.", + "deletePhase": "Eliminar Fase", + "deletePhaseConfirm": "¿Estás seguro de que quieres eliminar esta fase? Esta acción no se puede deshacer.", + "rename": "Renombrar", + "delete": "Eliminar", + "enterPhaseName": "Introducir nombre de la fase", + "selectColor": "Seleccionar color", + "managePhases": "Gestionar Fases", + "close": "Cerrar" } diff --git a/worklenz-frontend/public/locales/es/task-list-filters.json b/worklenz-frontend/public/locales/es/task-list-filters.json index 39aeadb0..465368f0 100644 --- a/worklenz-frontend/public/locales/es/task-list-filters.json +++ b/worklenz-frontend/public/locales/es/task-list-filters.json @@ -66,5 +66,16 @@ "search": "Buscar", "groupedBy": "Agrupado por", "manageStatuses": "Gestionar Estados", - "managePhases": "Gestionar Fases" + "managePhases": "Gestionar Fases", + "dragToReorderStatuses": "Arrastra los estados para reordenarlos. Cada estado puede tener una categoría diferente.", + "enterNewStatusName": "Introducir nuevo nombre de estado...", + "addStatus": "Añadir Estado", + "noStatusesFound": "No se encontraron estados. Crea tu primer estado arriba.", + "deleteStatus": "Eliminar Estado", + "deleteStatusConfirm": "¿Estás seguro de que quieres eliminar este estado? Esta acción no se puede deshacer.", + "rename": "Renombrar", + "delete": "Eliminar", + "enterStatusName": "Introducir nombre del estado", + "selectCategory": "Seleccionar categoría", + "close": "Cerrar" } diff --git a/worklenz-frontend/public/locales/pt/phases-drawer.json b/worklenz-frontend/public/locales/pt/phases-drawer.json index 6339389a..080b13df 100644 --- a/worklenz-frontend/public/locales/pt/phases-drawer.json +++ b/worklenz-frontend/public/locales/pt/phases-drawer.json @@ -1,7 +1,19 @@ { "configurePhases": "Configurar fases", "phaseLabel": "Etiqueta de fase", - "enterPhaseName": "Ingrese un nombre para la etiqueta de fase", - "addOption": "Agregar opción", - "phaseOptions": "Opciones de fase:" + "enterPhaseName": "Digite um nome para o rótulo da fase", + "addOption": "Adicionar Opção", + "phaseOptions": "Opções de Fase:", + "dragToReorderPhases": "Arraste as fases para reordená-las. Cada fase pode ter uma cor diferente.", + "enterNewPhaseName": "Digite o novo nome da fase...", + "addPhase": "Adicionar Fase", + "noPhasesFound": "Nenhuma fase encontrada. Crie sua primeira fase acima.", + "deletePhase": "Excluir Fase", + "deletePhaseConfirm": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.", + "rename": "Renomear", + "delete": "Excluir", + "enterPhaseName": "Digite o nome da fase", + "selectColor": "Selecionar cor", + "managePhases": "Gerenciar Fases", + "close": "Fechar" } diff --git a/worklenz-frontend/public/locales/pt/task-list-filters.json b/worklenz-frontend/public/locales/pt/task-list-filters.json index 0bd8bc31..21e8806b 100644 --- a/worklenz-frontend/public/locales/pt/task-list-filters.json +++ b/worklenz-frontend/public/locales/pt/task-list-filters.json @@ -67,5 +67,16 @@ "search": "Pesquisar", "groupedBy": "Agrupado por", "manageStatuses": "Gerenciar Status", - "managePhases": "Gerenciar Fases" + "managePhases": "Gerenciar Fases", + "dragToReorderStatuses": "Arraste os status para reordená-los. Cada status pode ter uma categoria diferente.", + "enterNewStatusName": "Digite o novo nome do status...", + "addStatus": "Adicionar Status", + "noStatusesFound": "Nenhum status encontrado. Crie seu primeiro status acima.", + "deleteStatus": "Excluir Status", + "deleteStatusConfirm": "Tem certeza de que deseja excluir este status? Esta ação não pode ser desfeita.", + "rename": "Renomear", + "delete": "Excluir", + "enterStatusName": "Digite o nome do status", + "selectCategory": "Selecionar categoria", + "close": "Fechar" } diff --git a/worklenz-frontend/public/locales/zh/phases-drawer.json b/worklenz-frontend/public/locales/zh/phases-drawer.json index 4bfb2a13..24d21b38 100644 --- a/worklenz-frontend/public/locales/zh/phases-drawer.json +++ b/worklenz-frontend/public/locales/zh/phases-drawer.json @@ -3,5 +3,17 @@ "phaseLabel": "阶段标签", "enterPhaseName": "输入阶段标签名称", "addOption": "添加选项", - "phaseOptions": "阶段选项:" + "phaseOptions": "阶段选项:", + "dragToReorderPhases": "拖拽阶段以重新排序。每个阶段可以有不同的颜色。", + "enterNewPhaseName": "输入新阶段名称...", + "addPhase": "添加阶段", + "noPhasesFound": "未找到阶段。请在上面创建您的第一个阶段。", + "deletePhase": "删除阶段", + "deletePhaseConfirm": "您确定要删除此阶段吗?此操作无法撤销。", + "rename": "重命名", + "delete": "删除", + "enterPhaseName": "输入阶段名称", + "selectColor": "选择颜色", + "managePhases": "管理阶段", + "close": "关闭" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/task-list-filters.json b/worklenz-frontend/public/locales/zh/task-list-filters.json index f6a50e1e..84387509 100644 --- a/worklenz-frontend/public/locales/zh/task-list-filters.json +++ b/worklenz-frontend/public/locales/zh/task-list-filters.json @@ -64,5 +64,16 @@ "search": "搜索", "groupedBy": "分组依据", "manageStatuses": "管理状态", - "managePhases": "管理阶段" + "managePhases": "管理阶段", + "dragToReorderStatuses": "拖拽状态以重新排序。每个状态可以有不同的类别。", + "enterNewStatusName": "输入新状态名称...", + "addStatus": "添加状态", + "noStatusesFound": "未找到状态。请在上面创建您的第一个状态。", + "deleteStatus": "删除状态", + "deleteStatusConfirm": "您确定要删除此状态吗?此操作无法撤销。", + "rename": "重命名", + "delete": "删除", + "enterStatusName": "输入状态名称", + "selectCategory": "选择类别", + "close": "关闭" } \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/CreateTaskModal.css b/worklenz-frontend/src/components/task-management/CreateTaskModal.css new file mode 100644 index 00000000..481e986c --- /dev/null +++ b/worklenz-frontend/src/components/task-management/CreateTaskModal.css @@ -0,0 +1,188 @@ +/* CreateTaskModal.css */ + +/* Dark mode modal styling */ +.dark-modal .ant-modal-content { + background-color: #1f1f1f; + border: 1px solid #303030; +} + +.dark-modal .ant-modal-header { + background-color: #1f1f1f; + border-bottom: 1px solid #303030; +} + +.dark-modal .ant-modal-body { + background-color: #1f1f1f; +} + +.dark-modal .ant-modal-footer { + background-color: #1f1f1f; + border-top: 1px solid #303030; +} + +.dark-modal .ant-tabs-content-holder { + background-color: #1f1f1f; +} + +.dark-modal .ant-tabs-tab { + color: #d9d9d9; +} + +.dark-modal .ant-tabs-tab-active { + color: #ffffff; +} + +.dark-modal .ant-form-item-label > label { + color: #d9d9d9; +} + +.dark-modal .ant-input, +.dark-modal .ant-select-selector, +.dark-modal .ant-picker { + background-color: #141414; + border-color: #303030; + color: #d9d9d9; +} + +.dark-modal .ant-input::placeholder, +.dark-modal .ant-select-selection-placeholder { + color: #8c8c8c; +} + +.dark-modal .ant-select-dropdown { + background-color: #1f1f1f; + border-color: #303030; +} + +.dark-modal .ant-select-item { + color: #d9d9d9; +} + +.dark-modal .ant-select-item-option-selected { + background-color: #262626; +} + +.dark-modal .ant-select-item:hover { + background-color: #262626; +} + +/* Status management section styling */ +.status-item { + transition: all 0.2s ease; +} + +.status-item:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.dark-modal .status-item:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +/* Drag handle styling */ +.drag-handle { + cursor: grab; + opacity: 0.6; + transition: opacity 0.2s ease; +} + +.drag-handle:hover { + opacity: 1; +} + +.drag-handle:active { + cursor: grabbing; +} + +/* Status color indicator */ +.status-color { + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); +} + +.dark-modal .status-color { + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1); +} + +/* Sortable item styling */ +.sortable-status-item { + transition: transform 0.2s ease, opacity 0.2s ease; +} + +.sortable-status-item.is-dragging { + transform: scale(1.02); + z-index: 999; + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); +} + +.dark-modal .sortable-status-item.is-dragging { + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4); +} + +/* Form styling improvements */ +.dark-modal .ant-form-item-required::before { + color: #ff4d4f; +} + +.dark-modal .ant-btn-primary { + background-color: #1890ff; + border-color: #1890ff; +} + +.dark-modal .ant-btn-primary:hover { + background-color: #40a9ff; + border-color: #40a9ff; +} + +.dark-modal .ant-btn-text:hover { + background-color: #262626; +} + +/* Responsive design */ +@media (max-width: 768px) { + .dark-modal .ant-modal { + width: 95% !important; + margin: 10px; + } + + .dark-modal .ant-modal-body { + padding: 16px; + } + + .status-item { + padding: 12px; + } +} + +/* Accessibility improvements */ +.drag-handle:focus { + outline: 2px solid #1890ff; + outline-offset: 2px; +} + +.dark-modal .drag-handle:focus { + outline-color: #40a9ff; +} + +/* Animation for status creation */ +.status-item-enter { + opacity: 0; + transform: translateY(-10px); +} + +.status-item-enter-active { + opacity: 1; + transform: translateY(0); + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.status-item-exit { + opacity: 1; + transform: translateY(0); +} + +.status-item-exit-active { + opacity: 0; + transform: translateY(-10px); + transition: opacity 0.3s ease, transform 0.3s ease; +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/CreateTaskModal.tsx b/worklenz-frontend/src/components/task-management/CreateTaskModal.tsx new file mode 100644 index 00000000..c69aad5d --- /dev/null +++ b/worklenz-frontend/src/components/task-management/CreateTaskModal.tsx @@ -0,0 +1,553 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { Modal, Form, Input, Button, Tabs, Space, Divider, Typography, Flex, DatePicker, Select } from 'antd'; +import { PlusOutlined, DragOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import TaskDetailsForm from '@/components/task-drawer/shared/info-tab/task-details-form'; +import AssigneeSelector from '@/components/AssigneeSelector'; +import LabelsSelector from '@/components/LabelsSelector'; +import { createStatus, fetchStatuses, fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; +import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; +import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types'; +import { Modal as AntModal } from 'antd'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { useAuthService } from '@/hooks/useAuth'; +import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; +import './CreateTaskModal.css'; + +const { Title, Text } = Typography; +const { TabPane } = Tabs; + +interface CreateTaskModalProps { + open: boolean; + onClose: () => void; + projectId?: string; +} + +interface StatusItemProps { + status: any; + onRename: (id: string, name: string) => void; + onDelete: (id: string) => void; + isDarkMode: boolean; +} + +// Sortable Status Item Component +const SortableStatusItem: React.FC = ({ + id, + status, + onRename, + onDelete, + isDarkMode, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(status.name || ''); + const inputRef = useRef(null); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const handleSave = useCallback(() => { + if (editName.trim() && editName.trim() !== status.name) { + onRename(id, editName.trim()); + } + setIsEditing(false); + }, [editName, id, onRename, status.name]); + + const handleCancel = useCallback(() => { + setEditName(status.name || ''); + setIsEditing(false); + }, [status.name]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSave(); + } else if (e.key === 'Escape') { + handleCancel(); + } + }, [handleSave, handleCancel]); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + return ( +
+ {/* Drag Handle */} +
+ +
+ + {/* Status Color */} +
+ + {/* Status Name */} +
+ {isEditing ? ( + setEditName(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + size="small" + className="font-medium" + /> + ) : ( + setIsEditing(true)} + > + {status.name} + + )} +
+ + {/* Actions */} + + + + +
+ ); +}; + +// Status Management Component +const StatusManagement: React.FC<{ + projectId: string; + isDarkMode: boolean; +}> = ({ projectId, isDarkMode }) => { + const { t } = useTranslation('task-list-filters'); + const dispatch = useAppDispatch(); + + const { status: statuses } = useAppSelector(state => state.taskStatusReducer); + const [localStatuses, setLocalStatuses] = useState(statuses); + const [newStatusName, setNewStatusName] = useState(''); + + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + useEffect(() => { + setLocalStatuses(statuses); + }, [statuses]); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) { + return; + } + + setLocalStatuses((items) => { + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over.id); + + if (oldIndex === -1 || newIndex === -1) return items; + + const newItems = [...items]; + const [movedItem] = newItems.splice(oldIndex, 1); + newItems.splice(newIndex, 0, movedItem); + + // Update status order via API (fire and forget) + const columnOrder = newItems.map(item => item.id).filter(Boolean) as string[]; + const requestBody = { status_order: columnOrder }; + statusApiService.updateStatusOrder(requestBody, projectId).catch(error => { + console.error('Error updating status order:', error); + }); + + return newItems; + }); + }, []); + + const handleCreateStatus = useCallback(async () => { + if (!newStatusName.trim()) return; + + try { + const statusCategories = await dispatch(fetchStatusesCategories()).unwrap(); + const defaultCategory = statusCategories[0]?.id; + + if (!defaultCategory) { + console.error('No status categories found'); + return; + } + + const body = { + name: newStatusName.trim(), + category_id: defaultCategory, + project_id: projectId, + }; + + const res = await dispatch(createStatus({ body, currentProjectId: projectId })).unwrap(); + if (res.done) { + setNewStatusName(''); + dispatch(fetchStatuses(projectId)); + } + } catch (error) { + console.error('Error creating status:', error); + } + }, [newStatusName, projectId, dispatch]); + + const handleRenameStatus = useCallback(async (id: string, name: string) => { + try { + const body: ITaskStatusUpdateModel = { + name: name.trim(), + project_id: projectId, + }; + + await statusApiService.updateNameOfStatus(id, body, projectId); + dispatch(fetchStatuses(projectId)); + } catch (error) { + console.error('Error renaming status:', error); + } + }, [projectId, dispatch]); + + const handleDeleteStatus = useCallback(async (id: string) => { + AntModal.confirm({ + title: 'Delete Status', + content: 'Are you sure you want to delete this status? This action cannot be undone.', + onOk: async () => { + try { + const replacingStatusId = localStatuses.find(s => s.id !== id)?.id || ''; + await statusApiService.deleteStatus(id, projectId, replacingStatusId); + dispatch(fetchStatuses(projectId)); + } catch (error) { + console.error('Error deleting status:', error); + } + }, + }); + }, [localStatuses, projectId, dispatch]); + + return ( +
+
+ + {t('manageStatuses')} + + + Drag to reorder + +
+ + {/* Create New Status */} +
+ setNewStatusName(e.target.value)} + onPressEnter={handleCreateStatus} + className="flex-1" + /> + +
+ + + + {/* Status List with Drag & Drop */} + + status.id).map(status => status.id as string)} + strategy={verticalListSortingStrategy} + > +
+ {localStatuses.filter(status => status.id).map((status) => ( + + ))} +
+
+
+ + {localStatuses.length === 0 && ( +
+ No statuses found. Create your first status above. +
+ )} +
+ ); +}; + +const CreateTaskModal: React.FC = ({ + open, + onClose, + projectId, +}) => { + const { t } = useTranslation('task-drawer/task-drawer'); + const [form] = Form.useForm(); + const [activeTab, setActiveTab] = useState('task-info'); + const dispatch = useAppDispatch(); + + // Redux state + const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark'); + const currentProjectId = useAppSelector(state => state.projectReducer.projectId); + const user = useAppSelector(state => state.auth?.user); + + const finalProjectId = projectId || currentProjectId; + + const handleSubmit = useCallback(async () => { + try { + const values = await form.validateFields(); + + const { socket } = useSocket(); + + if (!socket || !user || !finalProjectId) { + console.error('Missing socket, user, or project ID'); + return; + } + + const taskData = { + name: values.name, + description: values.description || null, + project_id: finalProjectId, + status_id: values.status || null, + priority_id: values.priority || null, + assignees: values.assignees || [], + due_date: values.dueDate ? values.dueDate.format('YYYY-MM-DD') : null, + reporter_id: user.id, + }; + + // Create task via socket + socket.emit(SocketEvents.QUICK_TASK.toString(), taskData); + + // Refresh task list + dispatch(fetchTasksV3(finalProjectId)); + + // Reset form and close modal + form.resetFields(); + setActiveTab('task-info'); + onClose(); + + } catch (error) { + console.error('Form validation failed:', error); + } + }, [form, finalProjectId, dispatch, onClose]); + + const handleCancel = useCallback(() => { + form.resetFields(); + setActiveTab('task-info'); + onClose(); + }, [form, onClose]); + + return ( + + {t('createTask')} + + } + open={open} + onCancel={handleCancel} + width={800} + style={{ top: 20 }} + bodyStyle={{ + maxHeight: 'calc(100vh - 200px)', + overflowY: 'auto', + padding: '24px', + }} + footer={ + +
+ + + + +
+ } + className={isDarkMode ? 'dark-modal' : ''} + > + +
+ + + + + + + + + {/* Status Selection */} + + + + + {/* Priority Selection */} + + + + + {/* Assignees */} + + + + + {/* Due Date */} + + + + +
+
+ ), + }, + { + key: 'status-management', + label: t('manageStatuses'), + children: finalProjectId ? ( +
+ +
+ ) : ( +
+ Project ID is required for status management. +
+ ), + }, + ]} + /> + + ); +}; + +export default CreateTaskModal; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/ManagePhaseModal.css b/worklenz-frontend/src/components/task-management/ManagePhaseModal.css new file mode 100644 index 00000000..e36bcd73 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/ManagePhaseModal.css @@ -0,0 +1,277 @@ +/* ManagePhaseModal.css */ + +/* Dark mode modal styling */ +.dark-modal .ant-modal-content { + background-color: #1f1f1f; + border: 1px solid #303030; +} + +.dark-modal .ant-modal-header { + background-color: #1f1f1f; + border-bottom: 1px solid #303030; +} + +.dark-modal .ant-modal-body { + background-color: #1f1f1f; +} + +.dark-modal .ant-modal-footer { + background-color: #1f1f1f; + border-top: 1px solid #303030; +} + +.dark-modal .ant-form-item-label > label { + color: #d9d9d9; +} + +.dark-modal .ant-input, +.dark-modal .ant-select-selector { + background-color: #141414; + border-color: #303030; + color: #d9d9d9; +} + +.dark-modal .ant-input::placeholder { + color: #8c8c8c; +} + +.dark-modal .ant-btn-primary { + background-color: #1890ff; + border-color: #1890ff; +} + +.dark-modal .ant-btn-primary:hover { + background-color: #40a9ff; + border-color: #40a9ff; +} + +.dark-modal .ant-btn-text:hover { + background-color: #262626; +} + +/* Color picker styling */ +.dark-modal .ant-color-picker-trigger { + background-color: #141414; + border-color: #303030; +} + +.dark-modal .ant-color-picker-trigger:hover { + border-color: #40a9ff; +} + +.dark-modal .ant-color-picker-panel { + background-color: #1f1f1f; + border-color: #303030; +} + +/* Phase management section styling */ +.phase-item { + transition: all 0.2s ease; +} + +.phase-item:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1); +} + +.dark-modal .phase-item:hover { + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); +} + +/* Enhanced phase card styling */ +.phase-card { + border-radius: 8px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.phase-card:hover { + transform: translateY(-1px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12); +} + +.dark-modal .phase-card:hover { + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4); +} + +/* Drag handle styling */ +.drag-handle { + cursor: grab; + opacity: 0.6; + transition: opacity 0.2s ease; +} + +.drag-handle:hover { + opacity: 1; +} + +.drag-handle:active { + cursor: grabbing; +} + +/* Phase color picker styling */ +.phase-color-picker { + border-radius: 6px; + overflow: hidden; + transition: all 0.2s ease; +} + +.phase-color-picker:hover { + transform: scale(1.05); +} + +.dark-modal .phase-color-picker { + border-color: #303030; +} + +.dark-modal .phase-color-picker:hover { + border-color: #40a9ff; +} + +/* Improved drag handle */ +.drag-handle { + cursor: grab; + opacity: 0.7; + transition: all 0.2s ease; + border-radius: 6px; +} + +.drag-handle:hover { + opacity: 1; + background-color: rgba(0, 0, 0, 0.05); +} + +.dark-modal .drag-handle:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.drag-handle:active { + cursor: grabbing; + transform: scale(0.95); +} + +/* Phase color dot enhancement */ +.phase-color-dot { + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.phase-color-dot:hover { + transform: scale(1.1); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.dark-modal .phase-color-dot { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.dark-modal .phase-color-dot:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); +} + +/* Sortable item styling */ +.sortable-phase-item { + transition: transform 0.2s ease, opacity 0.2s ease; +} + +.sortable-phase-item.is-dragging { + transform: scale(1.02); + z-index: 999; + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); +} + +.dark-modal .sortable-phase-item.is-dragging { + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4); +} + +/* Phase label input styling */ +.phase-label-input { + margin-bottom: 16px; +} + +.dark-modal .phase-label-input .ant-input { + background-color: #141414; + border-color: #303030; + color: #d9d9d9; +} + +.dark-modal .phase-label-input .ant-input:focus { + border-color: #40a9ff; + box-shadow: 0 0 0 2px rgba(64, 169, 255, 0.2); +} + +/* Responsive design */ +@media (max-width: 768px) { + .dark-modal .ant-modal { + width: 95% !important; + margin: 10px; + } + + .dark-modal .ant-modal-body { + padding: 16px; + } + + .phase-item { + padding: 12px; + } + + .phase-color-picker { + width: 24px; + height: 24px; + } +} + +/* Accessibility improvements */ +.drag-handle:focus { + outline: 2px solid #1890ff; + outline-offset: 2px; +} + +.dark-modal .drag-handle:focus { + outline-color: #40a9ff; +} + +.phase-color-picker:focus { + outline: 2px solid #1890ff; + outline-offset: 2px; +} + +.dark-modal .phase-color-picker:focus { + outline-color: #40a9ff; +} + +/* Animation for phase creation */ +.phase-item-enter { + opacity: 0; + transform: translateY(-10px); +} + +.phase-item-enter-active { + opacity: 1; + transform: translateY(0); + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.phase-item-exit { + opacity: 1; + transform: translateY(0); +} + +.phase-item-exit-active { + opacity: 0; + transform: translateY(-10px); + transition: opacity 0.3s ease, transform 0.3s ease; +} + +/* Loading state styling */ +.dark-modal .ant-spin-container { + background-color: transparent; +} + +.dark-modal .ant-spin-dot-item { + background-color: #40a9ff; +} + +/* Divider styling for dark mode */ +.dark-modal .ant-divider-horizontal { + border-color: #303030; +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx b/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx new file mode 100644 index 00000000..0e10d437 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx @@ -0,0 +1,519 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { Modal, Form, Input, Button, Space, Divider, Typography, Flex, ColorPicker } from 'antd'; +import { PlusOutlined, HolderOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { + addPhaseOption, + fetchPhasesByProjectId, + updatePhaseOrder, + updatePhaseListOrder, + updateProjectPhaseLabel, + updatePhaseName, + deletePhaseOption, + updatePhaseColor, +} from '@/features/projects/singleProject/phase/phases.slice'; +import { ITaskPhase } from '@/types/tasks/taskPhase.types'; +import { Modal as AntModal } from 'antd'; +import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; +import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { PhaseColorCodes } from '@/shared/constants'; +import './ManagePhaseModal.css'; + +const { Title, Text } = Typography; + +interface ManagePhaseModalProps { + open: boolean; + onClose: () => void; + projectId?: string; +} + +interface PhaseItemProps { + phase: ITaskPhase; + onRename: (id: string, name: string) => void; + onDelete: (id: string) => void; + onColorChange: (id: string, color: string) => void; + isDarkMode: boolean; +} + +// Sortable Phase Item Component +const SortablePhaseItem: React.FC = ({ + id, + phase, + onRename, + onDelete, + onColorChange, + isDarkMode, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(phase.name || ''); + const [color, setColor] = useState(phase.color_code || PhaseColorCodes[0]); + const inputRef = useRef(null); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const handleSave = useCallback(() => { + if (editName.trim() && editName.trim() !== phase.name) { + onRename(id, editName.trim()); + } + setIsEditing(false); + }, [editName, id, onRename, phase.name]); + + const handleCancel = useCallback(() => { + setEditName(phase.name || ''); + setIsEditing(false); + }, [phase.name]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSave(); + } else if (e.key === 'Escape') { + handleCancel(); + } + }, [handleSave, handleCancel]); + + const handleColorChangeComplete = useCallback(() => { + if (color !== phase.color_code) { + onColorChange(id, color); + } + }, [color, id, onColorChange, phase.color_code]); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + useEffect(() => { + setColor(phase.color_code || PhaseColorCodes[0]); + }, [phase.color_code]); + + return ( +
+ {/* Header Row - Drag Handle, Phase Name, Actions */} +
+ {/* Drag Handle */} +
+ +
+ + {/* Phase Name */} +
+ {isEditing ? ( + setEditName(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + className="font-medium" + placeholder="Enter phase name" + /> + ) : ( + setIsEditing(true)} + > + {phase.name} + + )} +
+ + {/* Actions */} + + + + +
+ + {/* Color Row */} +
+ + Color: + + setColor(value.toHexString())} + onChangeComplete={handleColorChangeComplete} + size="small" + className="phase-color-picker" + /> +
+
+
+ ); +}; + +const ManagePhaseModal: React.FC = ({ + open, + onClose, + projectId, +}) => { + const { t } = useTranslation('phases-drawer'); + const dispatch = useAppDispatch(); + + // Redux state + const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark'); + const currentProjectId = useAppSelector(state => state.projectReducer.projectId); + const { project } = useAppSelector(state => state.projectReducer); + const { phaseList, loadingPhases } = useAppSelector(state => state.phaseReducer); + + const [phaseName, setPhaseName] = useState(project?.phase_label || ''); + const [initialPhaseName, setInitialPhaseName] = useState(project?.phase_label || ''); + const [sorting, setSorting] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const finalProjectId = projectId || currentProjectId; + + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + useEffect(() => { + if (open && finalProjectId) { + dispatch(fetchPhasesByProjectId(finalProjectId)); + setPhaseName(project?.phase_label || ''); + setInitialPhaseName(project?.phase_label || ''); + } + }, [open, finalProjectId, project?.phase_label, dispatch]); + + const refreshTasks = useCallback(async () => { + if (finalProjectId) { + await dispatch(fetchTasksV3(finalProjectId)); + await dispatch(fetchEnhancedKanbanGroups(finalProjectId)); + } + }, [finalProjectId, dispatch]); + + const handleDragEnd = useCallback(async (event: DragEndEvent) => { + if (!finalProjectId) return; + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = phaseList.findIndex(item => item.id === active.id); + const newIndex = phaseList.findIndex(item => item.id === over.id); + + const newPhaseList = [...phaseList]; + const [movedItem] = newPhaseList.splice(oldIndex, 1); + newPhaseList.splice(newIndex, 0, movedItem); + + try { + setSorting(true); + dispatch(updatePhaseListOrder(newPhaseList)); + + const body = { + from_index: oldIndex, + to_index: newIndex, + phases: newPhaseList, + project_id: finalProjectId, + }; + + await dispatch(updatePhaseOrder({ projectId: finalProjectId, body })).unwrap(); + await refreshTasks(); + } catch (error) { + dispatch(fetchPhasesByProjectId(finalProjectId)); + console.error('Error updating phase order', error); + } finally { + setSorting(false); + } + } + }, [finalProjectId, phaseList, dispatch, refreshTasks]); + + const handleAddPhase = useCallback(async () => { + if (!finalProjectId) return; + + try { + await dispatch(addPhaseOption({ projectId: finalProjectId })); + await dispatch(fetchPhasesByProjectId(finalProjectId)); + await refreshTasks(); + } catch (error) { + console.error('Error adding phase:', error); + } + }, [finalProjectId, dispatch, refreshTasks]); + + const handleRenamePhase = useCallback(async (id: string, name: string) => { + if (!finalProjectId) return; + + try { + const phase = phaseList.find(p => p.id === id); + if (!phase) return; + + const updatedPhase = { ...phase, name: name.trim() }; + const response = await dispatch( + updatePhaseName({ + phaseId: id, + phase: updatedPhase, + projectId: finalProjectId, + }) + ).unwrap(); + + if (response.done) { + dispatch(fetchPhasesByProjectId(finalProjectId)); + await refreshTasks(); + } + } catch (error) { + console.error('Error renaming phase:', error); + } + }, [finalProjectId, phaseList, dispatch, refreshTasks]); + + const handleDeletePhase = useCallback(async (id: string) => { + if (!finalProjectId) return; + + AntModal.confirm({ + title: 'Delete Phase', + content: 'Are you sure you want to delete this phase? This action cannot be undone.', + onOk: async () => { + try { + const response = await dispatch( + deletePhaseOption({ phaseOptionId: id, projectId: finalProjectId }) + ).unwrap(); + + if (response.done) { + dispatch(fetchPhasesByProjectId(finalProjectId)); + await refreshTasks(); + } + } catch (error) { + console.error('Error deleting phase:', error); + } + }, + }); + }, [finalProjectId, dispatch, refreshTasks]); + + const handleColorChange = useCallback(async (id: string, color: string) => { + if (!finalProjectId) return; + + try { + const phase = phaseList.find(p => p.id === id); + if (!phase) return; + + const updatedPhase = { ...phase, color_code: color }; + const response = await dispatch( + updatePhaseColor({ projectId: finalProjectId, body: updatedPhase }) + ).unwrap(); + + if (response.done) { + dispatch(fetchPhasesByProjectId(finalProjectId)); + await refreshTasks(); + } + } catch (error) { + console.error('Error changing phase color:', error); + } + }, [finalProjectId, phaseList, dispatch, refreshTasks]); + + const handlePhaseNameBlur = useCallback(async () => { + if (!finalProjectId || phaseName === initialPhaseName) return; + + try { + setIsSaving(true); + const res = await dispatch( + updateProjectPhaseLabel({ projectId: finalProjectId, phaseLabel: phaseName }) + ).unwrap(); + + if (res.done) { + setInitialPhaseName(phaseName); + await refreshTasks(); + } + } catch (error) { + console.error('Error updating phase name:', error); + } finally { + setIsSaving(false); + } + }, [finalProjectId, phaseName, initialPhaseName, dispatch, refreshTasks]); + + const handleClose = useCallback(() => { + onClose(); + }, [onClose]); + + return ( + + {t('configurePhases')} + + } + open={open} + onCancel={handleClose} + width={600} + style={{ top: 20 }} + bodyStyle={{ + maxHeight: 'calc(100vh - 200px)', + overflowY: 'auto', + padding: '24px', + }} + footer={ + + + + } + className={isDarkMode ? 'dark-modal' : ''} + loading={loadingPhases || sorting} + > +
+ {/* Phase Label Configuration */} +
+
+ + {t('phaseLabel')} + + setPhaseName(e.currentTarget.value)} + onPressEnter={handlePhaseNameBlur} + onBlur={handlePhaseNameBlur} + disabled={isSaving} + size="small" + /> +
+
+ + + + {/* Phase Options */} +
+
+ + 🎨 Drag phases to reorder them. Each phase can have a custom color. + +
+ + {/* Add New Phase */} +
+
+ + {t('phaseOptions')}: + + +
+
+ + {/* Phase List with Drag & Drop */} + + phase.id)} + strategy={verticalListSortingStrategy} + > +
+ {phaseList.map((phase) => ( + + ))} +
+
+
+ + {phaseList.length === 0 && ( +
+ No phases found. Add your first phase above. +
+ )} +
+
+
+ ); +}; + +export default ManagePhaseModal; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/ManageStatusModal.css b/worklenz-frontend/src/components/task-management/ManageStatusModal.css new file mode 100644 index 00000000..69ee4379 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/ManageStatusModal.css @@ -0,0 +1,249 @@ +/* ManageStatusModal.css */ + +/* Dark mode modal styling */ +.dark-modal .ant-modal-content { + background-color: #1f1f1f; + border: 1px solid #303030; +} + +.dark-modal .ant-modal-header { + background-color: #1f1f1f; + border-bottom: 1px solid #303030; +} + +.dark-modal .ant-modal-body { + background-color: #1f1f1f; +} + +.dark-modal .ant-modal-footer { + background-color: #1f1f1f; + border-top: 1px solid #303030; +} + +.dark-modal .ant-form-item-label > label { + color: #d9d9d9; +} + +.dark-modal .ant-input, +.dark-modal .ant-select-selector { + background-color: #141414; + border-color: #303030; + color: #d9d9d9; +} + +.dark-modal .ant-select-dropdown { + background-color: #1f1f1f; + border-color: #303030; +} + +.dark-modal .ant-select-item { + color: #d9d9d9; +} + +.dark-modal .ant-select-item:hover { + background-color: #262626; +} + +.dark-modal .ant-select-item-option-selected { + background-color: #1890ff; + color: white; +} + +/* Category select styling */ +.category-select .ant-select-selector { + height: 28px !important; + min-height: 28px !important; + border-radius: 6px !important; + transition: all 0.2s ease !important; +} + +.category-select:hover .ant-select-selector { + border-color: #40a9ff !important; +} + +.dark-modal .category-select .ant-select-selector { + background-color: #141414; + border-color: #303030; + color: #d9d9d9; +} + +.dark-modal .category-select:hover .ant-select-selector { + border-color: #40a9ff !important; + background-color: #1a1a1a; +} + +/* Improved drag handle */ +.drag-handle { + cursor: grab; + opacity: 0.7; + transition: all 0.2s ease; + border-radius: 6px; +} + +.drag-handle:hover { + opacity: 1; + background-color: rgba(0, 0, 0, 0.05); +} + +.dark-modal .drag-handle:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.drag-handle:active { + cursor: grabbing; + transform: scale(0.95); +} + +/* Status color dot enhancement */ +.status-color-dot { + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.status-color-dot:hover { + transform: scale(1.1); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.dark-modal .status-color-dot { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.dark-modal .status-color-dot:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); +} + +.dark-modal .ant-input::placeholder { + color: #8c8c8c; +} + +.dark-modal .ant-btn-primary { + background-color: #1890ff; + border-color: #1890ff; +} + +.dark-modal .ant-btn-primary:hover { + background-color: #40a9ff; + border-color: #40a9ff; +} + +.dark-modal .ant-btn-text:hover { + background-color: #262626; +} + +/* Status management section styling */ +.status-item { + transition: all 0.2s ease; +} + +.status-item:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1); +} + +.dark-modal .status-item:hover { + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); +} + +/* Enhanced status card styling */ +.status-card { + border-radius: 8px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.status-card:hover { + transform: translateY(-1px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12); +} + +.dark-modal .status-card:hover { + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4); +} + +/* Drag handle styling */ +.drag-handle { + cursor: grab; + opacity: 0.6; + transition: opacity 0.2s ease; +} + +.drag-handle:hover { + opacity: 1; +} + +.drag-handle:active { + cursor: grabbing; +} + +/* Status color indicator */ +.status-color { + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); +} + +.dark-modal .status-color { + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1); +} + +/* Sortable item styling */ +.sortable-status-item { + transition: transform 0.2s ease, opacity 0.2s ease; +} + +.sortable-status-item.is-dragging { + transform: scale(1.02); + z-index: 999; + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); +} + +.dark-modal .sortable-status-item.is-dragging { + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4); +} + +/* Responsive design */ +@media (max-width: 768px) { + .dark-modal .ant-modal { + width: 95% !important; + margin: 10px; + } + + .dark-modal .ant-modal-body { + padding: 16px; + } + + .status-item { + padding: 12px; + } +} + +/* Accessibility improvements */ +.drag-handle:focus { + outline: 2px solid #1890ff; + outline-offset: 2px; +} + +.dark-modal .drag-handle:focus { + outline-color: #40a9ff; +} + +/* Animation for status creation */ +.status-item-enter { + opacity: 0; + transform: translateY(-10px); +} + +.status-item-enter-active { + opacity: 1; + transform: translateY(0); + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.status-item-exit { + opacity: 1; + transform: translateY(0); +} + +.status-item-exit-active { + opacity: 0; + transform: translateY(-10px); + transition: opacity 0.3s ease, transform 0.3s ease; +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx b/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx new file mode 100644 index 00000000..054ecc88 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx @@ -0,0 +1,469 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { Modal, Form, Input, Button, Space, Divider, Typography, Flex, Select } from 'antd'; +import { PlusOutlined, HolderOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { createStatus, fetchStatuses, fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; +import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; +import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types'; +import { Modal as AntModal } from 'antd'; +import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; +import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import './ManageStatusModal.css'; + +const { Title, Text } = Typography; + +interface ManageStatusModalProps { + open: boolean; + onClose: () => void; + projectId?: string; +} + +interface StatusItemProps { + status: any; + onRename: (id: string, name: string) => void; + onDelete: (id: string) => void; + onCategoryChange: (id: string, categoryId: string) => void; + isDarkMode: boolean; + categories: any[]; +} + +// Sortable Status Item Component +const SortableStatusItem: React.FC = ({ + id, + status, + onRename, + onDelete, + onCategoryChange, + isDarkMode, + categories, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(status.name || ''); + const inputRef = useRef(null); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const handleSave = useCallback(() => { + if (editName.trim() && editName.trim() !== status.name) { + onRename(id, editName.trim()); + } + setIsEditing(false); + }, [editName, id, onRename, status.name]); + + const handleCancel = useCallback(() => { + setEditName(status.name || ''); + setIsEditing(false); + }, [status.name]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSave(); + } else if (e.key === 'Escape') { + handleCancel(); + } + }, [handleSave, handleCancel]); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + return ( +
+ {/* Header Row - Drag Handle, Color, Name, Actions */} +
+ {/* Drag Handle */} +
+ +
+ + {/* Status Color */} +
+ + {/* Status Name */} +
+ {isEditing ? ( + setEditName(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + className="font-medium" + placeholder="Enter status name" + /> + ) : ( + setIsEditing(true)} + > + {status.name} + + )} +
+ + {/* Actions */} + + + + +
+ + {/* Category Row */} +
+ + Category: + + +
+
+ ); +}; + +const ManageStatusModal: React.FC = ({ + open, + onClose, + projectId, +}) => { + const { t } = useTranslation('task-list-filters'); + const dispatch = useAppDispatch(); + + // Redux state + const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark'); + const currentProjectId = useAppSelector(state => state.projectReducer.projectId); + const { status: statuses } = useAppSelector(state => state.taskStatusReducer); + + const [localStatuses, setLocalStatuses] = useState(statuses); + const [newStatusName, setNewStatusName] = useState(''); + const [statusCategories, setStatusCategories] = useState([]); + + const finalProjectId = projectId || currentProjectId; + + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + useEffect(() => { + setLocalStatuses(statuses); + }, [statuses]); + + useEffect(() => { + if (open && finalProjectId) { + dispatch(fetchStatuses(finalProjectId)); + // Fetch status categories + dispatch(fetchStatusesCategories()).then((result: any) => { + if (result.payload && Array.isArray(result.payload)) { + setStatusCategories(result.payload); + } + }).catch(() => { + setStatusCategories([]); + }); + } + }, [open, finalProjectId, dispatch]); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id || !finalProjectId) { + return; + } + + setLocalStatuses((items) => { + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over.id); + + if (oldIndex === -1 || newIndex === -1) return items; + + const newItems = [...items]; + const [movedItem] = newItems.splice(oldIndex, 1); + newItems.splice(newIndex, 0, movedItem); + + // Update status order via API (fire and forget) + const columnOrder = newItems.map(item => item.id).filter(Boolean) as string[]; + const requestBody = { status_order: columnOrder }; + statusApiService.updateStatusOrder(requestBody, finalProjectId).then(() => { + // Refresh enhanced kanban after status order change + dispatch(fetchEnhancedKanbanGroups(finalProjectId)); + }).catch(error => { + console.error('Error updating status order:', error); + }); + + return newItems; + }); + }, [finalProjectId]); + + const handleCreateStatus = useCallback(async () => { + if (!newStatusName.trim() || !finalProjectId) return; + + try { + const statusCategories = await dispatch(fetchStatusesCategories()).unwrap(); + const defaultCategory = statusCategories[0]?.id; + + if (!defaultCategory) { + console.error('No status categories found'); + return; + } + + const body = { + name: newStatusName.trim(), + category_id: defaultCategory, + project_id: finalProjectId, + }; + + const res = await dispatch(createStatus({ body, currentProjectId: finalProjectId })).unwrap(); + if (res.done) { + setNewStatusName(''); + dispatch(fetchStatuses(finalProjectId)); + dispatch(fetchTasksV3(finalProjectId)); + dispatch(fetchEnhancedKanbanGroups(finalProjectId)); + } + } catch (error) { + console.error('Error creating status:', error); + } + }, [newStatusName, finalProjectId, dispatch]); + + const handleRenameStatus = useCallback(async (id: string, name: string) => { + if (!finalProjectId) return; + + try { + const body: ITaskStatusUpdateModel = { + name: name.trim(), + project_id: finalProjectId, + }; + + await statusApiService.updateNameOfStatus(id, body, finalProjectId); + dispatch(fetchStatuses(finalProjectId)); + dispatch(fetchTasksV3(finalProjectId)); + dispatch(fetchEnhancedKanbanGroups(finalProjectId)); + } catch (error) { + console.error('Error renaming status:', error); + } + }, [finalProjectId, dispatch]); + + const handleDeleteStatus = useCallback(async (id: string) => { + if (!finalProjectId) return; + + AntModal.confirm({ + title: 'Delete Status', + content: 'Are you sure you want to delete this status? This action cannot be undone.', + onOk: async () => { + try { + const replacingStatusId = localStatuses.find(s => s.id !== id)?.id || ''; + await statusApiService.deleteStatus(id, finalProjectId, replacingStatusId); + dispatch(fetchStatuses(finalProjectId)); + dispatch(fetchTasksV3(finalProjectId)); + dispatch(fetchEnhancedKanbanGroups(finalProjectId)); + } catch (error) { + console.error('Error deleting status:', error); + } + }, + }); + }, [localStatuses, finalProjectId, dispatch]); + + const handleCategoryChange = useCallback(async (id: string, categoryId: string) => { + if (!finalProjectId) return; + + try { + const body: ITaskStatusUpdateModel = { + category_id: categoryId, + project_id: finalProjectId, + }; + + await statusApiService.updateNameOfStatus(id, body, finalProjectId); + dispatch(fetchStatuses(finalProjectId)); + dispatch(fetchTasksV3(finalProjectId)); + dispatch(fetchEnhancedKanbanGroups(finalProjectId)); + } catch (error) { + console.error('Error changing status category:', error); + } + }, [finalProjectId, dispatch]); + + const handleClose = useCallback(() => { + setNewStatusName(''); + onClose(); + }, [onClose]); + + return ( + + {t('manageStatuses')} + + } + open={open} + onCancel={handleClose} + width={600} + style={{ top: 20 }} + bodyStyle={{ + maxHeight: 'calc(100vh - 200px)', + overflowY: 'auto', + padding: '24px', + }} + footer={ + + + + } + className={isDarkMode ? 'dark-modal' : ''} + > +
+
+ + 💡 Drag statuses to reorder them. Each status can have a different category. + +
+ + {/* Create New Status */} +
+
+ setNewStatusName(e.target.value)} + onPressEnter={handleCreateStatus} + className="flex-1" + size="small" + /> + +
+
+ + + + {/* Status List with Drag & Drop */} + + status.id).map(status => status.id as string)} + strategy={verticalListSortingStrategy} + > +
+ {localStatuses.filter(status => status.id).map((status) => ( + + ))} +
+
+
+ + {localStatuses.length === 0 && ( +
+ No statuses found. Create your first status above. +
+ )} +
+
+ ); +}; + +export default ManageStatusModal; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx index 6e6669c0..c913d6e9 100644 --- a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx +++ b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx @@ -73,9 +73,9 @@ import { setBoardLabels, } from '@/features/board/board-slice'; -// Import ConfigPhaseButton and CreateStatusButton components -import ConfigPhaseButton from '@/features/projects/singleProject/phase/ConfigPhaseButton'; -import CreateStatusButton from '@/components/project-task-filters/create-status-button/create-status-button'; +// Import modal components +import ManageStatusModal from '@/components/task-management/ManageStatusModal'; +import ManagePhaseModal from '@/components/task-management/ManagePhaseModal'; import { useAuthService } from '@/hooks/useAuth'; import useIsProjectManager from '@/hooks/useIsProjectManager'; @@ -367,6 +367,8 @@ const FilterDropdown: React.FC<{ isDarkMode: boolean; className?: string; dispatch?: any; + onManageStatus?: () => void; + onManagePhase?: () => void; }> = ({ section, onSelectionChange, @@ -376,6 +378,8 @@ const FilterDropdown: React.FC<{ isDarkMode, className = '', dispatch, + onManageStatus, + onManagePhase, }) => { const { t } = useTranslation('task-list-filters'); // Add permission checks for groupBy section @@ -486,11 +490,7 @@ const FilterDropdown: React.FC<{
{section.selectedValues[0] === 'phase' && (
+ + {/* Modals */} + { + setShowManageStatusModal(false); + // Refresh filter data after status changes + refreshFilterData(); + }} + projectId={projectId || undefined} + /> + + { + setShowManagePhaseModal(false); + // Refresh filter data after phase changes + refreshFilterData(); + }} + projectId={projectId || undefined} + />
); }; diff --git a/worklenz-frontend/src/hooks/useFilterDataLoader.ts b/worklenz-frontend/src/hooks/useFilterDataLoader.ts index 6165e018..9972b9eb 100644 --- a/worklenz-frontend/src/hooks/useFilterDataLoader.ts +++ b/worklenz-frontend/src/hooks/useFilterDataLoader.ts @@ -5,6 +5,7 @@ import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; import { fetchLabelsByProject, fetchTaskAssignees } from '@/features/tasks/tasks.slice'; import { getTeamMembers } from '@/features/team-members/team-members.slice'; import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice'; +import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; /** * Hook to manage filter data loading independently of main task list loading @@ -22,6 +23,27 @@ export const useFilterDataLoader = () => { // Memoize the projectId selector to prevent unnecessary re-renders const projectId = useAppSelector(state => state.projectReducer.projectId); + // Export a refresh function for external use + const refreshFilterData = useCallback(async () => { + if (projectId) { + dispatch(fetchLabelsByProject(projectId)); + dispatch(fetchTaskAssignees(projectId)); + dispatch(fetchStatuses(projectId)); + dispatch(fetchPhasesByProjectId(projectId)); + } + dispatch(fetchPriorities()); + dispatch( + getTeamMembers({ + index: 0, + size: 100, + field: null, + order: null, + search: null, + all: true, + }) + ); + }, [dispatch, projectId]); + // Load filter data asynchronously const loadFilterData = useCallback(async () => { try { @@ -71,5 +93,6 @@ export const useFilterDataLoader = () => { return { loadFilterData, + refreshFilterData, }; }; From 94977f7255394cff43ed1dc2c491ea16f823964c Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 10 Jul 2025 20:39:15 +0530 Subject: [PATCH 27/49] feat(performance): enhance application performance with optimizations and monitoring - Updated package dependencies for improved localization support and performance. - Introduced CSS performance optimizations to prevent layout shifts and enhance rendering efficiency. - Implemented asset preloading and lazy loading strategies for critical components to improve load times. - Enhanced translation loading with optimized caching and background loading strategies. - Added performance monitoring utilities to track key metrics and improve user experience. - Refactored task management components to utilize new performance features and ensure efficient rendering. - Introduced new utility functions for asset and CSS optimizations to streamline resource management. --- worklenz-frontend/package-lock.json | 116 ++- worklenz-frontend/package.json | 3 +- worklenz-frontend/src/App.tsx | 14 + .../lazy-loading-optimizations.tsx | 403 +++++++++++ .../task-management/task-list-board.tsx | 34 +- .../src/hooks/useTranslationPreloader.ts | 324 +++++++-- worklenz-frontend/src/i18n.ts | 253 +++++-- worklenz-frontend/src/index.tsx | 5 + worklenz-frontend/src/layouts/MainLayout.tsx | 27 +- .../src/socket/socketContext.tsx | 45 +- .../src/styles/performance-optimizations.css | 296 ++++++++ .../src/utils/asset-optimizations.ts | 588 +++++++++++++++ .../src/utils/css-optimizations.ts | 598 +++++++++++++++ .../utils/enhanced-performance-monitoring.ts | 680 ++++++++++++++++++ .../src/utils/redux-optimizations.ts | 319 ++++++++ 15 files changed, 3572 insertions(+), 133 deletions(-) create mode 100644 worklenz-frontend/src/components/task-management/lazy-loading-optimizations.tsx create mode 100644 worklenz-frontend/src/styles/performance-optimizations.css create mode 100644 worklenz-frontend/src/utils/asset-optimizations.ts create mode 100644 worklenz-frontend/src/utils/css-optimizations.ts create mode 100644 worklenz-frontend/src/utils/enhanced-performance-monitoring.ts create mode 100644 worklenz-frontend/src/utils/redux-optimizations.ts diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index f12aaee4..721124f0 100644 --- a/worklenz-frontend/package-lock.json +++ b/worklenz-frontend/package-lock.json @@ -34,8 +34,9 @@ "gantt-task-react": "^0.3.9", "html2canvas": "^1.4.1", "i18next": "^23.16.8", - "i18next-browser-languagedetector": "^8.0.3", + "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^2.7.3", + "i18next-localstorage-backend": "^4.2.0", "jspdf": "^3.0.0", "mixpanel-browser": "^2.56.0", "nanoid": "^5.1.5", @@ -728,6 +729,8 @@ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", @@ -741,7 +744,9 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -1051,6 +1056,8 @@ } ], "license": "MIT-0", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -1071,6 +1078,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" }, @@ -1095,6 +1104,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@csstools/color-helpers": "^5.0.2", "@csstools/css-calc": "^2.1.4" @@ -1123,6 +1134,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" }, @@ -1146,6 +1159,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -2997,6 +3012,8 @@ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 14" } @@ -3738,6 +3755,8 @@ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" @@ -3758,6 +3777,8 @@ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" @@ -3772,6 +3793,8 @@ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -3785,6 +3808,8 @@ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, "license": "BSD-2-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -3795,6 +3820,8 @@ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -3841,7 +3868,9 @@ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/deep-eql": { "version": "5.0.2", @@ -4016,6 +4045,8 @@ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=0.12" }, @@ -4499,6 +4530,8 @@ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "whatwg-encoding": "^3.1.1" }, @@ -4534,6 +4567,8 @@ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -4548,6 +4583,8 @@ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -4586,9 +4623,9 @@ } }, "node_modules/i18next-browser-languagedetector": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.1.0.tgz", - "integrity": "sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.2" @@ -4603,12 +4640,23 @@ "cross-fetch": "4.0.0" } }, + "node_modules/i18next-localstorage-backend": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/i18next-localstorage-backend/-/i18next-localstorage-backend-4.2.0.tgz", + "integrity": "sha512-vglEQF0AnLriX7dLA2drHnqAYzHxnLwWQzBDw8YxcIDjOvYZz5rvpal59Dq4In+IHNmGNM32YgF0TDjBT0fHmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.15" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -4729,7 +4777,9 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/isexe": { "version": "2.0.0", @@ -4818,6 +4868,8 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -4858,6 +4910,8 @@ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -4871,6 +4925,8 @@ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, "license": "BSD-2-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -4881,6 +4937,8 @@ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -4895,6 +4953,8 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -5533,7 +5593,9 @@ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/object-assign": { "version": "4.1.1", @@ -5595,6 +5657,8 @@ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "entities": "^6.0.0" }, @@ -6060,6 +6124,8 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -7205,7 +7271,9 @@ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/rrweb-snapshot": { "version": "2.0.0-alpha.18", @@ -7253,7 +7321,9 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/saxes": { "version": "6.0.0", @@ -7261,6 +7331,8 @@ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -7663,7 +7735,9 @@ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/tailwindcss": { "version": "3.4.17", @@ -7883,6 +7957,8 @@ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "tldts-core": "^6.1.86" }, @@ -7895,7 +7971,9 @@ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -7921,6 +7999,8 @@ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", + "optional": true, + "peer": true, "dependencies": { "tldts": "^6.1.32" }, @@ -8284,6 +8364,8 @@ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -8318,6 +8400,8 @@ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "iconv-lite": "0.6.3" }, @@ -8331,6 +8415,8 @@ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -8487,6 +8573,8 @@ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -8496,7 +8584,9 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json index b83e80fd..7e25181c 100644 --- a/worklenz-frontend/package.json +++ b/worklenz-frontend/package.json @@ -38,8 +38,9 @@ "gantt-task-react": "^0.3.9", "html2canvas": "^1.4.1", "i18next": "^23.16.8", - "i18next-browser-languagedetector": "^8.0.3", + "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^2.7.3", + "i18next-localstorage-backend": "^4.2.0", "jspdf": "^3.0.0", "mixpanel-browser": "^2.56.0", "nanoid": "^5.1.5", diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 3ed779c4..aa20e0ed 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -19,6 +19,9 @@ import { Language } from './features/i18n/localesSlice'; import logger from './utils/errorLogger'; import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback'; +// Performance optimizations +import { CSSPerformanceMonitor, LayoutStabilizer, CriticalCSSManager } from './utils/css-optimizations'; + // Service Worker import { registerSW } from './utils/serviceWorkerRegistration'; @@ -84,6 +87,17 @@ const App: React.FC = memo(() => { try { // Initialize CSRF token immediately as it's needed for API calls await initializeCsrfToken(); + + // Start CSS performance monitoring + CSSPerformanceMonitor.monitorLayoutShifts(); + CSSPerformanceMonitor.monitorRenderBlocking(); + + // Preload critical fonts to prevent layout shifts + LayoutStabilizer.preloadFonts([ + { family: 'Inter', weight: '400' }, + { family: 'Inter', weight: '500' }, + { family: 'Inter', weight: '600' }, + ]); } catch (error) { if (isMounted) { logger.error('Failed to initialize critical app functionality:', error); diff --git a/worklenz-frontend/src/components/task-management/lazy-loading-optimizations.tsx b/worklenz-frontend/src/components/task-management/lazy-loading-optimizations.tsx new file mode 100644 index 00000000..afdf83bb --- /dev/null +++ b/worklenz-frontend/src/components/task-management/lazy-loading-optimizations.tsx @@ -0,0 +1,403 @@ +import React, { lazy, Suspense, ComponentType, ReactNode } from 'react'; +import { Skeleton, Spin } from 'antd'; + +// Enhanced lazy loading with error boundary and retry logic +export function createOptimizedLazy>( + importFunc: () => Promise<{ default: T }>, + fallback?: ReactNode +): React.LazyExoticComponent { + let retryCount = 0; + const maxRetries = 3; + + const retryImport = async (): Promise<{ default: T }> => { + try { + return await importFunc(); + } catch (error) { + if (retryCount < maxRetries) { + retryCount++; + console.warn(`Lazy loading failed, retrying... (${retryCount}/${maxRetries})`); + // Exponential backoff: 500ms, 1s, 2s + await new Promise(resolve => setTimeout(resolve, 500 * Math.pow(2, retryCount - 1))); + return retryImport(); + } + throw error; + } + }; + + return lazy(retryImport); +} + +// Preloading utility for components that will likely be used +export const preloadComponent = >( + importFunc: () => Promise<{ default: T }> +): void => { + // Preload on user interaction or after a delay + if ('requestIdleCallback' in window) { + (window as any).requestIdleCallback(() => { + importFunc().catch(() => { + // Ignore preload errors + }); + }); + } else { + setTimeout(() => { + importFunc().catch(() => { + // Ignore preload errors + }); + }, 2000); + } +}; + +// Lazy-loaded task management components with optimized fallbacks +export const LazyTaskListBoard = createOptimizedLazy( + () => import('./task-list-board'), +
+ +
+); + +export const LazyVirtualizedTaskList = createOptimizedLazy( + () => import('./virtualized-task-list'), + +); + +export const LazyTaskRow = createOptimizedLazy( + () => import('./task-row'), + +); + +export const LazyImprovedTaskFilters = createOptimizedLazy( + () => import('./improved-task-filters'), + +); + +export const LazyOptimizedBulkActionBar = createOptimizedLazy( + () => import('./optimized-bulk-action-bar'), + +); + +export const LazyPerformanceAnalysis = createOptimizedLazy( + () => import('./performance-analysis'), +
Loading performance tools...
+); + +// Kanban-specific components +export const LazyKanbanTaskListBoard = createOptimizedLazy( + () => import('../kanban-board-management-v2/kanbanTaskListBoard'), +
+ +
+); + +// Task list V2 components +export const LazyTaskListV2Table = createOptimizedLazy( + () => import('../task-list-v2/TaskListV2Table'), + +); + +export const LazyTaskRowWithSubtasks = createOptimizedLazy( + () => import('../task-list-v2/TaskRowWithSubtasks'), + +); + +export const LazyCustomColumnModal = createOptimizedLazy( + () => import('@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal'), +
+); + +export const LazyLabelsSelector = createOptimizedLazy( + () => import('@/components/LabelsSelector'), + +); + +export const LazyAssigneeSelector = createOptimizedLazy( + () => import('./lazy-assignee-selector'), + +); + +export const LazyTaskStatusDropdown = createOptimizedLazy( + () => import('./task-status-dropdown'), + +); + +export const LazyTaskPriorityDropdown = createOptimizedLazy( + () => import('./task-priority-dropdown'), + +); + +export const LazyTaskPhaseDropdown = createOptimizedLazy( + () => import('./task-phase-dropdown'), + +); + +// HOC for progressive enhancement +interface ProgressiveEnhancementProps { + condition: boolean; + children: ReactNode; + fallback?: ReactNode; + loadingComponent?: ReactNode; +} + +export const ProgressiveEnhancement: React.FC = ({ + condition, + children, + fallback, + loadingComponent = +}) => { + if (!condition) { + return <>{fallback || loadingComponent}; + } + + return ( + + {children} + + ); +}; + +// Intersection observer based lazy loading for components +interface IntersectionLazyLoadProps { + children: ReactNode; + fallback?: ReactNode; + rootMargin?: string; + threshold?: number; + once?: boolean; +} + +export const IntersectionLazyLoad: React.FC = ({ + children, + fallback = , + rootMargin = '100px', + threshold = 0.1, + once = true +}) => { + const [isVisible, setIsVisible] = React.useState(false); + const [hasBeenVisible, setHasBeenVisible] = React.useState(false); + const ref = React.useRef(null); + + React.useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + if (once) { + setHasBeenVisible(true); + observer.disconnect(); + } + } else if (!once) { + setIsVisible(false); + } + }, + { rootMargin, threshold } + ); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [rootMargin, threshold, once]); + + const shouldRender = isVisible || hasBeenVisible; + + return ( +
+ {shouldRender ? ( + + {children} + + ) : ( + fallback + )} +
+ ); +}; + +// Route-based code splitting utility +export const createRouteComponent = >( + importFunc: () => Promise<{ default: T }>, + pageTitle?: string +) => { + const LazyComponent = createOptimizedLazy(importFunc); + + return React.memo(() => { + React.useEffect(() => { + if (pageTitle) { + document.title = pageTitle; + } + }, []); + + return ( + + +
+ } + > + + + ); + }); +}; + +// Bundle splitting by feature +export const TaskManagementBundle = { + TaskListBoard: LazyTaskListBoard, + VirtualizedTaskList: LazyVirtualizedTaskList, + TaskRow: LazyTaskRow, + TaskFilters: LazyImprovedTaskFilters, + BulkActionBar: LazyOptimizedBulkActionBar, + PerformanceAnalysis: LazyPerformanceAnalysis, +}; + +export const KanbanBundle = { + KanbanBoard: LazyKanbanTaskListBoard, +}; + +export const TaskListV2Bundle = { + TaskTable: LazyTaskListV2Table, + TaskRowWithSubtasks: LazyTaskRowWithSubtasks, +}; + +export const FormBundle = { + CustomColumnModal: LazyCustomColumnModal, + LabelsSelector: LazyLabelsSelector, + AssigneeSelector: LazyAssigneeSelector, +}; + +export const DropdownBundle = { + StatusDropdown: LazyTaskStatusDropdown, + PriorityDropdown: LazyTaskPriorityDropdown, + PhaseDropdown: LazyTaskPhaseDropdown, +}; + +// Preloading strategies +export const preloadTaskManagementComponents = () => { + // Preload core components that are likely to be used + preloadComponent(() => import('./task-list-board')); + preloadComponent(() => import('./virtualized-task-list')); + preloadComponent(() => import('./improved-task-filters')); +}; + +export const preloadKanbanComponents = () => { + preloadComponent(() => import('../kanban-board-management-v2/kanbanTaskListBoard')); +}; + +export const preloadFormComponents = () => { + preloadComponent(() => import('@/components/LabelsSelector')); +}; + +// Dynamic import utilities +export const importTaskComponent = async (componentName: string) => { + const componentMap: Record Promise> = { + 'task-list-board': () => import('./task-list-board'), + 'virtualized-task-list': () => import('./virtualized-task-list'), + 'task-row': () => import('./task-row'), + 'improved-task-filters': () => import('./improved-task-filters'), + 'optimized-bulk-action-bar': () => import('./optimized-bulk-action-bar'), + 'performance-analysis': () => import('./performance-analysis'), + }; + + const importFunc = componentMap[componentName]; + if (!importFunc) { + throw new Error(`Component ${componentName} not found`); + } + + return importFunc(); +}; + +// Error boundary for lazy loaded components +interface LazyErrorBoundaryState { + hasError: boolean; + error?: Error; +} + +export class LazyErrorBoundary extends React.Component< + { children: ReactNode; fallback?: ReactNode }, + LazyErrorBoundaryState +> { + constructor(props: { children: ReactNode; fallback?: ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): LazyErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('Lazy loading error:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( + this.props.fallback || ( +
+

Failed to load component

+ +
+ ) + ); + } + + return this.props.children; + } +} + +// Usage example and documentation +export const LazyLoadingExamples = { + // Basic lazy loading with suspense + BasicExample: () => ( + }> + + + ), + + // Progressive enhancement + ProgressiveExample: () => ( + + {}} + onToggleSubtasks={() => {}} + /> + + ), + + // Intersection observer lazy loading + IntersectionExample: () => ( + + + + ), + + // Error boundary with lazy loading + ErrorBoundaryExample: () => ( + + }> + {}} + onToggleSubtasks={() => {}} + /> + + + ), +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/task-list-board.tsx b/worklenz-frontend/src/components/task-management/task-list-board.tsx index 02e4c758..f6ea1721 100644 --- a/worklenz-frontend/src/components/task-management/task-list-board.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -77,6 +77,12 @@ import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; import ImprovedTaskFilters from './improved-task-filters'; import PerformanceAnalysis from './performance-analysis'; +// Import asset optimizations +import { AssetPreloader, LazyLoader } from '@/utils/asset-optimizations'; + +// Import performance monitoring +import { CustomPerformanceMeasurer } from '@/utils/enhanced-performance-monitoring'; + // Import drag and drop performance optimizations import './drag-drop-optimized.css'; import './optimized-bulk-action-bar.css'; @@ -210,13 +216,39 @@ const TaskListBoard: React.FC = ({ projectId, className = '' return () => clearInterval(interval); }, []); + // Initialize asset optimization + useEffect(() => { + // Preload critical task management assets + AssetPreloader.preloadAssets([ + { url: '/icons/task-status.svg', priority: 'high' }, + { url: '/icons/priority-high.svg', priority: 'high' }, + { url: '/icons/priority-medium.svg', priority: 'high' }, + { url: '/icons/priority-low.svg', priority: 'high' }, + { url: '/icons/phase.svg', priority: 'medium' }, + { url: '/icons/assignee.svg', priority: 'medium' }, + ]); + + // Preload critical images for better performance + LazyLoader.preloadCriticalImages([ + '/icons/task-status.svg', + '/icons/priority-high.svg', + '/icons/priority-medium.svg', + '/icons/priority-low.svg', + ]); + }, []); + // Fetch task groups when component mounts or dependencies change useEffect(() => { if (projectId && !hasInitialized.current) { hasInitialized.current = true; + // Measure task loading performance + CustomPerformanceMeasurer.mark('task-load-time'); + // Fetch real tasks from V3 API (minimal processing needed) - dispatch(fetchTasksV3(projectId)); + dispatch(fetchTasksV3(projectId)).finally(() => { + CustomPerformanceMeasurer.measure('task-load-time'); + }); } }, [projectId, dispatch]); diff --git a/worklenz-frontend/src/hooks/useTranslationPreloader.ts b/worklenz-frontend/src/hooks/useTranslationPreloader.ts index 46b3ad86..226953c8 100644 --- a/worklenz-frontend/src/hooks/useTranslationPreloader.ts +++ b/worklenz-frontend/src/hooks/useTranslationPreloader.ts @@ -1,83 +1,283 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ensureTranslationsLoaded } from '@/i18n'; +import { + ensureTranslationsLoaded, + preloadPageTranslations, + getPerformanceMetrics, + changeLanguageOptimized +} from '../i18n'; +import logger from '../utils/errorLogger'; -interface UseTranslationPreloaderOptions { - namespaces?: string[]; - fallback?: React.ReactNode; +// Cache for preloaded translation states +const preloadCache = new Map(); +const loadingStates = new Map(); + +interface TranslationHookOptions { + preload?: boolean; + priority?: number; + fallbackReady?: boolean; } -/** - * Hook to ensure translations are loaded before rendering components - * This prevents Suspense issues when components use useTranslation - */ -export const useTranslationPreloader = ( - namespaces: string[] = ['tasks/task-table-bulk-actions', 'task-management'], - options: UseTranslationPreloaderOptions = {} -) => { - const [isLoaded, setIsLoaded] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const { t, ready } = useTranslation(namespaces); +interface TranslationHookReturn { + t: (key: string, defaultValue?: string) => string; + ready: boolean; + isLoading: boolean; + error: Error | null; + retryLoad: () => Promise; + performanceMetrics: any; +} +// Enhanced translation hook with better performance +export const useOptimizedTranslation = ( + namespace: string | string[], + options: TranslationHookOptions = {} +): TranslationHookReturn => { + const { preload = true, priority = 5, fallbackReady = true } = options; + + const namespaces = Array.isArray(namespace) ? namespace : [namespace]; + const namespaceKey = namespaces.join(','); + + const [ready, setReady] = useState(fallbackReady); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const hasInitialized = useRef(false); + const loadingPromise = useRef | null>(null); + + const { t, i18n } = useTranslation(namespaces); + + // Memoized preload function + const preloadTranslations = useCallback(async () => { + const cacheKey = `${i18n.language}:${namespaceKey}`; + + // Skip if already preloaded or currently loading + if (preloadCache.get(cacheKey) || loadingStates.get(cacheKey)) { + return; + } + + try { + setIsLoading(true); + setError(null); + loadingStates.set(cacheKey, true); + + const startTime = performance.now(); + + // Use the optimized preload function + await preloadPageTranslations(namespaces); + + const endTime = performance.now(); + const loadTime = endTime - startTime; + + if (process.env.NODE_ENV === 'development') { + console.log( + `✅ Preloaded translations for ${namespaceKey} in ${loadTime.toFixed(2)}ms` + ); + } + + preloadCache.set(cacheKey, true); + setReady(true); + } catch (err) { + const error = err instanceof Error ? err : new Error('Failed to preload translations'); + setError(error); + logger.error(`Failed to preload translations for ${namespaceKey}:`, error); + + // Fallback to ready state even on error to prevent blocking UI + if (fallbackReady) { + setReady(true); + } + } finally { + setIsLoading(false); + loadingStates.set(cacheKey, false); + } + }, [namespaces, namespaceKey, i18n.language, fallbackReady]); + + // Initialize preloading useEffect(() => { - let isMounted = true; + if (!hasInitialized.current && preload) { + hasInitialized.current = true; + + if (!loadingPromise.current) { + loadingPromise.current = preloadTranslations(); + } + } + }, [preload, preloadTranslations]); - const loadTranslations = async () => { - try { - setIsLoading(true); - - // Only load translations for current language to avoid multiple requests - await ensureTranslationsLoaded(namespaces); - - // Wait for i18next to be ready - if (!ready) { - // If i18next is not ready, wait a bit and check again - await new Promise(resolve => setTimeout(resolve, 100)); - } - - if (isMounted) { - setIsLoaded(true); - setIsLoading(false); - } - } catch (error) { - if (isMounted) { - setIsLoaded(true); // Still set as loaded to prevent infinite loading - setIsLoading(false); - } + // Handle language changes + useEffect(() => { + const handleLanguageChange = () => { + const cacheKey = `${i18n.language}:${namespaceKey}`; + if (!preloadCache.get(cacheKey) && preload) { + setReady(false); + preloadTranslations(); } }; - // Only load if not already loaded - if (!isLoaded && !ready) { - loadTranslations(); - } else if (ready && !isLoaded) { - setIsLoaded(true); - setIsLoading(false); - } - + i18n.on('languageChanged', handleLanguageChange); return () => { - isMounted = false; + i18n.off('languageChanged', handleLanguageChange); }; - }, [namespaces, ready, isLoaded]); + }, [i18n, namespaceKey, preload, preloadTranslations]); + + // Retry function + const retryLoad = useCallback(async () => { + const cacheKey = `${i18n.language}:${namespaceKey}`; + preloadCache.delete(cacheKey); + loadingStates.delete(cacheKey); + await preloadTranslations(); + }, [namespaceKey, i18n.language, preloadTranslations]); + + // Get performance metrics + const performanceMetrics = useMemo(() => getPerformanceMetrics(), [ready]); + + // Enhanced t function with better error handling + const enhancedT = useCallback((key: string, defaultValue?: string) => { + try { + const translation = t(key, { defaultValue }); + + // Return the translation if it's not the key itself (indicating it was found) + if (translation !== key) { + return translation; + } + + // If we have a default value, use it + if (defaultValue) { + return defaultValue; + } + + // Fallback to the key + return key; + } catch (err) { + logger.error(`Translation error for key ${key}:`, err); + return defaultValue || key; + } + }, [t]); return { - t, - ready: isLoaded && ready, + t: enhancedT, + ready, isLoading, - isLoaded, + error, + retryLoad, + performanceMetrics, }; }; -/** - * Hook specifically for bulk action bar translations - */ -export const useBulkActionTranslations = () => { - return useTranslationPreloader(['tasks/task-table-bulk-actions']); +// Specialized hooks for commonly used namespaces +export const useTaskManagementTranslations = (options?: TranslationHookOptions) => { + return useOptimizedTranslation(['task-management', 'task-list-table'], { + priority: 8, + ...options, + }); }; -/** - * Hook for task management translations - */ -export const useTaskManagementTranslations = () => { - return useTranslationPreloader(['task-management', 'tasks/task-table-bulk-actions']); +export const useBulkActionTranslations = (options?: TranslationHookOptions) => { + return useOptimizedTranslation(['tasks/task-table-bulk-actions', 'task-management'], { + priority: 6, + ...options, + }); +}; + +export const useTaskDrawerTranslations = (options?: TranslationHookOptions) => { + return useOptimizedTranslation(['task-drawer/task-drawer', 'task-list-table'], { + priority: 7, + ...options, + }); +}; + +export const useProjectTranslations = (options?: TranslationHookOptions) => { + return useOptimizedTranslation(['project-drawer', 'common'], { + priority: 7, + ...options, + }); +}; + +export const useSettingsTranslations = (options?: TranslationHookOptions) => { + return useOptimizedTranslation(['settings', 'common'], { + priority: 4, + ...options, + }); +}; + +// Utility function to preload multiple namespaces +export const preloadMultipleNamespaces = async ( + namespaces: string[], + priority: number = 5 +): Promise => { + try { + await Promise.all( + namespaces.map(ns => preloadPageTranslations([ns])) + ); + return true; + } catch (error) { + logger.error('Failed to preload multiple namespaces:', error); + return false; + } +}; + +// Hook for pages that need multiple translation namespaces +export const usePageTranslations = ( + namespaces: string[], + options?: TranslationHookOptions +) => { + const { ready, isLoading, error } = useOptimizedTranslation(namespaces, options); + + // Create individual translation functions for each namespace + const translations = useMemo(() => { + const result: Record = {}; + + namespaces.forEach(ns => { + const { t } = useTranslation(ns); + result[ns] = t; + }); + + return result; + }, [namespaces, ready]); + + return { + ...translations, + ready, + isLoading, + error, + }; +}; + +// Language switching utilities +export const useLanguageSwitcher = () => { + const [switching, setSwitching] = useState(false); + + const switchLanguage = useCallback(async (language: string) => { + try { + setSwitching(true); + await changeLanguageOptimized(language); + + // Clear preload cache for new language + preloadCache.clear(); + loadingStates.clear(); + + } catch (error) { + logger.error('Failed to switch language:', error); + } finally { + setSwitching(false); + } + }, []); + + return { + switchLanguage, + switching, + }; +}; + +// Performance monitoring hook +export const useTranslationPerformance = () => { + const [metrics, setMetrics] = useState(getPerformanceMetrics()); + + useEffect(() => { + const interval = setInterval(() => { + setMetrics(getPerformanceMetrics()); + }, 5000); // Update every 5 seconds + + return () => clearInterval(interval); + }, []); + + return metrics; }; diff --git a/worklenz-frontend/src/i18n.ts b/worklenz-frontend/src/i18n.ts index 8c96cd62..444dd60d 100644 --- a/worklenz-frontend/src/i18n.ts +++ b/worklenz-frontend/src/i18n.ts @@ -1,6 +1,8 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import HttpApi from 'i18next-http-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import LocalStorageBackend from 'i18next-localstorage-backend'; import logger from './utils/errorLogger'; // Essential namespaces that should be preloaded to prevent Suspense @@ -19,53 +21,133 @@ const SECONDARY_NAMESPACES = [ 'project-drawer', ]; +// Tertiary namespaces that can be loaded even later +const TERTIARY_NAMESPACES = [ + 'task-drawer/task-drawer', + 'task-list-table', + 'phases-drawer', + 'schedule', + 'reporting', + 'admin-center/current-bill', +]; + // Cache to track loaded translations and prevent duplicate requests const loadedTranslations = new Set(); const loadingPromises = new Map>(); // Background loading queue for non-essential translations -let backgroundLoadingQueue: Array<{ lang: string; ns: string }> = []; +let backgroundLoadingQueue: Array<{ lang: string; ns: string; priority: number }> = []; let isBackgroundLoading = false; +// Performance monitoring +const performanceMetrics = { + totalLoadTime: 0, + translationsLoaded: 0, + cacheHits: 0, + cacheMisses: 0, +}; + +// Enhanced caching configuration +const CACHE_CONFIG = { + EXPIRATION_TIME: 7 * 24 * 60 * 60 * 1000, // 7 days + MAX_CACHE_SIZE: 50, // Maximum number of namespaces to cache + CLEANUP_INTERVAL: 24 * 60 * 60 * 1000, // Clean cache daily +}; + i18n - .use(HttpApi) + .use(LocalStorageBackend) // Cache translations to localStorage + .use(LanguageDetector) // Detect user language + .use(HttpApi) // Fetch translations if not in cache .use(initReactI18next) .init({ fallbackLng: 'en', backend: { loadPath: '/locales/{{lng}}/{{ns}}.json', - // Add request timeout to prevent hanging on slow connections - requestOptions: { - cache: 'default', - mode: 'cors', - credentials: 'same-origin', - }, + addPath: '/locales/add/{{lng}}/{{ns}}', + // Enhanced LocalStorage caching options + backendOptions: [{ + expirationTime: CACHE_CONFIG.EXPIRATION_TIME, + // Store translations more efficiently + store: { + setItem: (key: string, value: string) => { + try { + // Compress large translation objects + const compressedValue = value.length > 1000 ? + JSON.stringify(JSON.parse(value)) : value; + localStorage.setItem(key, compressedValue); + performanceMetrics.cacheHits++; + } catch (error) { + logger.error('Failed to store translation in cache:', error); + } + }, + getItem: (key: string) => { + try { + const value = localStorage.getItem(key); + if (value) { + performanceMetrics.cacheHits++; + return value; + } + performanceMetrics.cacheMisses++; + return null; + } catch (error) { + logger.error('Failed to retrieve translation from cache:', error); + performanceMetrics.cacheMisses++; + return null; + } + } + } + }, { + loadPath: '/locales/{{lng}}/{{ns}}.json', + // Add request timeout and retry logic + requestOptions: { + cache: 'force-cache', // Use browser cache when possible + }, + parse: (data: string) => { + try { + return JSON.parse(data); + } catch (error) { + logger.error('Failed to parse translation data:', error); + return {}; + } + } + }], }, defaultNS: 'common', - // Only load essential namespaces initially ns: ESSENTIAL_NAMESPACES, interpolation: { escapeValue: false, }, - // Only preload current language to reduce initial load preload: [], load: 'languageOnly', - // Disable loading all namespaces on init initImmediate: false, - // Cache translations with shorter expiration for better performance - cache: { - enabled: true, - expirationTime: 12 * 60 * 60 * 1000, // 12 hours + detection: { + order: ['localStorage', 'navigator'], // Check localStorage first, then browser language + caches: ['localStorage'], + // Cache the detected language for faster subsequent loads + cookieMinutes: 60 * 24 * 7, // 1 week }, // Reduce debug output in production debug: process.env.NODE_ENV === 'development', + // Performance optimizations + cleanCode: true, // Remove code characters + keySeparator: false, // Disable key separator for better performance + nsSeparator: false, // Disable namespace separator for better performance + pluralSeparator: '_', // Use underscore for plural separation + react: { + useSuspense: false, // Disable suspense for better control + bindI18n: 'languageChanged loaded', // Only bind necessary events + bindI18nStore: false, // Disable store binding for better performance + }, }); -// Optimized function to ensure translations are loaded +// Optimized function to ensure translations are loaded with priority support export const ensureTranslationsLoaded = async ( namespaces: string[] = ESSENTIAL_NAMESPACES, - languages: string[] = [i18n.language || 'en'] + languages: string[] = [i18n.language || 'en'], + priority: number = 0 ) => { + const startTime = performance.now(); + try { const loadPromises: Promise[] = []; @@ -84,7 +166,7 @@ export const ensureTranslationsLoaded = async ( continue; } - // Create loading promise + // Create loading promise with enhanced error handling const loadingPromise = new Promise((resolve, reject) => { const currentLang = i18n.language; const shouldSwitchLang = currentLang !== lang; @@ -102,10 +184,12 @@ export const ensureTranslationsLoaded = async ( } loadedTranslations.add(key); + performanceMetrics.translationsLoaded++; resolve(); } catch (error) { logger.error(`Failed to load namespace: ${ns} for language: ${lang}`, error); - reject(error); + // Don't reject completely, just log and continue + resolve(); // Still resolve to prevent blocking other translations } finally { loadingPromises.delete(key); } @@ -120,6 +204,10 @@ export const ensureTranslationsLoaded = async ( } await Promise.all(loadPromises); + + const endTime = performance.now(); + performanceMetrics.totalLoadTime += (endTime - startTime); + return true; } catch (error) { logger.error('Failed to load translations:', error); @@ -127,28 +215,37 @@ export const ensureTranslationsLoaded = async ( } }; -// Background loading function for non-essential translations +// Enhanced background loading function with priority queue const processBackgroundQueue = async () => { if (isBackgroundLoading || backgroundLoadingQueue.length === 0) return; isBackgroundLoading = true; try { - // Process queue in batches to avoid overwhelming the network - const batchSize = 3; + // Sort by priority (higher priority first) + backgroundLoadingQueue.sort((a, b) => b.priority - a.priority); + + // Process queue in smaller batches to avoid overwhelming the network + const batchSize = 2; // Reduced batch size for better performance while (backgroundLoadingQueue.length > 0) { const batch = backgroundLoadingQueue.splice(0, batchSize); const batchPromises = batch.map(({ lang, ns }) => - ensureTranslationsLoaded([ns], [lang]).catch(error => { - logger.error(`Background loading failed for ${lang}:${ns}`, error); - }) + ensureTranslationsLoaded([ns], [lang], 0).catch(error => { + logger.error(`Background loading failed for ${lang}:${ns}`, error); + }) ); await Promise.all(batchPromises); - // Add small delay between batches to prevent blocking + // Add delay between batches to prevent blocking main thread if (backgroundLoadingQueue.length > 0) { - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise(resolve => setTimeout(resolve, 200)); // Increased delay + } + + // Break if we've been loading for too long (prevent infinite loops) + if (performance.now() - performanceMetrics.totalLoadTime > 30000) { // 30 seconds max + logger.error('Background translation loading taking too long, stopping'); + break; } } } finally { @@ -156,17 +253,29 @@ const processBackgroundQueue = async () => { } }; -// Queue secondary translations for background loading -const queueSecondaryTranslations = (language: string) => { - SECONDARY_NAMESPACES.forEach(ns => { +// Enhanced queueing with priority support +const queueTranslations = (language: string, namespaces: string[], priority: number = 0) => { + namespaces.forEach(ns => { const key = `${language}:${ns}`; if (!loadedTranslations.has(key)) { - backgroundLoadingQueue.push({ lang: language, ns }); + // Remove existing entry if it exists with lower priority + const existingIndex = backgroundLoadingQueue.findIndex(item => + item.lang === language && item.ns === ns); + if (existingIndex >= 0) { + if (backgroundLoadingQueue[existingIndex].priority < priority) { + backgroundLoadingQueue.splice(existingIndex, 1); + } else { + return; // Don't add duplicate with lower or equal priority + } + } + + backgroundLoadingQueue.push({ lang: language, ns, priority }); } }); - // Start background loading with a delay to not interfere with initial render - setTimeout(processBackgroundQueue, 2000); + // Start background loading with appropriate delay based on priority + const delay = priority > 5 ? 1000 : priority > 2 ? 2000 : 3000; + setTimeout(processBackgroundQueue, delay); }; // Initialize only essential translations for current language @@ -174,11 +283,14 @@ const initializeTranslations = async () => { try { const currentLang = i18n.language || 'en'; - // Load only essential namespaces initially - await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [currentLang]); + // Load only essential namespaces immediately + await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [currentLang], 10); - // Queue secondary translations for background loading - queueSecondaryTranslations(currentLang); + // Queue secondary translations with medium priority + queueTranslations(currentLang, SECONDARY_NAMESPACES, 5); + + // Queue tertiary translations with low priority + queueTranslations(currentLang, TERTIARY_NAMESPACES, 1); return true; } catch (error) { @@ -187,17 +299,20 @@ const initializeTranslations = async () => { } }; -// Language change handler that prioritizes essential namespaces +// Enhanced language change handler with better prioritization export const changeLanguageOptimized = async (language: string) => { try { // Change language first await i18n.changeLanguage(language); - // Load essential namespaces immediately - await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [language]); + // Load essential namespaces immediately with high priority + await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [language], 10); - // Queue secondary translations for background loading - queueSecondaryTranslations(language); + // Queue secondary translations with medium priority + queueTranslations(language, SECONDARY_NAMESPACES, 5); + + // Queue tertiary translations with low priority + queueTranslations(language, TERTIARY_NAMESPACES, 1); return true; } catch (error) { @@ -206,7 +321,59 @@ export const changeLanguageOptimized = async (language: string) => { } }; -// Initialize translations on app startup (only essential ones) +// Cache cleanup functionality +const cleanupCache = () => { + try { + const keys = Object.keys(localStorage).filter(key => + key.startsWith('i18next_res_') + ); + + if (keys.length > CACHE_CONFIG.MAX_CACHE_SIZE) { + // Remove oldest entries + const entriesToRemove = keys.slice(0, keys.length - CACHE_CONFIG.MAX_CACHE_SIZE); + entriesToRemove.forEach(key => { + try { + localStorage.removeItem(key); + } catch (error) { + logger.error('Failed to remove cache entry:', error); + } + }); + } + } catch (error) { + logger.error('Failed to cleanup translation cache:', error); + } +}; + +// Performance monitoring functions +export const getPerformanceMetrics = () => ({ + ...performanceMetrics, + cacheEfficiency: performanceMetrics.cacheHits / + (performanceMetrics.cacheHits + performanceMetrics.cacheMisses) * 100, + averageLoadTime: performanceMetrics.totalLoadTime / performanceMetrics.translationsLoaded, +}); + +export const resetPerformanceMetrics = () => { + performanceMetrics.totalLoadTime = 0; + performanceMetrics.translationsLoaded = 0; + performanceMetrics.cacheHits = 0; + performanceMetrics.cacheMisses = 0; +}; + +// Utility function to preload translations for a specific page/component +export const preloadPageTranslations = async (pageNamespaces: string[]) => { + const currentLang = i18n.language || 'en'; + return ensureTranslationsLoaded(pageNamespaces, [currentLang], 8); +}; + +// Set up periodic cache cleanup +if (typeof window !== 'undefined') { + setInterval(cleanupCache, CACHE_CONFIG.CLEANUP_INTERVAL); + + // Cleanup on page unload + window.addEventListener('beforeunload', cleanupCache); +} + +// Initialize translations on app startup initializeTranslations(); export default i18n; diff --git a/worklenz-frontend/src/index.tsx b/worklenz-frontend/src/index.tsx index cf2c161c..c8cf3b99 100644 --- a/worklenz-frontend/src/index.tsx +++ b/worklenz-frontend/src/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; +import './styles/performance-optimizations.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; import './i18n'; @@ -10,12 +11,16 @@ import { applyCssVariables } from './styles/colors'; import { ConfigProvider, theme } from 'antd'; import { colors } from './styles/colors'; import { getInitialTheme } from './utils/get-initial-theme'; +import { initializePerformanceMonitoring } from './utils/enhanced-performance-monitoring'; const initialTheme = getInitialTheme(); // Apply CSS variables and initial theme applyCssVariables(); +// Initialize enhanced performance monitoring +initializePerformanceMonitoring(); + const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); document.documentElement.classList.add(initialTheme); diff --git a/worklenz-frontend/src/layouts/MainLayout.tsx b/worklenz-frontend/src/layouts/MainLayout.tsx index 53f31bc7..8e3cc66b 100644 --- a/worklenz-frontend/src/layouts/MainLayout.tsx +++ b/worklenz-frontend/src/layouts/MainLayout.tsx @@ -1,6 +1,6 @@ import { Col, ConfigProvider, Layout } from 'antd'; import { Outlet, useNavigate } from 'react-router-dom'; -import { memo, useMemo } from 'react'; +import { memo, useMemo, useEffect, useRef } from 'react'; import { useMediaQuery } from 'react-responsive'; import Navbar from '../features/navbar/navbar'; @@ -10,15 +10,30 @@ import { colors } from '../styles/colors'; import { useRenderPerformance } from '@/utils/performance'; import HubSpot from '@/components/HubSpot'; +import { DynamicCSSLoader, LayoutStabilizer } from '@/utils/css-optimizations'; const MainLayout = memo(() => { const themeMode = useAppSelector(state => state.themeReducer.mode); const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' }); - + const layoutRef = useRef(null); // Performance monitoring in development useRenderPerformance('MainLayout'); + // Apply layout optimizations + useEffect(() => { + if (layoutRef.current) { + // Prevent layout shifts in main content area + LayoutStabilizer.applyContainment(layoutRef.current, 'layout'); + + // Load non-critical CSS dynamically + DynamicCSSLoader.loadCSS('/styles/non-critical.css', { + priority: 'low', + media: 'all' + }); + } + }, []); + // Memoize styles to prevent object recreation on every render @@ -64,13 +79,13 @@ const MainLayout = memo(() => { return ( - - + + - - + + diff --git a/worklenz-frontend/src/socket/socketContext.tsx b/worklenz-frontend/src/socket/socketContext.tsx index c77ba658..c20d676c 100644 --- a/worklenz-frontend/src/socket/socketContext.tsx +++ b/worklenz-frontend/src/socket/socketContext.tsx @@ -8,6 +8,9 @@ import { Modal, message } from 'antd'; import { SocketEvents } from '@/shared/socket-events'; import { getUserSession } from '@/utils/session-helper'; +// Global socket instance to prevent multiple connections in StrictMode +let globalSocketInstance: Socket | null = null; + interface SocketContextType { socket: Socket | null; connected: boolean; @@ -24,12 +27,30 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr const profile = getUserSession(); // Adjust based on your Redux structure const [messageApi, messageContextHolder] = message.useMessage(); // Add message API const hasShownConnectedMessage = useRef(false); // Add ref to track if message was shown + const isInitialized = useRef(false); // Track if socket is already initialized + const messageApiRef = useRef(messageApi); + const tRef = useRef(t); + + // Update refs when values change + useEffect(() => { + messageApiRef.current = messageApi; + }, [messageApi]); + + useEffect(() => { + tRef.current = t; + }, [t]); // Initialize socket connection useEffect(() => { - // Only create a new socket if one doesn't exist - if (!socketRef.current) { - socketRef.current = io(SOCKET_CONFIG.url, { + // Prevent duplicate initialization + if (isInitialized.current) { + return; + } + + // Only create a new socket if one doesn't exist globally or locally + if (!socketRef.current && !globalSocketInstance) { + isInitialized.current = true; + globalSocketInstance = io(SOCKET_CONFIG.url, { ...SOCKET_CONFIG.options, reconnection: true, reconnectionAttempts: Infinity, @@ -37,10 +58,18 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr reconnectionDelayMax: 5000, timeout: 20000, }); + socketRef.current = globalSocketInstance; + } else if (globalSocketInstance && !socketRef.current) { + // Reuse existing global socket instance + socketRef.current = globalSocketInstance; + isInitialized.current = true; } const socket = socketRef.current; + // Only proceed if socket exists + if (!socket) return; + // Set up event listeners before connecting socket.on('connect', () => { logger.info('Socket connected'); @@ -48,7 +77,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr // Only show connected message once if (!hasShownConnectedMessage.current) { - messageApi.success(t('connection-restored')); + messageApiRef.current.success(tRef.current('connection-restored')); hasShownConnectedMessage.current = true; } }); @@ -64,7 +93,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr socket.on('connect_error', error => { logger.error('Connection error', { error }); setConnected(false); - messageApi.error(t('connection-lost')); + messageApiRef.current.error(tRef.current('connection-lost')); // Reset the connected message flag on error hasShownConnectedMessage.current = false; }); @@ -72,7 +101,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr socket.on('disconnect', () => { logger.info('Socket disconnected'); setConnected(false); - messageApi.loading(t('reconnecting')); + messageApiRef.current.loading(tRef.current('reconnecting')); // Reset the connected message flag on disconnect hasShownConnectedMessage.current = false; @@ -121,10 +150,12 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr // Then close the connection socket.close(); socketRef.current = null; + globalSocketInstance = null; // Clear global instance hasShownConnectedMessage.current = false; // Reset on unmount + isInitialized.current = false; // Reset initialization flag } }; - }, [messageApi, t]); // Add messageApi and t to dependencies + }, []); // Remove dependencies to prevent re-initialization const value = { socket: socketRef.current, diff --git a/worklenz-frontend/src/styles/performance-optimizations.css b/worklenz-frontend/src/styles/performance-optimizations.css new file mode 100644 index 00000000..4907234a --- /dev/null +++ b/worklenz-frontend/src/styles/performance-optimizations.css @@ -0,0 +1,296 @@ +/* Performance Optimization Styles for Worklenz */ + +/* Layout shift prevention */ +.prevent-layout-shift { + contain: layout style; +} + +/* Efficient animations */ +.gpu-accelerated { + transform: translateZ(0); + will-change: transform; +} + +.efficient-transition { + transition: transform 0.2s ease-out, opacity 0.2s ease-out; +} + +/* Critical loading states */ +.critical-loading { + background: linear-gradient(90deg, #f0f0f0 25%, transparent 37%, #f0f0f0 63%); + background-size: 400% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +/* Font loading optimization */ +.font-loading { + font-display: swap; +} + +/* Container queries for responsive design */ +.container-responsive { + container-type: inline-size; +} + +@container (min-width: 300px) { + .container-responsive .content { + display: grid; + grid-template-columns: 1fr 1fr; + } +} + +/* CSS containment for performance */ +.layout-contained { + contain: layout; +} + +.paint-contained { + contain: paint; +} + +.size-contained { + contain: size; +} + +.style-contained { + contain: style; +} + +/* Optimized scrolling */ +.smooth-scroll { + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; +} + +/* Prevent repaints during animations */ +.animation-optimized { + backface-visibility: hidden; + perspective: 1000px; +} + +/* Critical path optimizations */ +.above-fold { + priority: 1; +} + +.below-fold { + priority: 0; +} + +/* Resource hints via CSS */ +.preload-critical::before { + content: ''; + display: block; + width: 0; + height: 0; + background-image: url('/critical-image.webp'); +} + +/* Task management specific optimizations */ +.task-list-board { + contain: layout style; +} + +.task-groups-container-fixed { + contain: strict; + transform: translateZ(0); +} + +.task-row { + contain: layout style; + will-change: transform; +} + +.task-row:hover { + transform: translateZ(0); +} + +/* Virtualized components */ +.virtualized-task-groups { + contain: strict; + transform: translateZ(0); +} + +/* Bulk action bar optimizations */ +.optimized-bulk-action-bar { + contain: layout style; + transform: translateZ(0); +} + +/* Loading state optimizations */ +.task-loading-skeleton { + contain: layout; + animation: shimmer 1.5s ease-in-out infinite; +} + +/* Avatar and image optimizations */ +.lazy-image { + opacity: 0; + transition: opacity 0.3s ease-in-out; +} + +.lazy-image.loaded { + opacity: 1; +} + +.lazy-image.loading { + background: linear-gradient(90deg, #f0f0f0 25%, transparent 37%, #f0f0f0 63%); + background-size: 400% 100%; + animation: shimmer 1s ease-in-out infinite; +} + +/* Reduce layout shifts for dynamic content */ +.task-content-container { + min-height: 40px; + contain-intrinsic-size: auto 40px; +} + +.project-content-container { + min-height: 60px; + contain-intrinsic-size: auto 60px; +} + +/* Performance-optimized grid layouts */ +.task-grid-optimized { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 16px; + contain: layout; +} + +.task-grid-optimized .task-card { + contain: layout style; + transform: translateZ(0); +} + +/* Dark mode optimizations */ +[data-theme="dark"] .critical-loading { + background: linear-gradient(90deg, #2a2a2a 25%, transparent 37%, #2a2a2a 63%); +} + +[data-theme="dark"] .lazy-image.loading { + background: linear-gradient(90deg, #2a2a2a 25%, transparent 37%, #2a2a2a 63%); +} + +/* Print optimizations */ +@media print { + .gpu-accelerated, + .animation-optimized { + transform: none; + will-change: auto; + animation: none; + } +} + +/* High contrast mode optimizations */ +@media (prefers-contrast: high) { + .critical-loading, + .lazy-image.loading { + background: repeating-linear-gradient( + 45deg, + transparent, + transparent 10px, + rgba(0, 0, 0, 0.1) 10px, + rgba(0, 0, 0, 0.1) 20px + ); + } +} + +/* Reduced motion optimizations */ +@media (prefers-reduced-motion: reduce) { + .efficient-transition, + .critical-loading, + .lazy-image { + animation: none; + transition: none; + } +} + +/* Viewport-based optimizations */ +@media (max-width: 768px) { + .task-grid-optimized { + grid-template-columns: 1fr; + } + + .prevent-layout-shift { + contain: layout; + } +} + +/* Memory-conscious styles for large lists */ +.large-list-container { + contain: strict; + content-visibility: auto; +} + +.large-list-item { + contain: layout style; + content-visibility: auto; + contain-intrinsic-size: auto 50px; +} + +/* Performance monitoring debug styles (dev only) */ +.performance-debug { + position: fixed; + top: 10px; + right: 10px; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 8px; + border-radius: 4px; + font-size: 12px; + z-index: 9999; + display: none; +} + +.performance-debug.visible { + display: block; +} + +/* Critical CSS for above-the-fold content */ +.critical-above-fold { + /* Reset and basic typography */ + box-sizing: border-box; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + + /* Layout grid */ + display: grid; + grid-template-areas: + "header header" + "sidebar main"; + grid-template-rows: auto 1fr; + grid-template-columns: 250px 1fr; + min-height: 100vh; + + /* Colors and spacing */ + background-color: #f5f5f5; + color: #333; + margin: 0; + padding: 0; +} + +/* Font display optimization */ +@font-face { + font-family: 'Inter'; + font-display: swap; + src: url('/fonts/inter-var.woff2') format('woff2'); +} + +/* Critical animations only */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.fade-in { + animation: fadeIn 0.3s ease-in-out; +} \ No newline at end of file diff --git a/worklenz-frontend/src/utils/asset-optimizations.ts b/worklenz-frontend/src/utils/asset-optimizations.ts new file mode 100644 index 00000000..89392847 --- /dev/null +++ b/worklenz-frontend/src/utils/asset-optimizations.ts @@ -0,0 +1,588 @@ +// Asset optimization utilities for improved performance + +// Image optimization constants +export const IMAGE_OPTIMIZATION = { + // Quality settings for different use cases + QUALITY: { + THUMBNAIL: 70, + AVATAR: 80, + CONTENT: 85, + HIGH_QUALITY: 95, + }, + + // Size presets for responsive images + SIZES: { + THUMBNAIL: { width: 64, height: 64 }, + AVATAR_SMALL: { width: 32, height: 32 }, + AVATAR_MEDIUM: { width: 48, height: 48 }, + AVATAR_LARGE: { width: 64, height: 64 }, + ICON_SMALL: { width: 16, height: 16 }, + ICON_MEDIUM: { width: 24, height: 24 }, + ICON_LARGE: { width: 32, height: 32 }, + CARD_IMAGE: { width: 300, height: 200 }, + }, + + // Supported formats in order of preference + FORMATS: ['webp', 'jpeg', 'png'], + + // Browser support detection + WEBP_SUPPORT: typeof window !== 'undefined' && + window.document?.createElement('canvas').toDataURL('image/webp').indexOf('webp') > -1, +} as const; + +// Asset caching strategies +export const CACHE_STRATEGIES = { + // Cache durations in seconds + DURATIONS: { + STATIC_ASSETS: 31536000, // 1 year + IMAGES: 2592000, // 30 days + AVATARS: 86400, // 1 day + DYNAMIC_CONTENT: 3600, // 1 hour + }, + + // Cache keys + KEYS: { + COMPRESSED_IMAGES: 'compressed_images', + AVATAR_CACHE: 'avatar_cache', + ICON_CACHE: 'icon_cache', + STATIC_ASSETS: 'static_assets', + }, +} as const; + +// Image compression utilities +export class ImageOptimizer { + private static canvas: HTMLCanvasElement | null = null; + private static ctx: CanvasRenderingContext2D | null = null; + + private static getCanvas(): HTMLCanvasElement { + if (!this.canvas) { + this.canvas = document.createElement('canvas'); + this.ctx = this.canvas.getContext('2d'); + } + return this.canvas; + } + + // Compress image with quality and size options + static async compressImage( + file: File | string, + options: { + quality?: number; + maxWidth?: number; + maxHeight?: number; + format?: 'jpeg' | 'webp' | 'png'; + } = {} + ): Promise { + const { + quality = IMAGE_OPTIMIZATION.QUALITY.CONTENT, + maxWidth = 1920, + maxHeight = 1080, + format = 'jpeg', + } = options; + + return new Promise((resolve, reject) => { + const img = new Image(); + + img.onload = () => { + try { + const canvas = this.getCanvas(); + const ctx = this.ctx!; + + // Calculate optimal dimensions + const { width, height } = this.calculateOptimalSize( + img.width, + img.height, + maxWidth, + maxHeight + ); + + canvas.width = width; + canvas.height = height; + + // Clear canvas and draw resized image + ctx.clearRect(0, 0, width, height); + ctx.drawImage(img, 0, 0, width, height); + + // Convert to optimized format + const mimeType = format === 'jpeg' ? 'image/jpeg' : + format === 'webp' ? 'image/webp' : 'image/png'; + + const compressedDataUrl = canvas.toDataURL(mimeType, quality / 100); + resolve(compressedDataUrl); + } catch (error) { + reject(error); + } + }; + + img.onerror = reject; + + if (typeof file === 'string') { + img.src = file; + } else { + const reader = new FileReader(); + reader.onload = (e) => { + img.src = e.target?.result as string; + }; + reader.readAsDataURL(file); + } + }); + } + + // Calculate optimal size maintaining aspect ratio + private static calculateOptimalSize( + originalWidth: number, + originalHeight: number, + maxWidth: number, + maxHeight: number + ): { width: number; height: number } { + const aspectRatio = originalWidth / originalHeight; + + let width = originalWidth; + let height = originalHeight; + + // Scale down if necessary + if (width > maxWidth) { + width = maxWidth; + height = width / aspectRatio; + } + + if (height > maxHeight) { + height = maxHeight; + width = height * aspectRatio; + } + + return { + width: Math.round(width), + height: Math.round(height), + }; + } + + // Generate responsive image srcSet + static generateSrcSet( + baseUrl: string, + sizes: Array<{ width: number; quality?: number }> + ): string { + return sizes + .map(({ width, quality = IMAGE_OPTIMIZATION.QUALITY.CONTENT }) => { + const url = `${baseUrl}?w=${width}&q=${quality}${ + IMAGE_OPTIMIZATION.WEBP_SUPPORT ? '&f=webp' : '' + }`; + return `${url} ${width}w`; + }) + .join(', '); + } + + // Create optimized avatar URL + static getOptimizedAvatarUrl( + baseUrl: string, + size: keyof typeof IMAGE_OPTIMIZATION.SIZES = 'AVATAR_MEDIUM' + ): string { + const dimensions = IMAGE_OPTIMIZATION.SIZES[size]; + const quality = IMAGE_OPTIMIZATION.QUALITY.AVATAR; + + return `${baseUrl}?w=${dimensions.width}&h=${dimensions.height}&q=${quality}${ + IMAGE_OPTIMIZATION.WEBP_SUPPORT ? '&f=webp' : '' + }`; + } + + // Create optimized icon URL + static getOptimizedIconUrl( + baseUrl: string, + size: keyof typeof IMAGE_OPTIMIZATION.SIZES = 'ICON_MEDIUM' + ): string { + const dimensions = IMAGE_OPTIMIZATION.SIZES[size]; + + return `${baseUrl}?w=${dimensions.width}&h=${dimensions.height}&q=100${ + IMAGE_OPTIMIZATION.WEBP_SUPPORT ? '&f=webp' : '' + }`; + } +} + +// Asset caching utilities +export class AssetCache { + private static cache = new Map(); + + // Set item in cache with TTL + static set(key: string, data: any, duration: number = CACHE_STRATEGIES.DURATIONS.DYNAMIC_CONTENT): void { + this.cache.set(key, { + data, + timestamp: Date.now(), + duration: duration * 1000, // Convert to milliseconds + }); + + // Clean up expired items periodically + if (this.cache.size % 50 === 0) { + this.cleanup(); + } + } + + // Get item from cache + static get(key: string): T | null { + const item = this.cache.get(key); + + if (!item) return null; + + // Check if expired + if (Date.now() - item.timestamp > item.duration) { + this.cache.delete(key); + return null; + } + + return item.data; + } + + // Remove expired items + static cleanup(): void { + const now = Date.now(); + for (const [key, item] of this.cache.entries()) { + if (now - item.timestamp > item.duration) { + this.cache.delete(key); + } + } + } + + // Clear all cache + static clear(): void { + this.cache.clear(); + } + + // Get cache size and statistics + static getStats(): { size: number; totalItems: number; hitRate: number } { + return { + size: this.cache.size, + totalItems: this.cache.size, + hitRate: 0, // Could be implemented with counters + }; + } +} + +// Lazy loading utilities +export class LazyLoader { + private static observer: IntersectionObserver | null = null; + private static loadedImages = new Set(); + + // Initialize intersection observer + private static getObserver(): IntersectionObserver { + if (!this.observer) { + this.observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const img = entry.target as HTMLImageElement; + this.loadImage(img); + this.observer?.unobserve(img); + } + }); + }, + { + rootMargin: '50px', // Start loading 50px before entering viewport + threshold: 0.1, + } + ); + } + return this.observer; + } + + // Setup lazy loading for an image + static setupLazyLoading(img: HTMLImageElement, src: string): void { + if (this.loadedImages.has(src)) { + img.src = src; + return; + } + + img.dataset.src = src; + img.classList.add('lazy-loading'); + this.getObserver().observe(img); + } + + // Load image and handle caching + private static loadImage(img: HTMLImageElement): void { + const src = img.dataset.src; + if (!src) return; + + // Check cache first + const cachedBlob = AssetCache.get(`image_${src}`); + if (cachedBlob) { + img.src = cachedBlob; + img.classList.remove('lazy-loading'); + img.classList.add('lazy-loaded'); + this.loadedImages.add(src); + return; + } + + // Load and cache image + const newImg = new Image(); + newImg.onload = () => { + img.src = src; + img.classList.remove('lazy-loading'); + img.classList.add('lazy-loaded'); + this.loadedImages.add(src); + + // Cache for future use + AssetCache.set(`image_${src}`, src, CACHE_STRATEGIES.DURATIONS.IMAGES); + }; + newImg.onerror = () => { + img.classList.remove('lazy-loading'); + img.classList.add('lazy-error'); + }; + newImg.src = src; + } + + // Preload critical images + static preloadCriticalImages(urls: string[]): Promise { + return Promise.all( + urls.map((url) => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + this.loadedImages.add(url); + AssetCache.set(`image_${url}`, url, CACHE_STRATEGIES.DURATIONS.IMAGES); + resolve(); + }; + img.onerror = reject; + img.src = url; + }); + }) + ); + } +} + +// Progressive loading utilities +export class ProgressiveLoader { + // Create progressive JPEG-like loading effect + static createProgressiveImage( + container: HTMLElement, + lowQualitySrc: string, + highQualitySrc: string + ): void { + const lowQualityImg = document.createElement('img'); + const highQualityImg = document.createElement('img'); + + // Style for smooth transition + const baseStyle = { + position: 'absolute' as const, + top: '0', + left: '0', + width: '100%', + height: '100%', + objectFit: 'cover' as const, + }; + + Object.assign(lowQualityImg.style, baseStyle, { + filter: 'blur(2px)', + transition: 'opacity 0.3s ease', + }); + + Object.assign(highQualityImg.style, baseStyle, { + opacity: '0', + transition: 'opacity 0.3s ease', + }); + + // Load low quality first + lowQualityImg.src = lowQualitySrc; + container.appendChild(lowQualityImg); + container.appendChild(highQualityImg); + + // Load high quality and fade in + highQualityImg.onload = () => { + highQualityImg.style.opacity = '1'; + setTimeout(() => { + lowQualityImg.remove(); + }, 300); + }; + highQualityImg.src = highQualitySrc; + } +} + +// Asset preloading strategies +export class AssetPreloader { + private static preloadedAssets = new Set(); + + // Preload assets based on priority + static preloadAssets(assets: Array<{ url: string; priority: 'high' | 'medium' | 'low' }>): void { + // Sort by priority + assets.sort((a, b) => { + const priorityOrder = { high: 0, medium: 1, low: 2 }; + return priorityOrder[a.priority] - priorityOrder[b.priority]; + }); + + // Preload high priority assets immediately + const highPriorityAssets = assets.filter(asset => asset.priority === 'high'); + this.preloadImmediately(highPriorityAssets.map(a => a.url)); + + // Preload medium priority assets after a short delay + setTimeout(() => { + const mediumPriorityAssets = assets.filter(asset => asset.priority === 'medium'); + this.preloadWithIdleCallback(mediumPriorityAssets.map(a => a.url)); + }, 100); + + // Preload low priority assets when browser is idle + setTimeout(() => { + const lowPriorityAssets = assets.filter(asset => asset.priority === 'low'); + this.preloadWithIdleCallback(lowPriorityAssets.map(a => a.url)); + }, 1000); + } + + // Immediate preloading for critical assets + private static preloadImmediately(urls: string[]): void { + urls.forEach(url => { + if (this.preloadedAssets.has(url)) return; + + const link = document.createElement('link'); + link.rel = 'preload'; + link.href = url; + + // Determine asset type + if (url.match(/\.(jpg|jpeg|png|webp|gif)$/i)) { + link.as = 'image'; + } else if (url.match(/\.(woff|woff2|ttf|otf)$/i)) { + link.as = 'font'; + link.crossOrigin = 'anonymous'; + } else if (url.match(/\.(css)$/i)) { + link.as = 'style'; + } else if (url.match(/\.(js)$/i)) { + link.as = 'script'; + } + + document.head.appendChild(link); + this.preloadedAssets.add(url); + }); + } + + // Preload with idle callback for non-critical assets + private static preloadWithIdleCallback(urls: string[]): void { + const preloadBatch = () => { + urls.forEach(url => { + if (this.preloadedAssets.has(url)) return; + + const img = new Image(); + img.src = url; + this.preloadedAssets.add(url); + }); + }; + + if ('requestIdleCallback' in window) { + (window as any).requestIdleCallback(preloadBatch, { timeout: 2000 }); + } else { + setTimeout(preloadBatch, 100); + } + } +} + +// CSS for optimized image loading +export const imageOptimizationStyles = ` +/* Lazy loading states */ +.lazy-loading { + background: linear-gradient(90deg, #f0f0f0 25%, transparent 37%, #f0f0f0 63%); + background-size: 400% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} + +.lazy-loaded { + animation: fadeIn 0.3s ease-in-out; +} + +.lazy-error { + background-color: #f5f5f5; + display: flex; + align-items: center; + justify-content: center; +} + +.lazy-error::after { + content: '⚠️'; + font-size: 24px; + opacity: 0.5; +} + +/* Shimmer animation for loading */ +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +/* Fade in animation */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Progressive image container */ +.progressive-image { + position: relative; + overflow: hidden; + background-color: #f5f5f5; +} + +/* Responsive image utilities */ +.responsive-image { + width: 100%; + height: auto; + max-width: 100%; +} + +/* Avatar optimization */ +.optimized-avatar { + border-radius: 50%; + object-fit: cover; + background-color: #e5e7eb; +} + +/* Icon optimization */ +.optimized-icon { + display: inline-block; + vertical-align: middle; +} + +/* Preload critical images */ +.critical-image { + object-fit: cover; + background-color: #f5f5f5; +} +`; + +// Utility functions +export const AssetUtils = { + // Get file size from data URL + getDataUrlSize: (dataUrl: string): number => { + const base64String = dataUrl.split(',')[1]; + return Math.round((base64String.length * 3) / 4); + }, + + // Convert file size to human readable format + formatFileSize: (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }, + + // Generate low quality placeholder + generatePlaceholder: (width: number, height: number, color: string = '#e5e7eb'): string => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + + canvas.width = width; + canvas.height = height; + + ctx.fillStyle = color; + ctx.fillRect(0, 0, width, height); + + return canvas.toDataURL('image/png'); + }, + + // Check if image is already cached + isImageCached: (url: string): boolean => { + return AssetCache.get(`image_${url}`) !== null; + }, + + // Prefetch critical resources + prefetchResources: (urls: string[]): void => { + urls.forEach(url => { + const link = document.createElement('link'); + link.rel = 'prefetch'; + link.href = url; + document.head.appendChild(link); + }); + }, +}; \ No newline at end of file diff --git a/worklenz-frontend/src/utils/css-optimizations.ts b/worklenz-frontend/src/utils/css-optimizations.ts new file mode 100644 index 00000000..8889e5e1 --- /dev/null +++ b/worklenz-frontend/src/utils/css-optimizations.ts @@ -0,0 +1,598 @@ +// CSS optimization utilities for improved performance and reduced layout shifts + +// Critical CSS constants +export const CSS_OPTIMIZATION = { + // Performance thresholds + THRESHOLDS: { + CRITICAL_CSS_SIZE: 14000, // 14KB critical CSS limit + INLINE_CSS_LIMIT: 4000, // 4KB inline CSS limit + UNUSED_CSS_THRESHOLD: 80, // Remove CSS with <80% usage + }, + + // Layout shift prevention + LAYOUT_PREVENTION: { + // Common aspect ratios for media + ASPECT_RATIOS: { + SQUARE: '1:1', + LANDSCAPE: '16:9', + PORTRAIT: '9:16', + CARD: '4:3', + WIDE: '21:9', + }, + + // Standard sizes for common elements + PLACEHOLDER_SIZES: { + AVATAR: { width: 40, height: 40 }, + BUTTON: { width: 120, height: 36 }, + INPUT: { width: 200, height: 40 }, + CARD: { width: 300, height: 200 }, + THUMBNAIL: { width: 64, height: 64 }, + }, + }, + + // CSS optimization strategies + STRATEGIES: { + CRITICAL_ABOVE_FOLD: ['layout', 'typography', 'colors', 'spacing'], + DEFER_BELOW_FOLD: ['animations', 'hover-effects', 'non-critical-components'], + INLINE_CRITICAL: ['reset', 'grid', 'typography', 'critical-components'], + }, +} as const; + +// CSS performance monitoring +export class CSSPerformanceMonitor { + private static metrics = { + layoutShifts: 0, + renderBlockingCSS: 0, + unusedCSS: 0, + criticalCSSSize: 0, + }; + + // Monitor Cumulative Layout Shift (CLS) + static monitorLayoutShifts(): () => void { + if (!('PerformanceObserver' in window)) { + return () => {}; + } + + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.entryType === 'layout-shift' && !(entry as any).hadRecentInput) { + this.metrics.layoutShifts += (entry as any).value; + } + } + }); + + observer.observe({ type: 'layout-shift', buffered: true }); + + return () => observer.disconnect(); + } + + // Monitor render-blocking resources + static monitorRenderBlocking(): void { + if ('PerformanceObserver' in window) { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.name.endsWith('.css') && (entry as any).renderBlockingStatus === 'blocking') { + this.metrics.renderBlockingCSS++; + } + } + }); + + observer.observe({ type: 'resource', buffered: true }); + } + } + + // Get current metrics + static getMetrics() { + return { ...this.metrics }; + } + + // Reset metrics + static reset(): void { + this.metrics = { + layoutShifts: 0, + renderBlockingCSS: 0, + unusedCSS: 0, + criticalCSSSize: 0, + }; + } +} + +// Layout shift prevention utilities +export class LayoutStabilizer { + // Create placeholder with known dimensions + static createPlaceholder( + element: HTMLElement, + dimensions: { width?: number; height?: number; aspectRatio?: string } + ): void { + const { width, height, aspectRatio } = dimensions; + + if (aspectRatio) { + element.style.aspectRatio = aspectRatio; + } + + if (width) { + element.style.width = `${width}px`; + } + + if (height) { + element.style.height = `${height}px`; + } + + // Prevent layout shifts during loading + element.style.minHeight = height ? `${height}px` : '1px'; + element.style.containIntrinsicSize = width && height ? `${width}px ${height}px` : 'auto'; + } + + // Reserve space for dynamic content + static reserveSpace( + container: HTMLElement, + estimatedHeight: number, + adjustOnLoad: boolean = true + ): () => void { + const originalHeight = container.style.height; + container.style.minHeight = `${estimatedHeight}px`; + + if (adjustOnLoad) { + const observer = new ResizeObserver(() => { + if (container.scrollHeight > estimatedHeight) { + container.style.minHeight = 'auto'; + observer.disconnect(); + } + }); + observer.observe(container); + + return () => observer.disconnect(); + } + + return () => { + container.style.height = originalHeight; + container.style.minHeight = 'auto'; + }; + } + + // Preload fonts to prevent text layout shifts + static preloadFonts(fontFaces: Array<{ family: string; weight?: string; style?: string }>): void { + fontFaces.forEach(({ family, weight = '400', style = 'normal' }) => { + const link = document.createElement('link'); + link.rel = 'preload'; + link.as = 'font'; + link.type = 'font/woff2'; + link.crossOrigin = 'anonymous'; + link.href = `/fonts/${family}-${weight}-${style}.woff2`; + document.head.appendChild(link); + }); + } + + // Apply size-based CSS containment + static applyContainment(element: HTMLElement, type: 'size' | 'layout' | 'style' | 'paint'): void { + element.style.contain = type; + } +} + +// Critical CSS management +export class CriticalCSSManager { + private static criticalCSS = new Set(); + private static deferredCSS = new Set(); + + // Identify critical CSS selectors + static identifyCriticalCSS(): string[] { + const criticalSelectors: string[] = []; + + // Get above-the-fold elements + const viewportHeight = window.innerHeight; + const aboveFoldElements = Array.from(document.querySelectorAll('*')).filter( + (el) => el.getBoundingClientRect().top < viewportHeight + ); + + // Extract CSS rules for above-the-fold elements + aboveFoldElements.forEach((element) => { + const computedStyle = window.getComputedStyle(element); + const tagName = element.tagName.toLowerCase(); + const className = element.className; + const id = element.id; + + // Add tag selectors + criticalSelectors.push(tagName); + + // Add class selectors + if (className) { + className.split(' ').forEach((cls) => { + criticalSelectors.push(`.${cls}`); + }); + } + + // Add ID selectors + if (id) { + criticalSelectors.push(`#${id}`); + } + }); + + return Array.from(new Set(criticalSelectors)); + } + + // Extract critical CSS + static async extractCriticalCSS(html: string, css: string): Promise { + // This is a simplified version - in production, use tools like critical or penthouse + const criticalSelectors = this.identifyCriticalCSS(); + const criticalRules: string[] = []; + + // Parse CSS and extract matching rules + const cssRules = css.split('}').map(rule => rule.trim() + '}'); + + cssRules.forEach((rule) => { + for (const selector of criticalSelectors) { + if (rule.includes(selector)) { + criticalRules.push(rule); + } + } + }); + + return criticalRules.join('\n'); + } + + // Inline critical CSS + static inlineCriticalCSS(css: string): void { + const style = document.createElement('style'); + style.textContent = css; + style.setAttribute('data-critical', 'true'); + document.head.insertBefore(style, document.head.firstChild); + } + + // Load non-critical CSS asynchronously + static loadNonCriticalCSS(href: string, media: string = 'all'): void { + const link = document.createElement('link'); + link.rel = 'preload'; + link.as = 'style'; + link.href = href; + link.media = 'print'; // Load as print to avoid blocking + link.onload = () => { + link.media = media; // Switch to target media once loaded + }; + document.head.appendChild(link); + } +} + +// CSS optimization utilities +export class CSSOptimizer { + // Remove unused CSS selectors + static removeUnusedCSS(css: string): string { + const usedSelectors = new Set(); + + // Get all elements and their classes/IDs + document.querySelectorAll('*').forEach((element) => { + usedSelectors.add(element.tagName.toLowerCase()); + + if (element.className) { + element.className.split(' ').forEach((cls) => { + usedSelectors.add(`.${cls}`); + }); + } + + if (element.id) { + usedSelectors.add(`#${element.id}`); + } + }); + + // Filter CSS rules + const cssRules = css.split('}'); + const optimizedRules = cssRules.filter((rule) => { + const selectorPart = rule.split('{')[0]; + if (!selectorPart) return false; + const selector = selectorPart.trim(); + if (!selector) return false; + + // Check if selector is used + return Array.from(usedSelectors).some((used) => + selector.includes(used) + ); + }); + + return optimizedRules.join('}'); + } + + // Minify CSS + static minifyCSS(css: string): string { + return css + // Remove comments + .replace(/\/\*[\s\S]*?\*\//g, '') + // Remove unnecessary whitespace + .replace(/\s+/g, ' ') + // Remove whitespace around selectors and properties + .replace(/\s*{\s*/g, '{') + .replace(/;\s*/g, ';') + .replace(/}\s*/g, '}') + // Remove trailing semicolons + .replace(/;}/g, '}') + .trim(); + } + + // Bundle CSS efficiently + static bundleCSS(cssFiles: string[]): Promise { + return Promise.all( + cssFiles.map(async (file) => { + const response = await fetch(file); + return response.text(); + }) + ).then((styles) => { + const bundled = styles.join('\n'); + return this.minifyCSS(bundled); + }); + } +} + +// Dynamic CSS loading utilities +export class DynamicCSSLoader { + private static loadedStylesheets = new Set(); + private static loadingPromises = new Map>(); + + // Load CSS on demand + static async loadCSS(href: string, options: { + media?: string; + priority?: 'high' | 'low'; + critical?: boolean; + } = {}): Promise { + const { media = 'all', priority = 'low', critical = false } = options; + + if (this.loadedStylesheets.has(href)) { + return Promise.resolve(); + } + + if (this.loadingPromises.has(href)) { + return this.loadingPromises.get(href)!; + } + + const promise = new Promise((resolve, reject) => { + const link = document.createElement('link'); + link.rel = critical ? 'stylesheet' : 'preload'; + link.as = critical ? undefined : 'style'; + link.href = href; + link.media = media; + + if (priority === 'high') { + link.setAttribute('importance', 'high'); + } + + link.onload = () => { + if (!critical) { + link.rel = 'stylesheet'; + } + this.loadedStylesheets.add(href); + this.loadingPromises.delete(href); + resolve(); + }; + + link.onerror = () => { + this.loadingPromises.delete(href); + reject(new Error(`Failed to load CSS: ${href}`)); + }; + + document.head.appendChild(link); + }); + + this.loadingPromises.set(href, promise); + return promise; + } + + // Load CSS based on component visibility + static loadCSSOnIntersection( + element: HTMLElement, + cssHref: string, + options: { rootMargin?: string; threshold?: number } = {} + ): () => void { + const { rootMargin = '100px', threshold = 0.1 } = options; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.loadCSS(cssHref); + observer.unobserve(element); + } + }); + }, + { rootMargin, threshold } + ); + + observer.observe(element); + + return () => observer.disconnect(); + } + + // Load CSS based on user interaction + static loadCSSOnInteraction( + element: HTMLElement, + cssHref: string, + events: string[] = ['mouseenter', 'touchstart'] + ): () => void { + const loadCSS = () => { + this.loadCSS(cssHref); + cleanup(); + }; + + const cleanup = () => { + events.forEach((event) => { + element.removeEventListener(event, loadCSS); + }); + }; + + events.forEach((event) => { + element.addEventListener(event, loadCSS, { once: true, passive: true }); + }); + + return cleanup; + } +} + +// CSS performance optimization styles +export const cssPerformanceStyles = ` +/* Layout shift prevention */ +.prevent-layout-shift { + contain: layout style; +} + +/* Efficient animations */ +.gpu-accelerated { + transform: translateZ(0); + will-change: transform; +} + +.efficient-transition { + transition: transform 0.2s ease-out, opacity 0.2s ease-out; +} + +/* Critical loading states */ +.critical-loading { + background: linear-gradient(90deg, #f0f0f0 25%, transparent 37%, #f0f0f0 63%); + background-size: 400% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} + +/* Font loading optimization */ +.font-loading { + font-display: swap; +} + +/* Container queries for responsive design */ +.container-responsive { + container-type: inline-size; +} + +@container (min-width: 300px) { + .container-responsive .content { + display: grid; + grid-template-columns: 1fr 1fr; + } +} + +/* CSS containment for performance */ +.layout-contained { + contain: layout; +} + +.paint-contained { + contain: paint; +} + +.size-contained { + contain: size; +} + +.style-contained { + contain: style; +} + +/* Optimized scrolling */ +.smooth-scroll { + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; +} + +/* Prevent repaints during animations */ +.animation-optimized { + backface-visibility: hidden; + perspective: 1000px; +} + +/* Critical path optimizations */ +.above-fold { + priority: 1; +} + +.below-fold { + priority: 0; +} + +/* Resource hints via CSS */ +.preload-critical::before { + content: ''; + display: block; + width: 0; + height: 0; + background-image: url('/critical-image.webp'); +} +`; + +// Utility functions for CSS optimization +export const CSSUtils = { + // Calculate CSS specificity + calculateSpecificity: (selector: string): number => { + const idCount = (selector.match(/#/g) || []).length; + const classCount = (selector.match(/\./g) || []).length; + const elementCount = (selector.match(/[a-zA-Z]/g) || []).length; + + return idCount * 100 + classCount * 10 + elementCount; + }, + + // Check if CSS property is supported + isPropertySupported: (property: string, value: string): boolean => { + const element = document.createElement('div'); + element.style.setProperty(property, value); + return element.style.getPropertyValue(property) === value; + }, + + // Get critical viewport CSS + getCriticalViewportCSS: (): { width: number; height: number; ratio: number } => { + return { + width: window.innerWidth, + height: window.innerHeight, + ratio: window.innerWidth / window.innerHeight, + }; + }, + + // Optimize CSS custom properties + optimizeCustomProperties: (css: string): string => { + // Group related custom properties + const optimized = css.replace( + /:root\s*{([^}]*)}/g, + (match, properties) => { + const sorted = properties + .split(';') + .filter((prop: string) => prop.trim()) + .sort() + .join(';'); + return `:root{${sorted}}`; + } + ); + + return optimized; + }, + + // Generate responsive CSS + generateResponsiveCSS: ( + selector: string, + properties: Record, + breakpoints: Record + ): string => { + let css = `${selector} { ${Object.entries(properties).map(([prop, value]) => `${prop}: ${value}`).join('; ')} }`; + + Object.entries(breakpoints).forEach(([breakpoint, mediaQuery]) => { + css += `\n@media ${mediaQuery} { ${selector} { /* responsive styles */ } }`; + }); + + return css; + }, + + // Check for CSS performance issues + checkPerformanceIssues: (css: string): string[] => { + const issues: string[] = []; + + // Check for expensive selectors + if (css.includes('*')) { + issues.push('Universal selector (*) detected - may impact performance'); + } + + // Check for inefficient descendant selectors + const deepSelectors = css.match(/(\w+\s+){4,}/g); + if (deepSelectors) { + issues.push('Deep descendant selectors detected - consider using more specific classes'); + } + + // Check for !important overuse + const importantCount = (css.match(/!important/g) || []).length; + if (importantCount > 10) { + issues.push('Excessive use of !important detected'); + } + + return issues; + }, +}; \ No newline at end of file diff --git a/worklenz-frontend/src/utils/enhanced-performance-monitoring.ts b/worklenz-frontend/src/utils/enhanced-performance-monitoring.ts new file mode 100644 index 00000000..9e50424e --- /dev/null +++ b/worklenz-frontend/src/utils/enhanced-performance-monitoring.ts @@ -0,0 +1,680 @@ +// Enhanced performance monitoring for Worklenz application + +// Performance monitoring constants +export const PERFORMANCE_CONFIG = { + // Measurement thresholds + THRESHOLDS: { + FCP: 1800, // First Contentful Paint (ms) + LCP: 2500, // Largest Contentful Paint (ms) + FID: 100, // First Input Delay (ms) + CLS: 0.1, // Cumulative Layout Shift + TTFB: 600, // Time to First Byte (ms) + INP: 200, // Interaction to Next Paint (ms) + }, + + // Monitoring intervals + INTERVALS: { + METRICS_COLLECTION: 5000, // 5 seconds + PERFORMANCE_REPORT: 30000, // 30 seconds + CLEANUP_THRESHOLD: 300000, // 5 minutes + }, + + // Buffer sizes + BUFFERS: { + MAX_ENTRIES: 1000, + MAX_RESOURCE_ENTRIES: 500, + MAX_NAVIGATION_ENTRIES: 100, + }, +} as const; + +// Performance metrics interface +export interface PerformanceMetrics { + // Core Web Vitals + fcp?: number; + lcp?: number; + fid?: number; + cls?: number; + ttfb?: number; + inp?: number; + + // Custom metrics + domContentLoaded?: number; + windowLoad?: number; + firstByte?: number; + + // Application-specific metrics + taskLoadTime?: number; + projectSwitchTime?: number; + filterApplyTime?: number; + bulkActionTime?: number; + + // Memory and performance + memoryUsage?: { + usedJSHeapSize: number; + totalJSHeapSize: number; + jsHeapSizeLimit: number; + }; + + // Timing information + timestamp: number; + url: string; + userAgent: string; +} + +// Performance monitoring class +export class EnhancedPerformanceMonitor { + private static instance: EnhancedPerformanceMonitor; + private metrics: PerformanceMetrics[] = []; + private observers: PerformanceObserver[] = []; + private intervalIds: NodeJS.Timeout[] = []; + private isMonitoring = false; + + // Singleton pattern + static getInstance(): EnhancedPerformanceMonitor { + if (!this.instance) { + this.instance = new EnhancedPerformanceMonitor(); + } + return this.instance; + } + + // Start comprehensive performance monitoring + startMonitoring(): void { + if (this.isMonitoring) return; + + this.isMonitoring = true; + this.setupObservers(); + this.collectInitialMetrics(); + this.startPeriodicCollection(); + + console.log('🚀 Enhanced performance monitoring started'); + } + + // Stop monitoring and cleanup + stopMonitoring(): void { + if (!this.isMonitoring) return; + + this.isMonitoring = false; + this.cleanupObservers(); + this.clearIntervals(); + + console.log('🛑 Enhanced performance monitoring stopped'); + } + + // Setup performance observers + private setupObservers(): void { + if (!('PerformanceObserver' in window)) return; + + // Core Web Vitals observer + try { + const vitalsObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + this.processVitalMetric(entry); + } + }); + + vitalsObserver.observe({ + type: 'largest-contentful-paint', + buffered: true + }); + + vitalsObserver.observe({ + type: 'first-input', + buffered: true + }); + + vitalsObserver.observe({ + type: 'layout-shift', + buffered: true + }); + + this.observers.push(vitalsObserver); + } catch (error) { + console.warn('Failed to setup vitals observer:', error); + } + + // Navigation timing observer + try { + const navigationObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + this.processNavigationMetric(entry as PerformanceNavigationTiming); + } + }); + + navigationObserver.observe({ + type: 'navigation', + buffered: true + }); + + this.observers.push(navigationObserver); + } catch (error) { + console.warn('Failed to setup navigation observer:', error); + } + + // Resource timing observer + try { + const resourceObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + this.processResourceMetric(entry as PerformanceResourceTiming); + } + }); + + resourceObserver.observe({ + type: 'resource', + buffered: true + }); + + this.observers.push(resourceObserver); + } catch (error) { + console.warn('Failed to setup resource observer:', error); + } + + // Measure observer + try { + const measureObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + this.processCustomMeasure(entry as PerformanceMeasure); + } + }); + + measureObserver.observe({ + type: 'measure', + buffered: true + }); + + this.observers.push(measureObserver); + } catch (error) { + console.warn('Failed to setup measure observer:', error); + } + } + + // Process Core Web Vitals metrics + private processVitalMetric(entry: PerformanceEntry): void { + const metric: Partial = { + timestamp: Date.now(), + url: window.location.href, + userAgent: navigator.userAgent, + }; + + switch (entry.entryType) { + case 'largest-contentful-paint': + metric.lcp = entry.startTime; + break; + case 'first-input': + metric.fid = (entry as any).processingStart - entry.startTime; + break; + case 'layout-shift': + if (!(entry as any).hadRecentInput) { + metric.cls = (metric.cls || 0) + (entry as any).value; + } + break; + } + + this.addMetric(metric as PerformanceMetrics); + } + + // Process navigation timing metrics + private processNavigationMetric(entry: PerformanceNavigationTiming): void { + const metric: PerformanceMetrics = { + fcp: this.getFCP(), + ttfb: entry.responseStart - entry.requestStart, + domContentLoaded: entry.domContentLoadedEventEnd - entry.startTime, + windowLoad: entry.loadEventEnd - entry.startTime, + firstByte: entry.responseStart - entry.startTime, + timestamp: Date.now(), + url: window.location.href, + userAgent: navigator.userAgent, + }; + + this.addMetric(metric); + } + + // Process resource timing metrics + private processResourceMetric(entry: PerformanceResourceTiming): void { + // Track slow resources + const duration = entry.responseEnd - entry.requestStart; + + if (duration > 1000) { // Resources taking more than 1 second + console.warn(`Slow resource detected: ${entry.name} (${duration.toFixed(2)}ms)`); + } + + // Track render-blocking resources (check if property exists) + if ((entry as any).renderBlockingStatus === 'blocking') { + console.warn(`Render-blocking resource: ${entry.name}`); + } + } + + // Process custom performance measures + private processCustomMeasure(entry: PerformanceMeasure): void { + const metric: Partial = { + timestamp: Date.now(), + url: window.location.href, + userAgent: navigator.userAgent, + }; + + // Map custom measures to metrics + switch (entry.name) { + case 'task-load-time': + metric.taskLoadTime = entry.duration; + break; + case 'project-switch-time': + metric.projectSwitchTime = entry.duration; + break; + case 'filter-apply-time': + metric.filterApplyTime = entry.duration; + break; + case 'bulk-action-time': + metric.bulkActionTime = entry.duration; + break; + } + + if (Object.keys(metric).length > 3) { + this.addMetric(metric as PerformanceMetrics); + } + } + + // Get First Contentful Paint + private getFCP(): number | undefined { + const fcpEntry = performance.getEntriesByType('paint') + .find(entry => entry.name === 'first-contentful-paint'); + return fcpEntry?.startTime; + } + + // Collect initial metrics + private collectInitialMetrics(): void { + const metric: PerformanceMetrics = { + fcp: this.getFCP(), + timestamp: Date.now(), + url: window.location.href, + userAgent: navigator.userAgent, + }; + + // Add memory information if available + if ('memory' in performance) { + metric.memoryUsage = { + usedJSHeapSize: (performance as any).memory.usedJSHeapSize, + totalJSHeapSize: (performance as any).memory.totalJSHeapSize, + jsHeapSizeLimit: (performance as any).memory.jsHeapSizeLimit, + }; + } + + this.addMetric(metric); + } + + // Start periodic metrics collection + private startPeriodicCollection(): void { + // Collect metrics every 5 seconds + const metricsInterval = setInterval(() => { + this.collectPeriodicMetrics(); + }, PERFORMANCE_CONFIG.INTERVALS.METRICS_COLLECTION); + + // Generate performance report every 30 seconds + const reportInterval = setInterval(() => { + this.generatePerformanceReport(); + }, PERFORMANCE_CONFIG.INTERVALS.PERFORMANCE_REPORT); + + // Cleanup old metrics every 5 minutes + const cleanupInterval = setInterval(() => { + this.cleanupOldMetrics(); + }, PERFORMANCE_CONFIG.INTERVALS.CLEANUP_THRESHOLD); + + this.intervalIds.push(metricsInterval, reportInterval, cleanupInterval); + } + + // Collect periodic metrics + private collectPeriodicMetrics(): void { + const metric: PerformanceMetrics = { + timestamp: Date.now(), + url: window.location.href, + userAgent: navigator.userAgent, + }; + + // Add memory information if available + if ('memory' in performance) { + metric.memoryUsage = { + usedJSHeapSize: (performance as any).memory.usedJSHeapSize, + totalJSHeapSize: (performance as any).memory.totalJSHeapSize, + jsHeapSizeLimit: (performance as any).memory.jsHeapSizeLimit, + }; + } + + this.addMetric(metric); + } + + // Add metric to collection + private addMetric(metric: PerformanceMetrics): void { + this.metrics.push(metric); + + // Limit buffer size + if (this.metrics.length > PERFORMANCE_CONFIG.BUFFERS.MAX_ENTRIES) { + this.metrics = this.metrics.slice(-PERFORMANCE_CONFIG.BUFFERS.MAX_ENTRIES); + } + } + + // Generate performance report + private generatePerformanceReport(): void { + if (this.metrics.length === 0) return; + + const recent = this.metrics.slice(-10); // Last 10 metrics + const report = this.analyzeMetrics(recent); + + console.log('📊 Performance Report:', report); + + // Check for performance issues + this.checkPerformanceIssues(report); + } + + // Analyze metrics and generate insights + private analyzeMetrics(metrics: PerformanceMetrics[]): any { + const validMetrics = metrics.filter(m => m); + + if (validMetrics.length === 0) return {}; + + const report: any = { + timestamp: Date.now(), + sampleSize: validMetrics.length, + }; + + // Analyze each metric + ['fcp', 'lcp', 'fid', 'cls', 'ttfb', 'taskLoadTime', 'projectSwitchTime'].forEach(metric => { + const values = validMetrics + .map(m => (m as any)[metric]) + .filter(v => v !== undefined); + + if (values.length > 0) { + report[metric] = { + avg: values.reduce((a, b) => a + b, 0) / values.length, + min: Math.min(...values), + max: Math.max(...values), + latest: values[values.length - 1], + }; + } + }); + + // Memory analysis + const memoryMetrics = validMetrics + .map(m => m.memoryUsage) + .filter(m => m !== undefined); + + if (memoryMetrics.length > 0) { + const latest = memoryMetrics[memoryMetrics.length - 1]; + report.memory = { + usedMB: (latest.usedJSHeapSize / 1024 / 1024).toFixed(2), + totalMB: (latest.totalJSHeapSize / 1024 / 1024).toFixed(2), + usage: ((latest.usedJSHeapSize / latest.totalJSHeapSize) * 100).toFixed(2) + '%', + }; + } + + return report; + } + + // Check for performance issues + private checkPerformanceIssues(report: any): void { + const issues: string[] = []; + + // Check Core Web Vitals + if (report.fcp?.latest > PERFORMANCE_CONFIG.THRESHOLDS.FCP) { + issues.push(`FCP is slow: ${report.fcp.latest.toFixed(2)}ms (threshold: ${PERFORMANCE_CONFIG.THRESHOLDS.FCP}ms)`); + } + + if (report.lcp?.latest > PERFORMANCE_CONFIG.THRESHOLDS.LCP) { + issues.push(`LCP is slow: ${report.lcp.latest.toFixed(2)}ms (threshold: ${PERFORMANCE_CONFIG.THRESHOLDS.LCP}ms)`); + } + + if (report.fid?.latest > PERFORMANCE_CONFIG.THRESHOLDS.FID) { + issues.push(`FID is high: ${report.fid.latest.toFixed(2)}ms (threshold: ${PERFORMANCE_CONFIG.THRESHOLDS.FID}ms)`); + } + + if (report.cls?.latest > PERFORMANCE_CONFIG.THRESHOLDS.CLS) { + issues.push(`CLS is high: ${report.cls.latest.toFixed(3)} (threshold: ${PERFORMANCE_CONFIG.THRESHOLDS.CLS})`); + } + + // Check application-specific metrics + if (report.taskLoadTime?.latest > 1000) { + issues.push(`Task loading is slow: ${report.taskLoadTime.latest.toFixed(2)}ms`); + } + + if (report.projectSwitchTime?.latest > 500) { + issues.push(`Project switching is slow: ${report.projectSwitchTime.latest.toFixed(2)}ms`); + } + + // Check memory usage + if (report.memory && parseFloat(report.memory.usage) > 80) { + issues.push(`High memory usage: ${report.memory.usage}`); + } + + // Log issues + if (issues.length > 0) { + console.warn('⚠️ Performance Issues Detected:'); + issues.forEach(issue => console.warn(` - ${issue}`)); + } + } + + // Cleanup old metrics + private cleanupOldMetrics(): void { + const fiveMinutesAgo = Date.now() - PERFORMANCE_CONFIG.INTERVALS.CLEANUP_THRESHOLD; + this.metrics = this.metrics.filter(metric => metric.timestamp > fiveMinutesAgo); + } + + // Cleanup observers + private cleanupObservers(): void { + this.observers.forEach(observer => observer.disconnect()); + this.observers = []; + } + + // Clear intervals + private clearIntervals(): void { + this.intervalIds.forEach(id => clearInterval(id)); + this.intervalIds = []; + } + + // Get current metrics + getMetrics(): PerformanceMetrics[] { + return [...this.metrics]; + } + + // Get performance summary + getPerformanceSummary(): any { + return this.analyzeMetrics(this.metrics); + } + + // Export metrics for analysis + exportMetrics(): string { + return JSON.stringify({ + timestamp: Date.now(), + metrics: this.metrics, + summary: this.getPerformanceSummary(), + }, null, 2); + } +} + +// Custom performance measurement utilities +export class CustomPerformanceMeasurer { + private static marks = new Map(); + + // Mark start of operation + static mark(name: string): void { + if ('performance' in window && 'mark' in performance) { + performance.mark(`${name}-start`); + } + this.marks.set(name, Date.now()); + } + + // Measure operation duration + static measure(name: string): number { + const startTime = this.marks.get(name); + const endTime = Date.now(); + + if (startTime) { + const duration = endTime - startTime; + + if ('performance' in window && 'measure' in performance) { + try { + performance.measure(name, `${name}-start`); + } catch (error) { + console.warn(`Failed to create performance measure for ${name}:`, error); + } + } + + this.marks.delete(name); + return duration; + } + + return 0; + } + + // Measure async operation + static async measureAsync(name: string, operation: () => Promise): Promise { + this.mark(name); + try { + const result = await operation(); + this.measure(name); + return result; + } catch (error) { + this.measure(name); + throw error; + } + } + + // Measure function execution + static measureFunction( + name: string, + fn: (...args: T) => R + ): (...args: T) => R { + return (...args: T): R => { + this.mark(name); + try { + const result = fn(...args); + this.measure(name); + return result; + } catch (error) { + this.measure(name); + throw error; + } + }; + } +} + +// Performance optimization recommendations +export class PerformanceOptimizer { + // Analyze and provide optimization recommendations + static analyzeAndRecommend(metrics: PerformanceMetrics[]): string[] { + const recommendations: string[] = []; + const latest = metrics[metrics.length - 1]; + + if (!latest) return recommendations; + + // FCP recommendations + if (latest.fcp && latest.fcp > PERFORMANCE_CONFIG.THRESHOLDS.FCP) { + recommendations.push( + 'Consider optimizing critical rendering path: inline critical CSS, reduce render-blocking resources' + ); + } + + // LCP recommendations + if (latest.lcp && latest.lcp > PERFORMANCE_CONFIG.THRESHOLDS.LCP) { + recommendations.push( + 'Optimize Largest Contentful Paint: compress images, preload critical resources, improve server response times' + ); + } + + // Memory recommendations + if (latest.memoryUsage) { + const usagePercent = (latest.memoryUsage.usedJSHeapSize / latest.memoryUsage.totalJSHeapSize) * 100; + + if (usagePercent > 80) { + recommendations.push( + 'High memory usage detected: implement cleanup routines, check for memory leaks, optimize data structures' + ); + } + } + + // Task loading recommendations + if (latest.taskLoadTime && latest.taskLoadTime > 1000) { + recommendations.push( + 'Task loading is slow: implement pagination, optimize database queries, add loading states' + ); + } + + return recommendations; + } + + // Get optimization priority + static getOptimizationPriority(metrics: PerformanceMetrics[]): Array<{metric: string, priority: 'high' | 'medium' | 'low', value: number}> { + const latest = metrics[metrics.length - 1]; + if (!latest) return []; + + const priorities: Array<{metric: string, priority: 'high' | 'medium' | 'low', value: number}> = []; + + // Check each metric against thresholds + if (latest.fcp) { + const ratio = latest.fcp / PERFORMANCE_CONFIG.THRESHOLDS.FCP; + priorities.push({ + metric: 'First Contentful Paint', + priority: ratio > 2 ? 'high' : ratio > 1.5 ? 'medium' : 'low', + value: latest.fcp, + }); + } + + if (latest.lcp) { + const ratio = latest.lcp / PERFORMANCE_CONFIG.THRESHOLDS.LCP; + priorities.push({ + metric: 'Largest Contentful Paint', + priority: ratio > 2 ? 'high' : ratio > 1.5 ? 'medium' : 'low', + value: latest.lcp, + }); + } + + if (latest.cls) { + const ratio = latest.cls / PERFORMANCE_CONFIG.THRESHOLDS.CLS; + priorities.push({ + metric: 'Cumulative Layout Shift', + priority: ratio > 3 ? 'high' : ratio > 2 ? 'medium' : 'low', + value: latest.cls, + }); + } + + return priorities.sort((a, b) => { + const priorityOrder = { high: 3, medium: 2, low: 1 }; + return priorityOrder[b.priority] - priorityOrder[a.priority]; + }); + } +} + +// Track if performance monitoring has been initialized +let isInitialized = false; + +// Initialize performance monitoring +export const initializePerformanceMonitoring = (): void => { + // Prevent duplicate initialization + if (isInitialized) { + console.warn('Performance monitoring already initialized'); + return; + } + + isInitialized = true; + const monitor = EnhancedPerformanceMonitor.getInstance(); + monitor.startMonitoring(); + + // Cleanup on page unload + const cleanup = () => { + monitor.stopMonitoring(); + isInitialized = false; + }; + + window.addEventListener('beforeunload', cleanup); + + // Also cleanup on page visibility change (tab switching) + window.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + cleanup(); + } + }); +}; + +// Export global performance utilities +export const performanceUtils = { + monitor: EnhancedPerformanceMonitor.getInstance(), + measurer: CustomPerformanceMeasurer, + optimizer: PerformanceOptimizer, + initialize: initializePerformanceMonitoring, +}; \ No newline at end of file diff --git a/worklenz-frontend/src/utils/redux-optimizations.ts b/worklenz-frontend/src/utils/redux-optimizations.ts new file mode 100644 index 00000000..e7a9c505 --- /dev/null +++ b/worklenz-frontend/src/utils/redux-optimizations.ts @@ -0,0 +1,319 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { shallowEqual } from 'react-redux'; +import { RootState } from '@/app/store'; +import { Task } from '@/types/task-management.types'; + +// Performance-optimized selectors using createSelector for memoization + +// Basic state selectors (these will be cached) +const selectTaskManagementState = (state: RootState) => state.taskManagement; +const selectTaskReducerState = (state: RootState) => state.taskReducer; +const selectThemeState = (state: RootState) => state.themeReducer; +const selectTeamMembersState = (state: RootState) => state.teamMembersReducer; +const selectTaskStatusState = (state: RootState) => state.taskStatusReducer; +const selectPriorityState = (state: RootState) => state.priorityReducer; +const selectPhaseState = (state: RootState) => state.phaseReducer; +const selectTaskLabelsState = (state: RootState) => state.taskLabelsReducer; + +// Memoized task selectors +export const selectOptimizedAllTasks = createSelector( + [selectTaskManagementState], + (taskManagementState) => Object.values(taskManagementState.entities || {}) +); + +export const selectOptimizedTasksById = createSelector( + [selectTaskManagementState], + (taskManagementState) => taskManagementState.entities || {} +); + +export const selectOptimizedTaskGroups = createSelector( + [selectTaskManagementState], + (taskManagementState) => taskManagementState.groups || [] +); + +export const selectOptimizedCurrentGrouping = createSelector( + [selectTaskManagementState], + (taskManagementState) => taskManagementState.grouping || 'status' +); + +export const selectOptimizedLoading = createSelector( + [selectTaskManagementState], + (taskManagementState) => taskManagementState.loading || false +); + +export const selectOptimizedError = createSelector( + [selectTaskManagementState], + (taskManagementState) => taskManagementState.error +); + +export const selectOptimizedSearch = createSelector( + [selectTaskManagementState], + (taskManagementState) => taskManagementState.search || '' +); + +export const selectOptimizedArchived = createSelector( + [selectTaskManagementState], + (taskManagementState) => taskManagementState.archived || false +); + +// Theme selectors +export const selectOptimizedIsDarkMode = createSelector( + [selectThemeState], + (themeState) => themeState?.mode === 'dark' +); + +export const selectOptimizedThemeMode = createSelector( + [selectThemeState], + (themeState) => themeState?.mode || 'light' +); + +// Team members selectors +export const selectOptimizedTeamMembers = createSelector( + [selectTeamMembersState], + (teamMembersState) => teamMembersState.teamMembers || [] +); + +export const selectOptimizedTeamMembersById = createSelector( + [selectOptimizedTeamMembers], + (teamMembers) => { + if (!Array.isArray(teamMembers)) return {}; + const membersById: Record = {}; + teamMembers.forEach((member: any) => { + membersById[member.id] = member; + }); + return membersById; + } +); + +// Task status selectors +export const selectOptimizedTaskStatuses = createSelector( + [selectTaskStatusState], + (taskStatusState) => taskStatusState.status || [] +); + +export const selectOptimizedTaskStatusCategories = createSelector( + [selectTaskStatusState], + (taskStatusState) => taskStatusState.statusCategories || [] +); + +// Priority selectors +export const selectOptimizedPriorities = createSelector( + [selectPriorityState], + (priorityState) => priorityState.priorities || [] +); + +// Phase selectors +export const selectOptimizedPhases = createSelector( + [selectPhaseState], + (phaseState) => phaseState.phaseList || [] +); + +// Labels selectors +export const selectOptimizedLabels = createSelector( + [selectTaskLabelsState], + (labelsState) => labelsState.labels || [] +); + +// Complex computed selectors +export const selectOptimizedTasksByGroup = createSelector( + [selectOptimizedAllTasks, selectOptimizedTaskGroups], + (tasks, groups) => { + const tasksByGroup: Record = {}; + + groups.forEach((group: any) => { + tasksByGroup[group.id] = group.tasks || []; + }); + + return tasksByGroup; + } +); + +export const selectOptimizedTaskCounts = createSelector( + [selectOptimizedTasksByGroup], + (tasksByGroup) => { + const counts: Record = {}; + Object.keys(tasksByGroup).forEach(groupId => { + counts[groupId] = tasksByGroup[groupId].length; + }); + return counts; + } +); + +export const selectOptimizedTotalTaskCount = createSelector( + [selectOptimizedAllTasks], + (tasks) => tasks.length +); + +// Selection state selectors +export const selectOptimizedSelectedTaskIds = createSelector( + [(state: RootState) => state.taskManagementSelection?.selectedTaskIds], + (selectedTaskIds) => selectedTaskIds || [] +); + +export const selectOptimizedSelectedTasksCount = createSelector( + [selectOptimizedSelectedTaskIds], + (selectedTaskIds) => selectedTaskIds.length +); + +export const selectOptimizedSelectedTasks = createSelector( + [selectOptimizedAllTasks, selectOptimizedSelectedTaskIds], + (tasks, selectedTaskIds) => { + return tasks.filter((task: Task) => selectedTaskIds.includes(task.id)); + } +); + +// Performance utilities +export const createShallowEqualSelector = ( + selector: (state: RootState) => T +) => { + let lastResult: T; + let lastArgs: any; + + return (state: RootState): T => { + const newArgs = selector(state); + + if (!shallowEqual(newArgs, lastArgs)) { + lastArgs = newArgs; + lastResult = newArgs; + } + + return lastResult; + }; +}; + +// Memoized equality functions for React.memo +export const taskPropsAreEqual = ( + prevProps: any, + nextProps: any +): boolean => { + // Quick reference checks first + if (prevProps.task === nextProps.task) return true; + if (!prevProps.task || !nextProps.task) return false; + if (prevProps.task.id !== nextProps.task.id) return false; + + // Check other props + if (prevProps.isSelected !== nextProps.isSelected) return false; + if (prevProps.isDragOverlay !== nextProps.isDragOverlay) return false; + if (prevProps.groupId !== nextProps.groupId) return false; + if (prevProps.currentGrouping !== nextProps.currentGrouping) return false; + if (prevProps.level !== nextProps.level) return false; + + // Deep comparison for task properties that commonly change + const taskProps = [ + 'title', + 'progress', + 'status', + 'priority', + 'description', + 'startDate', + 'dueDate', + 'updatedAt', + 'sub_tasks_count', + 'show_sub_tasks' + ]; + + for (const prop of taskProps) { + if (prevProps.task[prop] !== nextProps.task[prop]) { + return false; + } + } + + // Compare arrays with shallow equality + if (!shallowEqual(prevProps.task.assignees, nextProps.task.assignees)) { + return false; + } + + if (!shallowEqual(prevProps.task.labels, nextProps.task.labels)) { + return false; + } + + return true; +}; + +export const taskGroupPropsAreEqual = ( + prevProps: any, + nextProps: any +): boolean => { + // Quick reference checks + if (prevProps.group === nextProps.group) return true; + if (!prevProps.group || !nextProps.group) return false; + if (prevProps.group.id !== nextProps.group.id) return false; + + // Check task lists + if (!shallowEqual(prevProps.group.taskIds, nextProps.group.taskIds)) { + return false; + } + + // Check other props + if (prevProps.projectId !== nextProps.projectId) return false; + if (prevProps.currentGrouping !== nextProps.currentGrouping) return false; + if (!shallowEqual(prevProps.selectedTaskIds, nextProps.selectedTaskIds)) { + return false; + } + + return true; +}; + +// Performance monitoring utilities +export const createPerformanceSelector = ( + selector: (state: RootState) => T, + name: string +) => { + return createSelector( + [selector], + (result) => { + if (process.env.NODE_ENV === 'development') { + const startTime = performance.now(); + const endTime = performance.now(); + const duration = endTime - startTime; + + if (duration > 5) { + console.warn(`Slow selector ${name}: ${duration.toFixed(2)}ms`); + } + } + return result; + } + ); +}; + +// Utility to create batched state updates +export const createBatchedStateUpdate = ( + updateFn: (updates: T[]) => void, + delay: number = 16 // One frame +) => { + let pending: T[] = []; + let timeoutId: NodeJS.Timeout | null = null; + + return (update: T) => { + pending.push(update); + + if (timeoutId) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + const updates = [...pending]; + pending = []; + timeoutId = null; + updateFn(updates); + }, delay); + }; +}; + +// Performance monitoring hook +export const useReduxPerformanceMonitor = () => { + if (process.env.NODE_ENV === 'development') { + const startTime = performance.now(); + + return () => { + const endTime = performance.now(); + const duration = endTime - startTime; + + if (duration > 16) { + console.warn(`Slow Redux operation: ${duration.toFixed(2)}ms`); + } + }; + } + + return () => {}; // No-op in production +}; \ No newline at end of file From 0e083868cb9539ae56727b4d4fdcbdd0bc0c2c42 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 09:37:50 +0530 Subject: [PATCH 28/49] chore: moved locale files --- .../src/public/locales/alb/404-page.json | 4 + .../src/public/locales/alb/account-setup.json | 31 ++ .../alb/admin-center/current-bill.json | 113 ++++++ .../locales/alb/admin-center/overview.json | 8 + .../locales/alb/admin-center/projects.json | 12 + .../locales/alb/admin-center/sidebar.json | 8 + .../locales/alb/admin-center/teams.json | 33 ++ .../locales/alb/admin-center/users.json | 9 + .../public/locales/alb/all-project-list.json | 34 ++ .../public/locales/alb/auth/auth-common.json | 5 + .../locales/alb/auth/forgot-password.json | 12 + .../src/public/locales/alb/auth/login.json | 27 ++ .../src/public/locales/alb/auth/signup.json | 29 ++ .../locales/alb/auth/verify-reset-email.json | 14 + .../src/public/locales/alb/common.json | 9 + .../alb/create-first-project-form.json | 13 + .../locales/alb/create-first-tasks.json | 7 + .../src/public/locales/alb/home.json | 46 +++ .../alb/invite-initial-team-members.json | 8 + .../src/public/locales/alb/kanban-board.json | 30 ++ .../public/locales/alb/license-expired.json | 6 + .../src/public/locales/alb/navbar.json | 31 ++ .../locales/alb/organization-name-form.json | 5 + .../src/public/locales/alb/phases-drawer.json | 19 + .../public/locales/alb/project-drawer.json | 42 ++ .../locales/alb/project-view-files.json | 14 + .../locales/alb/project-view-insights.json | 41 ++ .../locales/alb/project-view-members.json | 17 + .../locales/alb/project-view-updates.json | 6 + .../src/public/locales/alb/project-view.json | 14 + .../project-view/import-task-templates.json | 11 + .../project-view/project-member-drawer.json | 7 + .../alb/project-view/project-view-header.json | 30 ++ .../alb/project-view/save-as-template.json | 27 ++ .../locales/alb/reporting-members-drawer.json | 90 +++++ .../public/locales/alb/reporting-members.json | 35 ++ .../alb/reporting-overview-drawer.json | 39 ++ .../locales/alb/reporting-overview.json | 25 ++ .../alb/reporting-projects-drawer.json | 59 +++ .../alb/reporting-projects-filters.json | 35 ++ .../locales/alb/reporting-projects.json | 52 +++ .../public/locales/alb/reporting-sidebar.json | 8 + .../src/public/locales/alb/schedule.json | 39 ++ .../locales/alb/settings/categories.json | 10 + .../locales/alb/settings/change-password.json | 15 + .../public/locales/alb/settings/clients.json | 22 ++ .../locales/alb/settings/job-titles.json | 20 + .../public/locales/alb/settings/labels.json | 11 + .../public/locales/alb/settings/language.json | 7 + .../locales/alb/settings/notifications.json | 11 + .../public/locales/alb/settings/profile.json | 14 + .../alb/settings/project-templates.json | 8 + .../public/locales/alb/settings/sidebar.json | 14 + .../locales/alb/settings/task-templates.json | 9 + .../locales/alb/settings/team-members.json | 47 +++ .../public/locales/alb/settings/teams.json | 16 + .../alb/task-drawer/task-drawer-info-tab.json | 29 ++ .../locales/alb/task-drawer/task-drawer.json | 123 ++++++ .../public/locales/alb/task-list-filters.json | 85 ++++ .../public/locales/alb/task-list-table.json | 136 +++++++ .../public/locales/alb/task-management.json | 21 + .../locales/alb/task-template-drawer.json | 11 + .../alb/tasks/task-table-bulk-actions.json | 26 ++ .../public/locales/alb/template-drawer.json | 19 + .../public/locales/alb/templateDrawer.json | 23 ++ .../src/public/locales/alb/time-report.json | 44 +++ .../src/public/locales/alb/unauthorized.json | 5 + .../src/public/locales/de/404-page.json | 4 + .../src/public/locales/de/account-setup.json | 31 ++ .../locales/de/admin-center/current-bill.json | 113 ++++++ .../locales/de/admin-center/overview.json | 8 + .../locales/de/admin-center/projects.json | 12 + .../locales/de/admin-center/sidebar.json | 8 + .../public/locales/de/admin-center/teams.json | 33 ++ .../public/locales/de/admin-center/users.json | 9 + .../public/locales/de/all-project-list.json | 34 ++ .../public/locales/de/auth/auth-common.json | 5 + .../locales/de/auth/forgot-password.json | 12 + .../src/public/locales/de/auth/login.json | 27 ++ .../src/public/locales/de/auth/signup.json | 29 ++ .../locales/de/auth/verify-reset-email.json | 14 + .../src/public/locales/de/common.json | 9 + .../locales/de/create-first-project-form.json | 13 + .../public/locales/de/create-first-tasks.json | 7 + .../src/public/locales/de/home.json | 46 +++ .../de/invite-initial-team-members.json | 8 + .../src/public/locales/de/kanban-board.json | 30 ++ .../public/locales/de/license-expired.json | 6 + .../src/public/locales/de/navbar.json | 31 ++ .../locales/de/organization-name-form.json | 5 + .../src/public/locales/de/phases-drawer.json | 19 + .../src/public/locales/de/project-drawer.json | 42 ++ .../public/locales/de/project-view-files.json | 14 + .../locales/de/project-view-insights.json | 41 ++ .../locales/de/project-view-members.json | 17 + .../locales/de/project-view-updates.json | 6 + .../src/public/locales/de/project-view.json | 14 + .../project-view/import-task-templates.json | 11 + .../project-view/project-member-drawer.json | 7 + .../de/project-view/project-view-header.json | 30 ++ .../de/project-view/save-as-template.json | 27 ++ .../locales/de/reporting-members-drawer.json | 90 +++++ .../public/locales/de/reporting-members.json | 35 ++ .../locales/de/reporting-overview-drawer.json | 39 ++ .../public/locales/de/reporting-overview.json | 25 ++ .../locales/de/reporting-projects-drawer.json | 59 +++ .../de/reporting-projects-filters.json | 35 ++ .../public/locales/de/reporting-projects.json | 52 +++ .../public/locales/de/reporting-sidebar.json | 8 + .../src/public/locales/de/schedule.json | 39 ++ .../locales/de/settings/categories.json | 10 + .../locales/de/settings/change-password.json | 15 + .../public/locales/de/settings/clients.json | 22 ++ .../locales/de/settings/job-titles.json | 20 + .../public/locales/de/settings/labels.json | 11 + .../public/locales/de/settings/language.json | 7 + .../locales/de/settings/notifications.json | 11 + .../public/locales/de/settings/profile.json | 14 + .../de/settings/project-templates.json | 8 + .../public/locales/de/settings/sidebar.json | 14 + .../locales/de/settings/task-templates.json | 9 + .../locales/de/settings/team-members.json | 47 +++ .../src/public/locales/de/settings/teams.json | 16 + .../de/task-drawer/task-drawer-info-tab.json | 29 ++ .../locales/de/task-drawer/task-drawer.json | 123 ++++++ .../public/locales/de/task-list-filters.json | 85 ++++ .../public/locales/de/task-list-table.json | 136 +++++++ .../public/locales/de/task-management.json | 21 + .../locales/de/task-template-drawer.json | 11 + .../de/tasks/task-table-bulk-actions.json | 41 ++ .../public/locales/de/template-drawer.json | 19 + .../src/public/locales/de/templateDrawer.json | 23 ++ .../src/public/locales/de/time-report.json | 44 +++ .../src/public/locales/de/unauthorized.json | 5 + .../src/public/locales/en/404-page.json | 4 + .../src/public/locales/en/account-setup.json | 31 ++ .../locales/en/admin-center/current-bill.json | 121 ++++++ .../locales/en/admin-center/overview.json | 8 + .../locales/en/admin-center/projects.json | 12 + .../locales/en/admin-center/sidebar.json | 8 + .../public/locales/en/admin-center/teams.json | 35 ++ .../public/locales/en/admin-center/users.json | 9 + .../public/locales/en/all-project-list.json | 34 ++ .../public/locales/en/auth/auth-common.json | 5 + .../locales/en/auth/forgot-password.json | 12 + .../src/public/locales/en/auth/login.json | 27 ++ .../src/public/locales/en/auth/signup.json | 29 ++ .../locales/en/auth/verify-reset-email.json | 14 + .../src/public/locales/en/common.json | 9 + .../locales/en/create-first-project-form.json | 13 + .../public/locales/en/create-first-tasks.json | 7 + .../src/public/locales/en/home.json | 46 +++ .../en/invite-initial-team-members.json | 8 + .../src/public/locales/en/kanban-board.json | 33 ++ .../public/locales/en/license-expired.json | 6 + .../src/public/locales/en/navbar.json | 31 ++ .../locales/en/organization-name-form.json | 5 + .../src/public/locales/en/phases-drawer.json | 19 + .../src/public/locales/en/project-drawer.json | 52 +++ .../public/locales/en/project-view-files.json | 14 + .../locales/en/project-view-insights.json | 41 ++ .../locales/en/project-view-members.json | 17 + .../locales/en/project-view-updates.json | 6 + .../src/public/locales/en/project-view.json | 14 + .../project-view/import-task-templates.json | 11 + .../project-view/project-member-drawer.json | 7 + .../en/project-view/project-view-header.json | 30 ++ .../en/project-view/save-as-template.json | 27 ++ .../locales/en/reporting-members-drawer.json | 90 +++++ .../public/locales/en/reporting-members.json | 35 ++ .../locales/en/reporting-overview-drawer.json | 39 ++ .../public/locales/en/reporting-overview.json | 25 ++ .../locales/en/reporting-projects-drawer.json | 59 +++ .../en/reporting-projects-filters.json | 35 ++ .../public/locales/en/reporting-projects.json | 52 +++ .../public/locales/en/reporting-sidebar.json | 8 + .../src/public/locales/en/schedule.json | 39 ++ .../locales/en/settings/appearance.json | 5 + .../locales/en/settings/categories.json | 10 + .../locales/en/settings/change-password.json | 15 + .../public/locales/en/settings/clients.json | 22 ++ .../locales/en/settings/job-titles.json | 20 + .../public/locales/en/settings/labels.json | 11 + .../public/locales/en/settings/language.json | 7 + .../locales/en/settings/notifications.json | 11 + .../public/locales/en/settings/profile.json | 14 + .../en/settings/project-templates.json | 8 + .../public/locales/en/settings/sidebar.json | 15 + .../locales/en/settings/task-templates.json | 9 + .../locales/en/settings/team-members.json | 47 +++ .../src/public/locales/en/settings/teams.json | 16 + .../en/task-drawer/task-drawer-info-tab.json | 30 ++ .../task-drawer-recurring-config.json | 34 ++ .../locales/en/task-drawer/task-drawer.json | 123 ++++++ .../public/locales/en/task-list-filters.json | 85 ++++ .../public/locales/en/task-list-table.json | 136 +++++++ .../public/locales/en/task-management.json | 35 ++ .../locales/en/task-template-drawer.json | 12 + .../en/tasks/task-table-bulk-actions.json | 41 ++ .../public/locales/en/template-drawer.json | 19 + .../src/public/locales/en/templateDrawer.json | 23 ++ .../src/public/locales/en/time-report.json | 57 +++ .../src/public/locales/en/unauthorized.json | 5 + .../src/public/locales/es/404-page.json | 4 + .../src/public/locales/es/account-setup.json | 32 ++ .../locales/es/admin-center/current-bill.json | 113 ++++++ .../locales/es/admin-center/overview.json | 8 + .../locales/es/admin-center/projects.json | 12 + .../locales/es/admin-center/sidebar.json | 8 + .../public/locales/es/admin-center/teams.json | 35 ++ .../public/locales/es/admin-center/users.json | 9 + .../public/locales/es/all-project-list.json | 34 ++ .../public/locales/es/auth/auth-common.json | 5 + .../locales/es/auth/forgot-password.json | 12 + .../src/public/locales/es/auth/login.json | 27 ++ .../src/public/locales/es/auth/signup.json | 29 ++ .../locales/es/auth/verify-reset-email.json | 14 + .../src/public/locales/es/common.json | 9 + .../locales/es/create-first-project-form.json | 13 + .../public/locales/es/create-first-tasks.json | 7 + .../src/public/locales/es/home.json | 45 +++ .../es/invite-initial-team-members.json | 8 + .../src/public/locales/es/kanban-board.json | 30 ++ .../public/locales/es/license-expired.json | 6 + .../src/public/locales/es/navbar.json | 31 ++ .../locales/es/organization-name-form.json | 5 + .../src/public/locales/es/phases-drawer.json | 19 + .../src/public/locales/es/project-drawer.json | 52 +++ .../public/locales/es/project-view-files.json | 14 + .../locales/es/project-view-insights.json | 41 ++ .../locales/es/project-view-members.json | 17 + .../locales/es/project-view-updates.json | 6 + .../src/public/locales/es/project-view.json | 14 + .../project-view/import-task-templates.json | 11 + .../project-view/project-member-drawer.json | 7 + .../es/project-view/project-view-header.json | 30 ++ .../es/project-view/save-as-template.json | 27 ++ .../locales/es/reporting-members-drawer.json | 90 +++++ .../public/locales/es/reporting-members.json | 35 ++ .../locales/es/reporting-overview-drawer.json | 39 ++ .../public/locales/es/reporting-overview.json | 25 ++ .../locales/es/reporting-projects-drawer.json | 59 +++ .../es/reporting-projects-filters.json | 35 ++ .../public/locales/es/reporting-projects.json | 52 +++ .../public/locales/es/reporting-sidebar.json | 8 + .../src/public/locales/es/schedule.json | 39 ++ .../locales/es/settings/appearance.json | 5 + .../locales/es/settings/categories.json | 10 + .../locales/es/settings/change-password.json | 15 + .../public/locales/es/settings/clients.json | 22 ++ .../locales/es/settings/job-titles.json | 20 + .../public/locales/es/settings/labels.json | 11 + .../public/locales/es/settings/language.json | 7 + .../locales/es/settings/notifications.json | 10 + .../public/locales/es/settings/profile.json | 14 + .../es/settings/project-templates.json | 8 + .../public/locales/es/settings/sidebar.json | 15 + .../locales/es/settings/task-templates.json | 9 + .../locales/es/settings/team-members.json | 47 +++ .../src/public/locales/es/settings/teams.json | 16 + .../es/task-drawer/task-drawer-info-tab.json | 30 ++ .../task-drawer-recurring-config.json | 34 ++ .../locales/es/task-drawer/task-drawer.json | 123 ++++++ .../public/locales/es/task-list-filters.json | 81 ++++ .../public/locales/es/task-list-table.json | 136 +++++++ .../public/locales/es/task-management.json | 21 + .../locales/es/task-template-drawer.json | 11 + .../es/tasks/task-table-bulk-actions.json | 41 ++ .../public/locales/es/template-drawer.json | 19 + .../src/public/locales/es/templateDrawer.json | 23 ++ .../src/public/locales/es/time-report.json | 57 +++ .../src/public/locales/es/unauthorized.json | 5 + .../src/public/locales/pt/404-page.json | 4 + .../src/public/locales/pt/account-setup.json | 32 ++ .../locales/pt/admin-center/current-bill.json | 113 ++++++ .../locales/pt/admin-center/overview.json | 8 + .../locales/pt/admin-center/projects.json | 12 + .../locales/pt/admin-center/sidebar.json | 8 + .../public/locales/pt/admin-center/teams.json | 35 ++ .../public/locales/pt/admin-center/users.json | 9 + .../public/locales/pt/all-project-list.json | 34 ++ .../public/locales/pt/auth/auth-common.json | 5 + .../locales/pt/auth/forgot-password.json | 12 + .../src/public/locales/pt/auth/login.json | 27 ++ .../src/public/locales/pt/auth/signup.json | 29 ++ .../locales/pt/auth/verify-reset-email.json | 14 + .../src/public/locales/pt/common.json | 9 + .../locales/pt/create-first-project-form.json | 13 + .../public/locales/pt/create-first-tasks.json | 7 + .../src/public/locales/pt/home.json | 45 +++ .../pt/invite-initial-team-members.json | 8 + .../src/public/locales/pt/kanban-board.json | 30 ++ .../public/locales/pt/license-expired.json | 6 + .../src/public/locales/pt/navbar.json | 31 ++ .../locales/pt/organization-name-form.json | 5 + .../src/public/locales/pt/phases-drawer.json | 19 + .../src/public/locales/pt/project-drawer.json | 52 +++ .../public/locales/pt/project-view-files.json | 14 + .../locales/pt/project-view-insights.json | 41 ++ .../locales/pt/project-view-members.json | 17 + .../locales/pt/project-view-updates.json | 6 + .../src/public/locales/pt/project-view.json | 14 + .../project-view/import-task-templates.json | 11 + .../project-view/project-member-drawer.json | 7 + .../pt/project-view/project-view-header.json | 30 ++ .../pt/project-view/save-as-template.json | 27 ++ .../locales/pt/reporting-members-drawer.json | 90 +++++ .../public/locales/pt/reporting-members.json | 35 ++ .../locales/pt/reporting-overview-drawer.json | 39 ++ .../public/locales/pt/reporting-overview.json | 25 ++ .../locales/pt/reporting-projects-drawer.json | 59 +++ .../pt/reporting-projects-filters.json | 35 ++ .../public/locales/pt/reporting-projects.json | 52 +++ .../public/locales/pt/reporting-sidebar.json | 8 + .../src/public/locales/pt/schedule.json | 39 ++ .../locales/pt/settings/appearance.json | 5 + .../locales/pt/settings/categories.json | 10 + .../locales/pt/settings/change-password.json | 15 + .../public/locales/pt/settings/clients.json | 22 ++ .../locales/pt/settings/job-titles.json | 20 + .../public/locales/pt/settings/labels.json | 11 + .../public/locales/pt/settings/language.json | 7 + .../locales/pt/settings/notifications.json | 10 + .../public/locales/pt/settings/profile.json | 14 + .../pt/settings/project-templates.json | 8 + .../public/locales/pt/settings/sidebar.json | 15 + .../locales/pt/settings/task-templates.json | 9 + .../locales/pt/settings/team-members.json | 47 +++ .../src/public/locales/pt/settings/teams.json | 16 + .../pt/task-drawer/task-drawer-info-tab.json | 30 ++ .../task-drawer-recurring-config.json | 34 ++ .../locales/pt/task-drawer/task-drawer.json | 123 ++++++ .../public/locales/pt/task-list-filters.json | 82 ++++ .../public/locales/pt/task-list-table.json | 136 +++++++ .../public/locales/pt/task-management.json | 21 + .../locales/pt/task-template-drawer.json | 11 + .../pt/tasks/task-table-bulk-actions.json | 41 ++ .../public/locales/pt/template-drawer.json | 19 + .../src/public/locales/pt/templateDrawer.json | 23 ++ .../src/public/locales/pt/time-report.json | 57 +++ .../src/public/locales/pt/unauthorized.json | 5 + .../src/public/locales/zh/404-page.json | 4 + .../src/public/locales/zh/account-setup.json | 27 ++ .../locales/zh/admin-center/current-bill.json | 96 +++++ .../locales/zh/admin-center/overview.json | 8 + .../locales/zh/admin-center/projects.json | 12 + .../locales/zh/admin-center/sidebar.json | 8 + .../public/locales/zh/admin-center/teams.json | 33 ++ .../public/locales/zh/admin-center/users.json | 9 + .../public/locales/zh/all-project-list.json | 34 ++ .../public/locales/zh/auth/auth-common.json | 5 + .../locales/zh/auth/forgot-password.json | 12 + .../src/public/locales/zh/auth/login.json | 27 ++ .../src/public/locales/zh/auth/signup.json | 29 ++ .../locales/zh/auth/verify-reset-email.json | 14 + .../src/public/locales/zh/common.json | 9 + .../locales/zh/create-first-project-form.json | 13 + .../public/locales/zh/create-first-tasks.json | 7 + .../src/public/locales/zh/home.json | 46 +++ .../zh/invite-initial-team-members.json | 8 + .../src/public/locales/zh/kanban-board.json | 19 + .../public/locales/zh/license-expired.json | 6 + .../src/public/locales/zh/navbar.json | 31 ++ .../locales/zh/organization-name-form.json | 5 + .../src/public/locales/zh/phases-drawer.json | 19 + .../src/public/locales/zh/project-drawer.json | 42 ++ .../public/locales/zh/project-view-files.json | 14 + .../locales/zh/project-view-insights.json | 41 ++ .../locales/zh/project-view-members.json | 17 + .../locales/zh/project-view-updates.json | 6 + .../src/public/locales/zh/project-view.json | 14 + .../project-view/import-task-templates.json | 11 + .../project-view/project-member-drawer.json | 7 + .../zh/project-view/project-view-header.json | 30 ++ .../zh/project-view/save-as-template.json | 27 ++ .../locales/zh/reporting-members-drawer.json | 76 ++++ .../public/locales/zh/reporting-members.json | 31 ++ .../locales/zh/reporting-overview-drawer.json | 33 ++ .../public/locales/zh/reporting-overview.json | 22 ++ .../locales/zh/reporting-projects-drawer.json | 52 +++ .../zh/reporting-projects-filters.json | 31 ++ .../public/locales/zh/reporting-projects.json | 44 +++ .../public/locales/zh/reporting-sidebar.json | 8 + .../src/public/locales/zh/schedule.json | 34 ++ .../locales/zh/settings/categories.json | 10 + .../locales/zh/settings/change-password.json | 15 + .../public/locales/zh/settings/clients.json | 22 ++ .../locales/zh/settings/job-titles.json | 20 + .../public/locales/zh/settings/labels.json | 11 + .../public/locales/zh/settings/language.json | 7 + .../locales/zh/settings/notifications.json | 11 + .../public/locales/zh/settings/profile.json | 14 + .../zh/settings/project-templates.json | 8 + .../public/locales/zh/settings/sidebar.json | 15 + .../locales/zh/settings/task-templates.json | 9 + .../locales/zh/settings/team-members.json | 47 +++ .../src/public/locales/zh/settings/teams.json | 16 + .../zh/task-drawer/task-drawer-info-tab.json | 29 ++ .../locales/zh/task-drawer/task-drawer.json | 123 ++++++ .../public/locales/zh/task-list-filters.json | 79 ++++ .../public/locales/zh/task-list-table.json | 129 ++++++ .../public/locales/zh/task-management.json | 35 ++ .../locales/zh/task-template-drawer.json | 11 + .../zh/tasks/task-table-bulk-actions.json | 24 ++ .../public/locales/zh/template-drawer.json | 19 + .../src/public/locales/zh/templateDrawer.json | 23 ++ .../src/public/locales/zh/time-report.json | 33 ++ .../src/public/locales/zh/unauthorized.json | 5 + .../optimized-bulk-action-bar.tsx | 9 +- .../task-management/task-list-board.tsx | 15 +- .../src/hooks/useTranslationPreloader.ts | 283 ------------- worklenz-frontend/src/i18n.ts | 371 +----------------- .../projects/projectView/project-view.tsx | 19 +- worklenz-frontend/vite.config.ts | 1 + 414 files changed, 11185 insertions(+), 674 deletions(-) create mode 100644 worklenz-backend/src/public/locales/alb/404-page.json create mode 100644 worklenz-backend/src/public/locales/alb/account-setup.json create mode 100644 worklenz-backend/src/public/locales/alb/admin-center/current-bill.json create mode 100644 worklenz-backend/src/public/locales/alb/admin-center/overview.json create mode 100644 worklenz-backend/src/public/locales/alb/admin-center/projects.json create mode 100644 worklenz-backend/src/public/locales/alb/admin-center/sidebar.json create mode 100644 worklenz-backend/src/public/locales/alb/admin-center/teams.json create mode 100644 worklenz-backend/src/public/locales/alb/admin-center/users.json create mode 100644 worklenz-backend/src/public/locales/alb/all-project-list.json create mode 100644 worklenz-backend/src/public/locales/alb/auth/auth-common.json create mode 100644 worklenz-backend/src/public/locales/alb/auth/forgot-password.json create mode 100644 worklenz-backend/src/public/locales/alb/auth/login.json create mode 100644 worklenz-backend/src/public/locales/alb/auth/signup.json create mode 100644 worklenz-backend/src/public/locales/alb/auth/verify-reset-email.json create mode 100644 worklenz-backend/src/public/locales/alb/common.json create mode 100644 worklenz-backend/src/public/locales/alb/create-first-project-form.json create mode 100644 worklenz-backend/src/public/locales/alb/create-first-tasks.json create mode 100644 worklenz-backend/src/public/locales/alb/home.json create mode 100644 worklenz-backend/src/public/locales/alb/invite-initial-team-members.json create mode 100644 worklenz-backend/src/public/locales/alb/kanban-board.json create mode 100644 worklenz-backend/src/public/locales/alb/license-expired.json create mode 100644 worklenz-backend/src/public/locales/alb/navbar.json create mode 100644 worklenz-backend/src/public/locales/alb/organization-name-form.json create mode 100644 worklenz-backend/src/public/locales/alb/phases-drawer.json create mode 100644 worklenz-backend/src/public/locales/alb/project-drawer.json create mode 100644 worklenz-backend/src/public/locales/alb/project-view-files.json create mode 100644 worklenz-backend/src/public/locales/alb/project-view-insights.json create mode 100644 worklenz-backend/src/public/locales/alb/project-view-members.json create mode 100644 worklenz-backend/src/public/locales/alb/project-view-updates.json create mode 100644 worklenz-backend/src/public/locales/alb/project-view.json create mode 100644 worklenz-backend/src/public/locales/alb/project-view/import-task-templates.json create mode 100644 worklenz-backend/src/public/locales/alb/project-view/project-member-drawer.json create mode 100644 worklenz-backend/src/public/locales/alb/project-view/project-view-header.json create mode 100644 worklenz-backend/src/public/locales/alb/project-view/save-as-template.json create mode 100644 worklenz-backend/src/public/locales/alb/reporting-members-drawer.json create mode 100644 worklenz-backend/src/public/locales/alb/reporting-members.json create mode 100644 worklenz-backend/src/public/locales/alb/reporting-overview-drawer.json create mode 100644 worklenz-backend/src/public/locales/alb/reporting-overview.json create mode 100644 worklenz-backend/src/public/locales/alb/reporting-projects-drawer.json create mode 100644 worklenz-backend/src/public/locales/alb/reporting-projects-filters.json create mode 100644 worklenz-backend/src/public/locales/alb/reporting-projects.json create mode 100644 worklenz-backend/src/public/locales/alb/reporting-sidebar.json create mode 100644 worklenz-backend/src/public/locales/alb/schedule.json create mode 100644 worklenz-backend/src/public/locales/alb/settings/categories.json create mode 100644 worklenz-backend/src/public/locales/alb/settings/change-password.json create mode 100644 worklenz-backend/src/public/locales/alb/settings/clients.json create mode 100644 worklenz-backend/src/public/locales/alb/settings/job-titles.json create mode 100644 worklenz-backend/src/public/locales/alb/settings/labels.json create mode 100644 worklenz-backend/src/public/locales/alb/settings/language.json create mode 100644 worklenz-backend/src/public/locales/alb/settings/notifications.json create mode 100644 worklenz-backend/src/public/locales/alb/settings/profile.json create mode 100644 worklenz-backend/src/public/locales/alb/settings/project-templates.json create mode 100644 worklenz-backend/src/public/locales/alb/settings/sidebar.json create mode 100644 worklenz-backend/src/public/locales/alb/settings/task-templates.json create mode 100644 worklenz-backend/src/public/locales/alb/settings/team-members.json create mode 100644 worklenz-backend/src/public/locales/alb/settings/teams.json create mode 100644 worklenz-backend/src/public/locales/alb/task-drawer/task-drawer-info-tab.json create mode 100644 worklenz-backend/src/public/locales/alb/task-drawer/task-drawer.json create mode 100644 worklenz-backend/src/public/locales/alb/task-list-filters.json create mode 100644 worklenz-backend/src/public/locales/alb/task-list-table.json create mode 100644 worklenz-backend/src/public/locales/alb/task-management.json create mode 100644 worklenz-backend/src/public/locales/alb/task-template-drawer.json create mode 100644 worklenz-backend/src/public/locales/alb/tasks/task-table-bulk-actions.json create mode 100644 worklenz-backend/src/public/locales/alb/template-drawer.json create mode 100644 worklenz-backend/src/public/locales/alb/templateDrawer.json create mode 100644 worklenz-backend/src/public/locales/alb/time-report.json create mode 100644 worklenz-backend/src/public/locales/alb/unauthorized.json create mode 100644 worklenz-backend/src/public/locales/de/404-page.json create mode 100644 worklenz-backend/src/public/locales/de/account-setup.json create mode 100644 worklenz-backend/src/public/locales/de/admin-center/current-bill.json create mode 100644 worklenz-backend/src/public/locales/de/admin-center/overview.json create mode 100644 worklenz-backend/src/public/locales/de/admin-center/projects.json create mode 100644 worklenz-backend/src/public/locales/de/admin-center/sidebar.json create mode 100644 worklenz-backend/src/public/locales/de/admin-center/teams.json create mode 100644 worklenz-backend/src/public/locales/de/admin-center/users.json create mode 100644 worklenz-backend/src/public/locales/de/all-project-list.json create mode 100644 worklenz-backend/src/public/locales/de/auth/auth-common.json create mode 100644 worklenz-backend/src/public/locales/de/auth/forgot-password.json create mode 100644 worklenz-backend/src/public/locales/de/auth/login.json create mode 100644 worklenz-backend/src/public/locales/de/auth/signup.json create mode 100644 worklenz-backend/src/public/locales/de/auth/verify-reset-email.json create mode 100644 worklenz-backend/src/public/locales/de/common.json create mode 100644 worklenz-backend/src/public/locales/de/create-first-project-form.json create mode 100644 worklenz-backend/src/public/locales/de/create-first-tasks.json create mode 100644 worklenz-backend/src/public/locales/de/home.json create mode 100644 worklenz-backend/src/public/locales/de/invite-initial-team-members.json create mode 100644 worklenz-backend/src/public/locales/de/kanban-board.json create mode 100644 worklenz-backend/src/public/locales/de/license-expired.json create mode 100644 worklenz-backend/src/public/locales/de/navbar.json create mode 100644 worklenz-backend/src/public/locales/de/organization-name-form.json create mode 100644 worklenz-backend/src/public/locales/de/phases-drawer.json create mode 100644 worklenz-backend/src/public/locales/de/project-drawer.json create mode 100644 worklenz-backend/src/public/locales/de/project-view-files.json create mode 100644 worklenz-backend/src/public/locales/de/project-view-insights.json create mode 100644 worklenz-backend/src/public/locales/de/project-view-members.json create mode 100644 worklenz-backend/src/public/locales/de/project-view-updates.json create mode 100644 worklenz-backend/src/public/locales/de/project-view.json create mode 100644 worklenz-backend/src/public/locales/de/project-view/import-task-templates.json create mode 100644 worklenz-backend/src/public/locales/de/project-view/project-member-drawer.json create mode 100644 worklenz-backend/src/public/locales/de/project-view/project-view-header.json create mode 100644 worklenz-backend/src/public/locales/de/project-view/save-as-template.json create mode 100644 worklenz-backend/src/public/locales/de/reporting-members-drawer.json create mode 100644 worklenz-backend/src/public/locales/de/reporting-members.json create mode 100644 worklenz-backend/src/public/locales/de/reporting-overview-drawer.json create mode 100644 worklenz-backend/src/public/locales/de/reporting-overview.json create mode 100644 worklenz-backend/src/public/locales/de/reporting-projects-drawer.json create mode 100644 worklenz-backend/src/public/locales/de/reporting-projects-filters.json create mode 100644 worklenz-backend/src/public/locales/de/reporting-projects.json create mode 100644 worklenz-backend/src/public/locales/de/reporting-sidebar.json create mode 100644 worklenz-backend/src/public/locales/de/schedule.json create mode 100644 worklenz-backend/src/public/locales/de/settings/categories.json create mode 100644 worklenz-backend/src/public/locales/de/settings/change-password.json create mode 100644 worklenz-backend/src/public/locales/de/settings/clients.json create mode 100644 worklenz-backend/src/public/locales/de/settings/job-titles.json create mode 100644 worklenz-backend/src/public/locales/de/settings/labels.json create mode 100644 worklenz-backend/src/public/locales/de/settings/language.json create mode 100644 worklenz-backend/src/public/locales/de/settings/notifications.json create mode 100644 worklenz-backend/src/public/locales/de/settings/profile.json create mode 100644 worklenz-backend/src/public/locales/de/settings/project-templates.json create mode 100644 worklenz-backend/src/public/locales/de/settings/sidebar.json create mode 100644 worklenz-backend/src/public/locales/de/settings/task-templates.json create mode 100644 worklenz-backend/src/public/locales/de/settings/team-members.json create mode 100644 worklenz-backend/src/public/locales/de/settings/teams.json create mode 100644 worklenz-backend/src/public/locales/de/task-drawer/task-drawer-info-tab.json create mode 100644 worklenz-backend/src/public/locales/de/task-drawer/task-drawer.json create mode 100644 worklenz-backend/src/public/locales/de/task-list-filters.json create mode 100644 worklenz-backend/src/public/locales/de/task-list-table.json create mode 100644 worklenz-backend/src/public/locales/de/task-management.json create mode 100644 worklenz-backend/src/public/locales/de/task-template-drawer.json create mode 100644 worklenz-backend/src/public/locales/de/tasks/task-table-bulk-actions.json create mode 100644 worklenz-backend/src/public/locales/de/template-drawer.json create mode 100644 worklenz-backend/src/public/locales/de/templateDrawer.json create mode 100644 worklenz-backend/src/public/locales/de/time-report.json create mode 100644 worklenz-backend/src/public/locales/de/unauthorized.json create mode 100644 worklenz-backend/src/public/locales/en/404-page.json create mode 100644 worklenz-backend/src/public/locales/en/account-setup.json create mode 100644 worklenz-backend/src/public/locales/en/admin-center/current-bill.json create mode 100644 worklenz-backend/src/public/locales/en/admin-center/overview.json create mode 100644 worklenz-backend/src/public/locales/en/admin-center/projects.json create mode 100644 worklenz-backend/src/public/locales/en/admin-center/sidebar.json create mode 100644 worklenz-backend/src/public/locales/en/admin-center/teams.json create mode 100644 worklenz-backend/src/public/locales/en/admin-center/users.json create mode 100644 worklenz-backend/src/public/locales/en/all-project-list.json create mode 100644 worklenz-backend/src/public/locales/en/auth/auth-common.json create mode 100644 worklenz-backend/src/public/locales/en/auth/forgot-password.json create mode 100644 worklenz-backend/src/public/locales/en/auth/login.json create mode 100644 worklenz-backend/src/public/locales/en/auth/signup.json create mode 100644 worklenz-backend/src/public/locales/en/auth/verify-reset-email.json create mode 100644 worklenz-backend/src/public/locales/en/common.json create mode 100644 worklenz-backend/src/public/locales/en/create-first-project-form.json create mode 100644 worklenz-backend/src/public/locales/en/create-first-tasks.json create mode 100644 worklenz-backend/src/public/locales/en/home.json create mode 100644 worklenz-backend/src/public/locales/en/invite-initial-team-members.json create mode 100644 worklenz-backend/src/public/locales/en/kanban-board.json create mode 100644 worklenz-backend/src/public/locales/en/license-expired.json create mode 100644 worklenz-backend/src/public/locales/en/navbar.json create mode 100644 worklenz-backend/src/public/locales/en/organization-name-form.json create mode 100644 worklenz-backend/src/public/locales/en/phases-drawer.json create mode 100644 worklenz-backend/src/public/locales/en/project-drawer.json create mode 100644 worklenz-backend/src/public/locales/en/project-view-files.json create mode 100644 worklenz-backend/src/public/locales/en/project-view-insights.json create mode 100644 worklenz-backend/src/public/locales/en/project-view-members.json create mode 100644 worklenz-backend/src/public/locales/en/project-view-updates.json create mode 100644 worklenz-backend/src/public/locales/en/project-view.json create mode 100644 worklenz-backend/src/public/locales/en/project-view/import-task-templates.json create mode 100644 worklenz-backend/src/public/locales/en/project-view/project-member-drawer.json create mode 100644 worklenz-backend/src/public/locales/en/project-view/project-view-header.json create mode 100644 worklenz-backend/src/public/locales/en/project-view/save-as-template.json create mode 100644 worklenz-backend/src/public/locales/en/reporting-members-drawer.json create mode 100644 worklenz-backend/src/public/locales/en/reporting-members.json create mode 100644 worklenz-backend/src/public/locales/en/reporting-overview-drawer.json create mode 100644 worklenz-backend/src/public/locales/en/reporting-overview.json create mode 100644 worklenz-backend/src/public/locales/en/reporting-projects-drawer.json create mode 100644 worklenz-backend/src/public/locales/en/reporting-projects-filters.json create mode 100644 worklenz-backend/src/public/locales/en/reporting-projects.json create mode 100644 worklenz-backend/src/public/locales/en/reporting-sidebar.json create mode 100644 worklenz-backend/src/public/locales/en/schedule.json create mode 100644 worklenz-backend/src/public/locales/en/settings/appearance.json create mode 100644 worklenz-backend/src/public/locales/en/settings/categories.json create mode 100644 worklenz-backend/src/public/locales/en/settings/change-password.json create mode 100644 worklenz-backend/src/public/locales/en/settings/clients.json create mode 100644 worklenz-backend/src/public/locales/en/settings/job-titles.json create mode 100644 worklenz-backend/src/public/locales/en/settings/labels.json create mode 100644 worklenz-backend/src/public/locales/en/settings/language.json create mode 100644 worklenz-backend/src/public/locales/en/settings/notifications.json create mode 100644 worklenz-backend/src/public/locales/en/settings/profile.json create mode 100644 worklenz-backend/src/public/locales/en/settings/project-templates.json create mode 100644 worklenz-backend/src/public/locales/en/settings/sidebar.json create mode 100644 worklenz-backend/src/public/locales/en/settings/task-templates.json create mode 100644 worklenz-backend/src/public/locales/en/settings/team-members.json create mode 100644 worklenz-backend/src/public/locales/en/settings/teams.json create mode 100644 worklenz-backend/src/public/locales/en/task-drawer/task-drawer-info-tab.json create mode 100644 worklenz-backend/src/public/locales/en/task-drawer/task-drawer-recurring-config.json create mode 100644 worklenz-backend/src/public/locales/en/task-drawer/task-drawer.json create mode 100644 worklenz-backend/src/public/locales/en/task-list-filters.json create mode 100644 worklenz-backend/src/public/locales/en/task-list-table.json create mode 100644 worklenz-backend/src/public/locales/en/task-management.json create mode 100644 worklenz-backend/src/public/locales/en/task-template-drawer.json create mode 100644 worklenz-backend/src/public/locales/en/tasks/task-table-bulk-actions.json create mode 100644 worklenz-backend/src/public/locales/en/template-drawer.json create mode 100644 worklenz-backend/src/public/locales/en/templateDrawer.json create mode 100644 worklenz-backend/src/public/locales/en/time-report.json create mode 100644 worklenz-backend/src/public/locales/en/unauthorized.json create mode 100644 worklenz-backend/src/public/locales/es/404-page.json create mode 100644 worklenz-backend/src/public/locales/es/account-setup.json create mode 100644 worklenz-backend/src/public/locales/es/admin-center/current-bill.json create mode 100644 worklenz-backend/src/public/locales/es/admin-center/overview.json create mode 100644 worklenz-backend/src/public/locales/es/admin-center/projects.json create mode 100644 worklenz-backend/src/public/locales/es/admin-center/sidebar.json create mode 100644 worklenz-backend/src/public/locales/es/admin-center/teams.json create mode 100644 worklenz-backend/src/public/locales/es/admin-center/users.json create mode 100644 worklenz-backend/src/public/locales/es/all-project-list.json create mode 100644 worklenz-backend/src/public/locales/es/auth/auth-common.json create mode 100644 worklenz-backend/src/public/locales/es/auth/forgot-password.json create mode 100644 worklenz-backend/src/public/locales/es/auth/login.json create mode 100644 worklenz-backend/src/public/locales/es/auth/signup.json create mode 100644 worklenz-backend/src/public/locales/es/auth/verify-reset-email.json create mode 100644 worklenz-backend/src/public/locales/es/common.json create mode 100644 worklenz-backend/src/public/locales/es/create-first-project-form.json create mode 100644 worklenz-backend/src/public/locales/es/create-first-tasks.json create mode 100644 worklenz-backend/src/public/locales/es/home.json create mode 100644 worklenz-backend/src/public/locales/es/invite-initial-team-members.json create mode 100644 worklenz-backend/src/public/locales/es/kanban-board.json create mode 100644 worklenz-backend/src/public/locales/es/license-expired.json create mode 100644 worklenz-backend/src/public/locales/es/navbar.json create mode 100644 worklenz-backend/src/public/locales/es/organization-name-form.json create mode 100644 worklenz-backend/src/public/locales/es/phases-drawer.json create mode 100644 worklenz-backend/src/public/locales/es/project-drawer.json create mode 100644 worklenz-backend/src/public/locales/es/project-view-files.json create mode 100644 worklenz-backend/src/public/locales/es/project-view-insights.json create mode 100644 worklenz-backend/src/public/locales/es/project-view-members.json create mode 100644 worklenz-backend/src/public/locales/es/project-view-updates.json create mode 100644 worklenz-backend/src/public/locales/es/project-view.json create mode 100644 worklenz-backend/src/public/locales/es/project-view/import-task-templates.json create mode 100644 worklenz-backend/src/public/locales/es/project-view/project-member-drawer.json create mode 100644 worklenz-backend/src/public/locales/es/project-view/project-view-header.json create mode 100644 worklenz-backend/src/public/locales/es/project-view/save-as-template.json create mode 100644 worklenz-backend/src/public/locales/es/reporting-members-drawer.json create mode 100644 worklenz-backend/src/public/locales/es/reporting-members.json create mode 100644 worklenz-backend/src/public/locales/es/reporting-overview-drawer.json create mode 100644 worklenz-backend/src/public/locales/es/reporting-overview.json create mode 100644 worklenz-backend/src/public/locales/es/reporting-projects-drawer.json create mode 100644 worklenz-backend/src/public/locales/es/reporting-projects-filters.json create mode 100644 worklenz-backend/src/public/locales/es/reporting-projects.json create mode 100644 worklenz-backend/src/public/locales/es/reporting-sidebar.json create mode 100644 worklenz-backend/src/public/locales/es/schedule.json create mode 100644 worklenz-backend/src/public/locales/es/settings/appearance.json create mode 100644 worklenz-backend/src/public/locales/es/settings/categories.json create mode 100644 worklenz-backend/src/public/locales/es/settings/change-password.json create mode 100644 worklenz-backend/src/public/locales/es/settings/clients.json create mode 100644 worklenz-backend/src/public/locales/es/settings/job-titles.json create mode 100644 worklenz-backend/src/public/locales/es/settings/labels.json create mode 100644 worklenz-backend/src/public/locales/es/settings/language.json create mode 100644 worklenz-backend/src/public/locales/es/settings/notifications.json create mode 100644 worklenz-backend/src/public/locales/es/settings/profile.json create mode 100644 worklenz-backend/src/public/locales/es/settings/project-templates.json create mode 100644 worklenz-backend/src/public/locales/es/settings/sidebar.json create mode 100644 worklenz-backend/src/public/locales/es/settings/task-templates.json create mode 100644 worklenz-backend/src/public/locales/es/settings/team-members.json create mode 100644 worklenz-backend/src/public/locales/es/settings/teams.json create mode 100644 worklenz-backend/src/public/locales/es/task-drawer/task-drawer-info-tab.json create mode 100644 worklenz-backend/src/public/locales/es/task-drawer/task-drawer-recurring-config.json create mode 100644 worklenz-backend/src/public/locales/es/task-drawer/task-drawer.json create mode 100644 worklenz-backend/src/public/locales/es/task-list-filters.json create mode 100644 worklenz-backend/src/public/locales/es/task-list-table.json create mode 100644 worklenz-backend/src/public/locales/es/task-management.json create mode 100644 worklenz-backend/src/public/locales/es/task-template-drawer.json create mode 100644 worklenz-backend/src/public/locales/es/tasks/task-table-bulk-actions.json create mode 100644 worklenz-backend/src/public/locales/es/template-drawer.json create mode 100644 worklenz-backend/src/public/locales/es/templateDrawer.json create mode 100644 worklenz-backend/src/public/locales/es/time-report.json create mode 100644 worklenz-backend/src/public/locales/es/unauthorized.json create mode 100644 worklenz-backend/src/public/locales/pt/404-page.json create mode 100644 worklenz-backend/src/public/locales/pt/account-setup.json create mode 100644 worklenz-backend/src/public/locales/pt/admin-center/current-bill.json create mode 100644 worklenz-backend/src/public/locales/pt/admin-center/overview.json create mode 100644 worklenz-backend/src/public/locales/pt/admin-center/projects.json create mode 100644 worklenz-backend/src/public/locales/pt/admin-center/sidebar.json create mode 100644 worklenz-backend/src/public/locales/pt/admin-center/teams.json create mode 100644 worklenz-backend/src/public/locales/pt/admin-center/users.json create mode 100644 worklenz-backend/src/public/locales/pt/all-project-list.json create mode 100644 worklenz-backend/src/public/locales/pt/auth/auth-common.json create mode 100644 worklenz-backend/src/public/locales/pt/auth/forgot-password.json create mode 100644 worklenz-backend/src/public/locales/pt/auth/login.json create mode 100644 worklenz-backend/src/public/locales/pt/auth/signup.json create mode 100644 worklenz-backend/src/public/locales/pt/auth/verify-reset-email.json create mode 100644 worklenz-backend/src/public/locales/pt/common.json create mode 100644 worklenz-backend/src/public/locales/pt/create-first-project-form.json create mode 100644 worklenz-backend/src/public/locales/pt/create-first-tasks.json create mode 100644 worklenz-backend/src/public/locales/pt/home.json create mode 100644 worklenz-backend/src/public/locales/pt/invite-initial-team-members.json create mode 100644 worklenz-backend/src/public/locales/pt/kanban-board.json create mode 100644 worklenz-backend/src/public/locales/pt/license-expired.json create mode 100644 worklenz-backend/src/public/locales/pt/navbar.json create mode 100644 worklenz-backend/src/public/locales/pt/organization-name-form.json create mode 100644 worklenz-backend/src/public/locales/pt/phases-drawer.json create mode 100644 worklenz-backend/src/public/locales/pt/project-drawer.json create mode 100644 worklenz-backend/src/public/locales/pt/project-view-files.json create mode 100644 worklenz-backend/src/public/locales/pt/project-view-insights.json create mode 100644 worklenz-backend/src/public/locales/pt/project-view-members.json create mode 100644 worklenz-backend/src/public/locales/pt/project-view-updates.json create mode 100644 worklenz-backend/src/public/locales/pt/project-view.json create mode 100644 worklenz-backend/src/public/locales/pt/project-view/import-task-templates.json create mode 100644 worklenz-backend/src/public/locales/pt/project-view/project-member-drawer.json create mode 100644 worklenz-backend/src/public/locales/pt/project-view/project-view-header.json create mode 100644 worklenz-backend/src/public/locales/pt/project-view/save-as-template.json create mode 100644 worklenz-backend/src/public/locales/pt/reporting-members-drawer.json create mode 100644 worklenz-backend/src/public/locales/pt/reporting-members.json create mode 100644 worklenz-backend/src/public/locales/pt/reporting-overview-drawer.json create mode 100644 worklenz-backend/src/public/locales/pt/reporting-overview.json create mode 100644 worklenz-backend/src/public/locales/pt/reporting-projects-drawer.json create mode 100644 worklenz-backend/src/public/locales/pt/reporting-projects-filters.json create mode 100644 worklenz-backend/src/public/locales/pt/reporting-projects.json create mode 100644 worklenz-backend/src/public/locales/pt/reporting-sidebar.json create mode 100644 worklenz-backend/src/public/locales/pt/schedule.json create mode 100644 worklenz-backend/src/public/locales/pt/settings/appearance.json create mode 100644 worklenz-backend/src/public/locales/pt/settings/categories.json create mode 100644 worklenz-backend/src/public/locales/pt/settings/change-password.json create mode 100644 worklenz-backend/src/public/locales/pt/settings/clients.json create mode 100644 worklenz-backend/src/public/locales/pt/settings/job-titles.json create mode 100644 worklenz-backend/src/public/locales/pt/settings/labels.json create mode 100644 worklenz-backend/src/public/locales/pt/settings/language.json create mode 100644 worklenz-backend/src/public/locales/pt/settings/notifications.json create mode 100644 worklenz-backend/src/public/locales/pt/settings/profile.json create mode 100644 worklenz-backend/src/public/locales/pt/settings/project-templates.json create mode 100644 worklenz-backend/src/public/locales/pt/settings/sidebar.json create mode 100644 worklenz-backend/src/public/locales/pt/settings/task-templates.json create mode 100644 worklenz-backend/src/public/locales/pt/settings/team-members.json create mode 100644 worklenz-backend/src/public/locales/pt/settings/teams.json create mode 100644 worklenz-backend/src/public/locales/pt/task-drawer/task-drawer-info-tab.json create mode 100644 worklenz-backend/src/public/locales/pt/task-drawer/task-drawer-recurring-config.json create mode 100644 worklenz-backend/src/public/locales/pt/task-drawer/task-drawer.json create mode 100644 worklenz-backend/src/public/locales/pt/task-list-filters.json create mode 100644 worklenz-backend/src/public/locales/pt/task-list-table.json create mode 100644 worklenz-backend/src/public/locales/pt/task-management.json create mode 100644 worklenz-backend/src/public/locales/pt/task-template-drawer.json create mode 100644 worklenz-backend/src/public/locales/pt/tasks/task-table-bulk-actions.json create mode 100644 worklenz-backend/src/public/locales/pt/template-drawer.json create mode 100644 worklenz-backend/src/public/locales/pt/templateDrawer.json create mode 100644 worklenz-backend/src/public/locales/pt/time-report.json create mode 100644 worklenz-backend/src/public/locales/pt/unauthorized.json create mode 100644 worklenz-backend/src/public/locales/zh/404-page.json create mode 100644 worklenz-backend/src/public/locales/zh/account-setup.json create mode 100644 worklenz-backend/src/public/locales/zh/admin-center/current-bill.json create mode 100644 worklenz-backend/src/public/locales/zh/admin-center/overview.json create mode 100644 worklenz-backend/src/public/locales/zh/admin-center/projects.json create mode 100644 worklenz-backend/src/public/locales/zh/admin-center/sidebar.json create mode 100644 worklenz-backend/src/public/locales/zh/admin-center/teams.json create mode 100644 worklenz-backend/src/public/locales/zh/admin-center/users.json create mode 100644 worklenz-backend/src/public/locales/zh/all-project-list.json create mode 100644 worklenz-backend/src/public/locales/zh/auth/auth-common.json create mode 100644 worklenz-backend/src/public/locales/zh/auth/forgot-password.json create mode 100644 worklenz-backend/src/public/locales/zh/auth/login.json create mode 100644 worklenz-backend/src/public/locales/zh/auth/signup.json create mode 100644 worklenz-backend/src/public/locales/zh/auth/verify-reset-email.json create mode 100644 worklenz-backend/src/public/locales/zh/common.json create mode 100644 worklenz-backend/src/public/locales/zh/create-first-project-form.json create mode 100644 worklenz-backend/src/public/locales/zh/create-first-tasks.json create mode 100644 worklenz-backend/src/public/locales/zh/home.json create mode 100644 worklenz-backend/src/public/locales/zh/invite-initial-team-members.json create mode 100644 worklenz-backend/src/public/locales/zh/kanban-board.json create mode 100644 worklenz-backend/src/public/locales/zh/license-expired.json create mode 100644 worklenz-backend/src/public/locales/zh/navbar.json create mode 100644 worklenz-backend/src/public/locales/zh/organization-name-form.json create mode 100644 worklenz-backend/src/public/locales/zh/phases-drawer.json create mode 100644 worklenz-backend/src/public/locales/zh/project-drawer.json create mode 100644 worklenz-backend/src/public/locales/zh/project-view-files.json create mode 100644 worklenz-backend/src/public/locales/zh/project-view-insights.json create mode 100644 worklenz-backend/src/public/locales/zh/project-view-members.json create mode 100644 worklenz-backend/src/public/locales/zh/project-view-updates.json create mode 100644 worklenz-backend/src/public/locales/zh/project-view.json create mode 100644 worklenz-backend/src/public/locales/zh/project-view/import-task-templates.json create mode 100644 worklenz-backend/src/public/locales/zh/project-view/project-member-drawer.json create mode 100644 worklenz-backend/src/public/locales/zh/project-view/project-view-header.json create mode 100644 worklenz-backend/src/public/locales/zh/project-view/save-as-template.json create mode 100644 worklenz-backend/src/public/locales/zh/reporting-members-drawer.json create mode 100644 worklenz-backend/src/public/locales/zh/reporting-members.json create mode 100644 worklenz-backend/src/public/locales/zh/reporting-overview-drawer.json create mode 100644 worklenz-backend/src/public/locales/zh/reporting-overview.json create mode 100644 worklenz-backend/src/public/locales/zh/reporting-projects-drawer.json create mode 100644 worklenz-backend/src/public/locales/zh/reporting-projects-filters.json create mode 100644 worklenz-backend/src/public/locales/zh/reporting-projects.json create mode 100644 worklenz-backend/src/public/locales/zh/reporting-sidebar.json create mode 100644 worklenz-backend/src/public/locales/zh/schedule.json create mode 100644 worklenz-backend/src/public/locales/zh/settings/categories.json create mode 100644 worklenz-backend/src/public/locales/zh/settings/change-password.json create mode 100644 worklenz-backend/src/public/locales/zh/settings/clients.json create mode 100644 worklenz-backend/src/public/locales/zh/settings/job-titles.json create mode 100644 worklenz-backend/src/public/locales/zh/settings/labels.json create mode 100644 worklenz-backend/src/public/locales/zh/settings/language.json create mode 100644 worklenz-backend/src/public/locales/zh/settings/notifications.json create mode 100644 worklenz-backend/src/public/locales/zh/settings/profile.json create mode 100644 worklenz-backend/src/public/locales/zh/settings/project-templates.json create mode 100644 worklenz-backend/src/public/locales/zh/settings/sidebar.json create mode 100644 worklenz-backend/src/public/locales/zh/settings/task-templates.json create mode 100644 worklenz-backend/src/public/locales/zh/settings/team-members.json create mode 100644 worklenz-backend/src/public/locales/zh/settings/teams.json create mode 100644 worklenz-backend/src/public/locales/zh/task-drawer/task-drawer-info-tab.json create mode 100644 worklenz-backend/src/public/locales/zh/task-drawer/task-drawer.json create mode 100644 worklenz-backend/src/public/locales/zh/task-list-filters.json create mode 100644 worklenz-backend/src/public/locales/zh/task-list-table.json create mode 100644 worklenz-backend/src/public/locales/zh/task-management.json create mode 100644 worklenz-backend/src/public/locales/zh/task-template-drawer.json create mode 100644 worklenz-backend/src/public/locales/zh/tasks/task-table-bulk-actions.json create mode 100644 worklenz-backend/src/public/locales/zh/template-drawer.json create mode 100644 worklenz-backend/src/public/locales/zh/templateDrawer.json create mode 100644 worklenz-backend/src/public/locales/zh/time-report.json create mode 100644 worklenz-backend/src/public/locales/zh/unauthorized.json delete mode 100644 worklenz-frontend/src/hooks/useTranslationPreloader.ts diff --git a/worklenz-backend/src/public/locales/alb/404-page.json b/worklenz-backend/src/public/locales/alb/404-page.json new file mode 100644 index 00000000..a5e803fe --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/404-page.json @@ -0,0 +1,4 @@ +{ + "doesNotExistText": "Na vjen keq, faqja që kërkoni nuk ekziston.", + "backHomeButton": "Kthehu në Faqen Kryesore" +} diff --git a/worklenz-backend/src/public/locales/alb/account-setup.json b/worklenz-backend/src/public/locales/alb/account-setup.json new file mode 100644 index 00000000..d5f624b3 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/account-setup.json @@ -0,0 +1,31 @@ +{ + "continue": "Vazhdo", + + "setupYourAccount": "Konfiguro Llogarinë Tënde në Worklenz.", + "organizationStepTitle": "Emërtoni Organizatën Tuaj", + "organizationStepLabel": "Zgjidhni një emër për llogarinë tuaj në Worklenz.", + + "projectStepTitle": "Krijoni projektin tuaj të parë", + "projectStepLabel": "Në cilin projekt po punoni aktualisht?", + "projectStepPlaceholder": "p.sh. Plani i Marketingut", + + "tasksStepTitle": "Krijoni detyrat tuaja të para", + "tasksStepLabel": "Shkruani disa detyra që do të kryeni në", + "tasksStepAddAnother": "Shto një tjetër", + + "emailPlaceholder": "Adresa email", + "invalidEmail": "Ju lutemi vendosni një adresë email të vlefshme", + "or": "ose", + "templateButton": "Importo nga shablloni", + "goBack": "Kthehu Mbrapa", + "cancel": "Anulo", + "create": "Krijo", + "templateDrawerTitle": "Zgjidh nga shabllonet", + "step3InputLabel": "Fto me email", + "addAnother": "Shto një tjetër", + "skipForNow": "Kalo tani për tani", + "formTitle": "Krijoni detyrën tuaj të parë.", + "step3Title": "Fto ekipin tënd të punojë me", + "maxMembers": " (Mund të ftoni deri në 5 anëtarë)", + "maxTasks": " (Mund të krijoni deri në 5 detyra)" +} diff --git a/worklenz-backend/src/public/locales/alb/admin-center/current-bill.json b/worklenz-backend/src/public/locales/alb/admin-center/current-bill.json new file mode 100644 index 00000000..1f76f32b --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/admin-center/current-bill.json @@ -0,0 +1,113 @@ +{ + "title": "Faturimet", + "currentBill": "Fatura Aktuale", + "configuration": "Konfigurimi", + "currentPlanDetails": "Detajet e Planit Aktual", + "upgradePlan": "Përmirëso Planin", + "cardBodyText01": "Provë falas", + "cardBodyText02": "(Plani juaj i provës skadon në 1 muaj 19 ditë)", + "redeemCode": "Kodi i Zbritjes", + "accountStorage": "Depozita e Llogarisë", + "used": "Përdorur:", + "remaining": "E mbetur:", + "charges": "Tarifat", + "tooltip": "Tarifat për ciklin aktual të faturimit", + "description": "Përshkrimi", + "billingPeriod": "Periudha e Faturimit", + "billStatus": "Statusi i Faturës", + "perUserValue": "Vlera për Përdorues", + "users": "Përdoruesit", + + "amount": "Shuma", + "invoices": "Faturat", + "transactionId": "ID e Transaksionit", + "transactionDate": "Data e Transaksionit", + "paymentMethod": "Metoda e Pagesës", + "status": "Statusi", + "ltdUsers": "Mund të shtoni deri në {{ltd_users}} përdorues.", + + "totalSeats": "Vende totale", + "availableSeats": "Vende të disponueshme", + "addMoreSeats": "Shto më shumë vende", + + "drawerTitle": "Kodi i Zbritjes", + "label": "Kodi i Zbritjes", + "drawerPlaceholder": "Vendosni kodin tuaj të zbritjes", + "redeemSubmit": "Paraqit", + + "modalTitle": "Zgjidhni planin më të mirë për ekipin tuaj", + "seatLabel": "Numri i vendeve", + "freePlan": "Plan Falas", + "startup": "Startup", + "business": "Biznes", + "tag": "Më i Popullarizuar", + "enterprise": "Ndërmarrje", + + "freeSubtitle": "falas përgjithmonë", + "freeUsers": "Më e mira për përdorim personal", + "freeText01": "100MB depozitë", + "freeText02": "3 projekte", + "freeText03": "5 anëtarë të ekipit", + + "startupSubtitle": "ÇMIM I RASTËSISHËM / muaj", + "startupUsers": "Deri në 15 përdorues", + "startupText01": "25GB depozitë", + "startupText02": "Projekte të pakufizuara aktive", + "startupText03": "Orar", + "startupText04": "Raportim", + "startupText05": "Abonohu në projekte", + + "businessSubtitle": "përdorues / muaj", + "businessUsers": "16 - 200 përdorues", + + "enterpriseUsers": "200 - 500+ përdorues", + + "footerTitle": "Ju lutemi na jepni një numër kontakti që mund të përdorim për t'ju kontaktuar.", + "footerLabel": "Numri i Kontaktit", + "footerButton": "Na kontaktoni", + + "redeemCodePlaceHolder": "Vendosni kodin tuaj të zbritjes", + "submit": "Paraqit", + + "trialPlan": "Provë Falas", + "trialExpireDate": "E vlefshme deri më {{trial_expire_date}}", + "trialExpired": "Provat tuaja falas skaduan {{trial_expire_string}}", + "trialInProgress": "Provat tuaja falas skadojnë {{trial_expire_string}}", + + "required": "Kjo fushë është e detyrueshme", + "invalidCode": "Kod i pavlefshëm", + + "selectPlan": "Zgjidhni planin më të mirë për ekipin tuaj", + "changeSubscriptionPlan": "Ndryshoni planin tuaj të abonimit", + "noOfSeats": "Numri i vendeve", + "annualPlan": "Pro - Vjetor", + "monthlyPlan": "Pro - Mujor", + "freeForever": "Falas Përgjithmonë", + "bestForPersonalUse": "Më e mira për përdorim personal", + "storage": "Depozitë", + "projects": "Projekte", + "teamMembers": "Anëtarët e Ekipit", + "unlimitedTeamMembers": "Anëtarë të pakufizuar të ekipit", + "unlimitedActiveProjects": "Projekte të pakufizuara aktive", + "schedule": "Orar", + "reporting": "Raportim", + "subscribeToProjects": "Abonohu në projekte", + "billedAnnually": "Faturuar çdo vit", + "billedMonthly": "Faturuar çdo muaj", + + "pausePlan": "Pauzë Planin", + "resumePlan": "Rifillo Planin", + "changePlan": "Ndrysho Planin", + "cancelPlan": "Anulo Planin", + + "perMonthPerUser": "për përdorues/muaj", + "viewInvoice": "Shiko Faturën", + "switchToFreePlan": "Kalo në Planin Falas", + + "expirestoday": "sot", + "expirestomorrow": "nesër", + "expiredDaysAgo": "{{days}} ditë më parë", + + "continueWith": "Vazhdo me {{plan}}", + "changeToPlan": "Ndrysho në {{plan}}" +} diff --git a/worklenz-backend/src/public/locales/alb/admin-center/overview.json b/worklenz-backend/src/public/locales/alb/admin-center/overview.json new file mode 100644 index 00000000..296eae4c --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/admin-center/overview.json @@ -0,0 +1,8 @@ +{ + "overview": "Përmbledhje", + "name": "Emri i Organizatës", + "owner": "Pronari i Organizatës", + "admins": "Administruesit e Organizatës", + "contactNumber": "Shto Numrin e Kontaktit", + "edit": "Redakto" +} diff --git a/worklenz-backend/src/public/locales/alb/admin-center/projects.json b/worklenz-backend/src/public/locales/alb/admin-center/projects.json new file mode 100644 index 00000000..356aaec9 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/admin-center/projects.json @@ -0,0 +1,12 @@ +{ + "membersCount": "Numri i Anëtarëve", + "createdAt": "Krijuar më", + "projectName": "Emri i Projektit", + "teamName": "Emri i Ekipit", + "refreshProjects": "Rifresko Projektet", + "searchPlaceholder": "Kërkoni sipas emrit të projektit", + "deleteProject": "Jeni i sigurt që dëshironi të fshini këtë projekt?", + "confirm": "Konfirmo", + "cancel": "Anulo", + "delete": "Fshi Projektin" +} diff --git a/worklenz-backend/src/public/locales/alb/admin-center/sidebar.json b/worklenz-backend/src/public/locales/alb/admin-center/sidebar.json new file mode 100644 index 00000000..584a9a10 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/admin-center/sidebar.json @@ -0,0 +1,8 @@ +{ + "overview": "Përmbledhje", + "users": "Përdoruesit", + "teams": "Ekipet", + "billing": "Faturimi", + "projects": "Projektet", + "adminCenter": "Qendra Administrative" +} diff --git a/worklenz-backend/src/public/locales/alb/admin-center/teams.json b/worklenz-backend/src/public/locales/alb/admin-center/teams.json new file mode 100644 index 00000000..de37bf7a --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/admin-center/teams.json @@ -0,0 +1,33 @@ +{ + "title": "Ekipet", + "subtitle": "ekipet", + "tooltip": "Rifresko ekipet", + "placeholder": "Kërko sipas emrit", + "addTeam": "Shto Ekip", + "team": "Ekipi", + "membersCount": "Numri i Anëtarëve", + "members": "Anëtarët", + "drawerTitle": "Krijo Ekip të Ri", + "label": "Emri i Ekipit", + "drawerPlaceholder": "Emri", + "create": "Krijo", + "delete": "Fshi", + "settings": "Cilësimet", + "popTitle": "Jeni i sigurt?", + "message": "Ju lutemi shkruani një Emër", + "teamSettings": "Cilësimet e Ekipit", + "teamName": "Emri i Ekipit", + "teamDescription": "Përshkrimi i Ekipit", + "teamMembers": "Anëtarët e Ekipit", + "teamMembersCount": "Numri i Anëtarëve të Ekipit", + "teamMembersPlaceholder": "Kërko sipas emrit", + "addMember": "Shto Anëtar", + "add": "Shto", + "update": "Përditëso", + "teamNamePlaceholder": "Emri i ekipit", + "user": "Përdoruesi", + "role": "Roli", + "owner": "Pronari", + "admin": "Administruesi", + "member": "Anëtari" +} diff --git a/worklenz-backend/src/public/locales/alb/admin-center/users.json b/worklenz-backend/src/public/locales/alb/admin-center/users.json new file mode 100644 index 00000000..9cfe7956 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/admin-center/users.json @@ -0,0 +1,9 @@ +{ + "title": "Përdoruesit", + "subTitle": "përdoruesit", + "placeholder": "Kërko sipas emrit", + "user": "Përdoruesi", + "email": "Email", + "lastActivity": "Aktiviteti i Fundit", + "refresh": "Rifresko përdoruesit" +} diff --git a/worklenz-backend/src/public/locales/alb/all-project-list.json b/worklenz-backend/src/public/locales/alb/all-project-list.json new file mode 100644 index 00000000..8079f13d --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/all-project-list.json @@ -0,0 +1,34 @@ +{ + "name": "Emri", + "client": "Klienti", + "category": "Kategoria", + "status": "Statusi", + "tasksProgress": "Përparimi i Detyrave", + "updated_at": "E Përditësuar së Fundi", + "members": "Anëtarët", + "setting": "Cilësimet", + "projects": "Projektet", + "refreshProjects": "Rifresko projektet", + "all": "Të gjitha", + "favorites": "Të preferuarit", + "archived": "E arkivuar", + "placeholder": "Kërko sipas emrit", + "archive": "Arkivo", + "unarchive": "Çarkivo", + "archiveConfirm": "Jeni i sigurt që dëshironi të arkivoni këtë projekt?", + "unarchiveConfirm": "Jeni i sigurt që dëshironi të çarkivoni këtë projekt?", + "yes": "Po", + "no": "Jo", + "clickToFilter": "Kliko për të filtruar sipas", + "noProjects": "Nuk u gjetën projekte", + "addToFavourites": "Shto te të preferuarit", + "list": "Lista", + "group": "Grupi", + "listView": "Pamja e Listës", + "groupView": "Pamja e Grupit", + "groupBy": { + "category": "Kategoria", + "client": "Klienti" + }, + "noPermission": "Nuk keni leje për të kryer këtë veprim" +} diff --git a/worklenz-backend/src/public/locales/alb/auth/auth-common.json b/worklenz-backend/src/public/locales/alb/auth/auth-common.json new file mode 100644 index 00000000..5f687234 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/auth/auth-common.json @@ -0,0 +1,5 @@ +{ + "loggingOut": "Po dilni...", + "authenticating": "Po autentikoheni...", + "gettingThingsReady": "Po përgatiten gjërat për ju..." +} diff --git a/worklenz-backend/src/public/locales/alb/auth/forgot-password.json b/worklenz-backend/src/public/locales/alb/auth/forgot-password.json new file mode 100644 index 00000000..2ee64388 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/auth/forgot-password.json @@ -0,0 +1,12 @@ +{ + "headerDescription": "Rivendosni fjalëkalimin tuaj", + "emailLabel": "Email", + "emailPlaceholder": "Vendosni email-in tuaj", + "emailRequired": "Ju lutemi vendosni Email-in tuaj!", + "resetPasswordButton": "Rivendos Fjalëkalimin", + "returnToLoginButton": "Kthehu te Hyrja", + "passwordResetSuccessMessage": "Një lidhje për rivendosjen e fjalëkalimit është dërguar në email-in tuaj.", + "orText": "OSE", + "successTitle": "U dërguan udhëzimet për rivendosje!", + "successMessage": "Informacioni për rivendosje është dërguar në email-in tuaj. Ju lutemi kontrolloni email-in." +} diff --git a/worklenz-backend/src/public/locales/alb/auth/login.json b/worklenz-backend/src/public/locales/alb/auth/login.json new file mode 100644 index 00000000..668b4fdc --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/auth/login.json @@ -0,0 +1,27 @@ +{ + "headerDescription": "Hyni në llogarinë tuaj", + "emailLabel": "Email", + "emailPlaceholder": "Vendosni email-in tuaj", + "emailRequired": "Ju lutemi vendosni Email-in tuaj!", + "passwordLabel": "Fjalëkalimi", + "passwordPlaceholder": "Vendosni fjalëkalimin", + "passwordRequired": "Ju lutemi vendosni Fjalëkalimin!", + "rememberMe": "Më mbaj mend", + "loginButton": "Hyr", + "signupButton": "Regjistrohu", + "forgotPasswordButton": "Keni harruar fjalëkalimin?", + "signInWithGoogleButton": "Hyr me Google", + "dontHaveAccountText": "Nuk keni llogari?", + "orText": "OSE", + "successMessage": "Jeni futur me sukses!", + "loginError": "Hyrja dështoi", + "googleLoginError": "Hyrja përmes Google dështoi", + "validationMessages": { + "email": "Ju lutemi vendosni një adresë email të vlefshme", + "password": "Fjalëkalimi duhet të jetë së paku 8 karaktere" + }, + "errorMessages": { + "loginErrorTitle": "Hyrja dështoi", + "loginErrorMessage": "Ju lutemi kontrolloni email-in dhe fjalëkalimin dhe provoni përsëri" + } +} diff --git a/worklenz-backend/src/public/locales/alb/auth/signup.json b/worklenz-backend/src/public/locales/alb/auth/signup.json new file mode 100644 index 00000000..1dac7a39 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/auth/signup.json @@ -0,0 +1,29 @@ +{ + "headerDescription": "Regjistrohuni për të filluar", + "nameLabel": "Emri i Plotë", + "namePlaceholder": "Shkruani emrin tuaj të plotë", + "nameRequired": "Ju lutemi shkruani emrin tuaj të plotë!", + "nameMinCharacterRequired": "Emri duhet të jetë së paku 4 karaktere!", + "emailLabel": "Email", + "emailPlaceholder": "Shkruani email-in tuaj", + "emailRequired": "Ju lutemi shkruani Email-in tuaj!", + "passwordLabel": "Fjalëkalimi", + "passwordPlaceholder": "Krijoni një fjalëkalim", + "passwordRequired": "Ju lutemi krijoni një Fjalëkalim!", + "passwordMinCharacterRequired": "Fjalëkalimi duhet të jetë së paku 8 karaktere!", + "passwordPatternRequired": "Fjalëkalimi nuk plotëson kërkesat!", + "strongPasswordPlaceholder": "Vendosni një fjalëkalim më të fortë", + "passwordValidationAltText": "Fjalëkalimi duhet të përmbajë së paku 8 karaktere me shkronja të mëdha dhe të vogla, një numër dhe një simbol.", + "signupSuccessMessage": "Jeni regjistruar me sukses!", + "privacyPolicyLink": "Politika e Privatësisë", + "termsOfUseLink": "Kushtet e Përdorimit", + "bySigningUpText": "Duke u regjistruar, ju pranoni", + "andText": "dhe", + "signupButton": "Regjistrohu", + "signInWithGoogleButton": "Hyr me Google", + "alreadyHaveAccountText": "Keni tashmë një llogari?", + "loginButton": "Hyr", + "orText": "OSE", + "reCAPTCHAVerificationError": "Gabim në Verifikimin e reCAPTCHA", + "reCAPTCHAVerificationErrorMessage": "Nuk mundëm të verifikojmë reCAPTCHA-n tuaj. Ju lutemi provoni përsëri." +} diff --git a/worklenz-backend/src/public/locales/alb/auth/verify-reset-email.json b/worklenz-backend/src/public/locales/alb/auth/verify-reset-email.json new file mode 100644 index 00000000..16017318 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/auth/verify-reset-email.json @@ -0,0 +1,14 @@ +{ + "title": "Verifikoni Email-in për Rivendosje", + "description": "Vendosni fjalëkalimin tuaj të ri", + "placeholder": "Vendosni fjalëkalimin tuaj të ri", + "confirmPasswordPlaceholder": "Konfirmoni fjalëkalimin e ri", + "passwordHint": "Të paktën 8 karaktere, me shkronja të mëdha dhe të vogla, një numër dhe një simbol.", + "resetPasswordButton": "Rivendos fjalëkalimin", + "orText": "Ose", + "resendResetEmail": "Dërgo përsëri email-in e rivendosjes", + "passwordRequired": "Ju lutemi vendosni fjalëkalimin e ri", + "returnToLoginButton": "Kthehu te Hyrja", + "confirmPasswordRequired": "Ju lutemi konfirmoni fjalëkalimin e ri", + "passwordMismatch": "Fjalëkalimet nuk përputhen" +} diff --git a/worklenz-backend/src/public/locales/alb/common.json b/worklenz-backend/src/public/locales/alb/common.json new file mode 100644 index 00000000..5af25f69 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/common.json @@ -0,0 +1,9 @@ +{ + "login-success": "Hyrja u krye me sukses!", + "login-failed": "Hyrja dështoi. Ju lutemi kontrolloni kredencialet dhe provoni përsëri.", + "signup-success": "Regjistrimi u krye me sukses! Mirë se erdhët.", + "signup-failed": "Regjistrimi dështoi. Ju lutemi sigurohuni që të gjitha fushat e nevojshme janë plotësuar dhe provoni përsëri.", + "reconnecting": "Jeni shkëputur nga serveri.", + "connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.", + "connection-restored": "U lidhët me serverin me sukses" +} diff --git a/worklenz-backend/src/public/locales/alb/create-first-project-form.json b/worklenz-backend/src/public/locales/alb/create-first-project-form.json new file mode 100644 index 00000000..80c3bb86 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/create-first-project-form.json @@ -0,0 +1,13 @@ +{ + "formTitle": "Krijoni projektin tuaj të parë", + "inputLabel": "Në cilin projekt po punoni aktualisht?", + "or": "ose", + "templateButton": "Importo nga shablloni", + "createFromTemplate": "Krijo nga shablloni", + "goBack": "Kthehu Mbrapa", + "continue": "Vazhdo", + "cancel": "Anulo", + "create": "Krijo", + "templateDrawerTitle": "Zgjidh nga shabllonet", + "createProject": "Krijo Projekt" +} diff --git a/worklenz-backend/src/public/locales/alb/create-first-tasks.json b/worklenz-backend/src/public/locales/alb/create-first-tasks.json new file mode 100644 index 00000000..5e74d7d4 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/create-first-tasks.json @@ -0,0 +1,7 @@ +{ + "formTitle": "Krijo detyrën tënde të parë.", + "inputLabel": "Shkruaj disa detyra që do të kryesh në", + "addAnother": "Shto një tjetër", + "goBack": "Kthehu mbrapa", + "continue": "Vazhdo" +} diff --git a/worklenz-backend/src/public/locales/alb/home.json b/worklenz-backend/src/public/locales/alb/home.json new file mode 100644 index 00000000..58d26e0b --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/home.json @@ -0,0 +1,46 @@ +{ + "todoList": { + "title": "Lista e Detyrave", + "refreshTasks": "Rifresko detyrat", + "addTask": "+ Shto Detyrë", + "noTasks": "Asnjë detyrë", + "pressEnter": "Shtyp", + "toCreate": "për të krijuar.", + "markAsDone": "Shëno si të përfunduar" + }, + "projects": { + "title": "Projektet", + "refreshProjects": "Rifresko projektet", + "noRecentProjects": "Aktualisht nuk jeni caktuar në asnjë projekt.", + "noFavouriteProjects": "Asnjë projekt i shënuar si i preferuar.", + "recent": "Të Fundit", + "favourites": "Të Preferuarat" + }, + "tasks": { + "assignedToMe": "Më janë caktuar", + "assignedByMe": "I kam caktuar", + "all": "Të Gjitha", + "today": "Sot", + "upcoming": "Ardhj", + "overdue": "Të vonuara", + "noDueDate": "Pa afat", + "noTasks": "Asnjë detyrë për të shfaqur.", + "addTask": "+ Shto detyrë", + "name": "Emri", + "project": "Projekti", + "status": "Statusi", + "dueDate": "Afati", + "dueDatePlaceholder": "Cakto Afatin", + "tomorrow": "Nesër", + "nextWeek": "Javën e Ardhshme", + "nextMonth": "Muajin e Ardhshëm", + "projectRequired": "Ju lutemi zgjidhni një projekt", + "pressTabToSelectDueDateAndProject": "Shtyp Tab për të zgjedhur afatin dhe projektin", + "dueOn": "Detyrat me afat më", + "taskRequired": "Ju lutemi shtoni një detyrë", + "list": "Listë", + "calendar": "Kalendar", + "tasks": "Detyrat", + "refresh": "Rifresko" + } +} diff --git a/worklenz-backend/src/public/locales/alb/invite-initial-team-members.json b/worklenz-backend/src/public/locales/alb/invite-initial-team-members.json new file mode 100644 index 00000000..c86da726 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/invite-initial-team-members.json @@ -0,0 +1,8 @@ +{ + "formTitle": "Fto ekipin tënd të punojë me", + "inputLabel": "Fto me email", + "addAnother": "Shto një tjetër", + "goBack": "Kthehu mbrapa", + "continue": "Vazhdo", + "skipForNow": "Anashkalo tani për tani" +} diff --git a/worklenz-backend/src/public/locales/alb/kanban-board.json b/worklenz-backend/src/public/locales/alb/kanban-board.json new file mode 100644 index 00000000..def705aa --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/kanban-board.json @@ -0,0 +1,30 @@ +{ + "rename": "Riemërto", + "delete": "Fshi", + "addTask": "Shto Detyrë", + "addSectionButton": "Shto Seksion", + "changeCategory": "Ndrysho kategorinë", + + "deleteTooltip": "Fshi", + "deleteConfirmationTitle": "Jeni i sigurt?", + "deleteConfirmationOk": "Po", + "deleteConfirmationCancel": "Anulo", + + "dueDate": "Data e përfundimit", + "cancel": "Anulo", + + "today": "Sot", + "tomorrow": "Nesër", + "assignToMe": "Cakto mua", + "archive": "Arkivo", + + "newTaskNamePlaceholder": "Shkruaj emrin e detyrës", + "newSubtaskNamePlaceholder": "Shkruaj emrin e nëndetyrës", + "untitledSection": "Seksion pa titull", + "unmapped": "Pa hartë", + "clickToChangeDate": "Klikoni për të ndryshuar datën", + "noDueDate": "Pa datë përfundimi", + "save": "Ruaj", + "clear": "Pastro", + "nextWeek": "Javën e ardhshme" +} diff --git a/worklenz-backend/src/public/locales/alb/license-expired.json b/worklenz-backend/src/public/locales/alb/license-expired.json new file mode 100644 index 00000000..94abf5ae --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/license-expired.json @@ -0,0 +1,6 @@ +{ + "title": "Prova juaj e Worklenz ka skaduar!", + "subtitle": "Ju lutemi përmirësoni tani.", + "button": "Përmirëso tani", + "checking": "Po kontrollohet statusi i abonimit..." +} diff --git a/worklenz-backend/src/public/locales/alb/navbar.json b/worklenz-backend/src/public/locales/alb/navbar.json new file mode 100644 index 00000000..88c53de4 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/navbar.json @@ -0,0 +1,31 @@ +{ + "logoAlt": "Logoja e Worklenz", + "home": "Kryefaqja", + "projects": "Projektet", + "schedule": "Orari", + "reporting": "Raportimi", + "clients": "Klientët", + "teams": "Ekipet", + "labels": "Etiketa", + "jobTitles": "Tituj Pune", + "upgradePlan": "Përmirëso Abonimin", + "upgradePlanTooltip": "Përmirëso abonimin", + "invite": "Fto", + "inviteTooltip": "Fto anëtarë të ekipit të bashkohen", + "switchTeamTooltip": "Ndrysho ekipin", + "help": "Ndihmë", + "notificationTooltip": "Shiko njoftimet", + "profileTooltip": "Shiko profilin", + "adminCenter": "Qendra Administrative", + "settings": "Cilësimet", + "logOut": "Dil", + "notificationsDrawer": { + "read": "Lexuara e njoftimet ", + "unread": "Njoftimet e palexuara", + "markAsRead": "Shëno si të lexuara", + "readAndJoin": "Lexo & Bashkohu", + "accept": "Prano", + "acceptAndJoin": "Prano & Bashkohu", + "noNotifications": "Asnjë njoftim" + } +} diff --git a/worklenz-backend/src/public/locales/alb/organization-name-form.json b/worklenz-backend/src/public/locales/alb/organization-name-form.json new file mode 100644 index 00000000..d01b09c8 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/organization-name-form.json @@ -0,0 +1,5 @@ +{ + "nameYourOrganization": "Emërtoni organizatën tuaj.", + "worklenzAccountTitle": "Zgjidhni një emër për llogarinë tuaj në Worklenz.", + "continue": "Vazhdo" +} diff --git a/worklenz-backend/src/public/locales/alb/phases-drawer.json b/worklenz-backend/src/public/locales/alb/phases-drawer.json new file mode 100644 index 00000000..cccda7d2 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/phases-drawer.json @@ -0,0 +1,19 @@ +{ + "configurePhases": "Konfiguro Fazat", + "phaseLabel": "Etiketa e Fazës", + "enterPhaseName": "Vendosni një emër për etiketën e fazës", + "addOption": "Shto Opsion", + "phaseOptions": "Opsionet e Fazës:", + "dragToReorderPhases": "Zvarrit fazat për t'i rirenditur. Çdo fazë mund të ketë një ngjyrë të ndryshme.", + "enterNewPhaseName": "Shkruani emrin e fazës së re...", + "addPhase": "Shto Fazë", + "noPhasesFound": "Nuk u gjetën faza. Krijoni fazën tuaj të parë më sipër.", + "deletePhase": "Fshi Fazën", + "deletePhaseConfirm": "Jeni të sigurt që doni të fshini këtë fazë? Ky veprim nuk mund të zhbëhet.", + "rename": "Riemëro", + "delete": "Fshi", + "enterPhaseName": "Shkruani emrin e fazës", + "selectColor": "Zgjidh ngjyrën", + "managePhases": "Menaxho Fazat", + "close": "Mbyll" +} diff --git a/worklenz-backend/src/public/locales/alb/project-drawer.json b/worklenz-backend/src/public/locales/alb/project-drawer.json new file mode 100644 index 00000000..952dba7e --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/project-drawer.json @@ -0,0 +1,42 @@ +{ + "createProject": "Krijo Projekt", + "editProject": "Modifiko Projektin", + "enterCategoryName": "Vendosni emër për kategorinë", + "hitEnterToCreate": "Shtyp Enter për të krijuar!", + "enterNotes": "Shënime", + "youCanManageClientsUnderSettings": "Mund të menaxhoni klientët nën Cilësimet", + "addCategory": "Shto kategori projektit", + "newCategory": "Kategori e Re", + "notes": "Shënime", + "startDate": "Data e Fillimit", + "endDate": "Data e Përfundimit", + "estimateWorkingDays": "Vlerëso ditët e punës", + "estimateManDays": "Vlerëso ditët e punëtorëve", + "hoursPerDay": "Orë në ditë", + "create": "Krijo", + "update": "Përditëso", + "delete": "Fshi", + "typeToSearchClients": "Shkruani për të kërkuar klientë", + "projectColor": "Ngjyra e Projektit", + "pleaseEnterAName": "Ju lutemi vendosni një emër", + "enterProjectName": "Vendosni emrin e projektit", + "name": "Emri", + "status": "Statusi", + "health": "Gjendja", + "category": "Kategoria", + "projectManager": "Menaxheri i Projektit", + "client": "Klienti", + "deleteConfirmation": "Jeni i sigurt që doni të fshini?", + "deleteConfirmationDescription": "Kjo do të fshijë të gjitha të dhënat e lidhura dhe nuk mund të zhbëhet.", + "yes": "Po", + "no": "Jo", + "createdAt": "Krijuar më", + "updatedAt": "Përditësuar më", + "by": "nga", + "add": "Shto", + "asClient": "si klient", + "createClient": "Krijo klient", + "searchInputPlaceholder": "Kërko sipas emrit ose emailit", + "hoursPerDayValidationMessage": "Orët në ditë duhet të jenë një numër midis 1 dhe 24", + "noPermission": "Nuk ka leje" +} diff --git a/worklenz-backend/src/public/locales/alb/project-view-files.json b/worklenz-backend/src/public/locales/alb/project-view-files.json new file mode 100644 index 00000000..1a49c36a --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/project-view-files.json @@ -0,0 +1,14 @@ +{ + "nameColumn": "Emri", + "attachedTaskColumn": "Detyra e Bashkangjitur", + "sizeColumn": "Madhësia", + "uploadedByColumn": "Ngarkuar Nga", + "uploadedAtColumn": "Ngarkuar Më", + "fileIconAlt": "Ikona e skedarit", + "titleDescriptionText": "Të gjitha bashkëngjitjet e detyrave në këtë projekt do të shfahen këtu.", + "deleteConfirmationTitle": "Jeni i sigurt?", + "deleteConfirmationOk": "Po", + "deleteConfirmationCancel": "Anulo", + "segmentedTooltip": "Së shpejti! Kaloni midis pamjes listë dhe pamjes miniaturash.", + "emptyText": "Nuk ka bashkëngjitje në projekt." +} diff --git a/worklenz-backend/src/public/locales/alb/project-view-insights.json b/worklenz-backend/src/public/locales/alb/project-view-insights.json new file mode 100644 index 00000000..c7714578 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/project-view-insights.json @@ -0,0 +1,41 @@ +{ + "overview": { + "title": "Përmbledhje", + "statusOverview": "Përmbledhje Statusi", + "priorityOverview": "Përmbledhje Prioriteti", + "lastUpdatedTasks": "Detyrat e Përditësuara Së Fundi" + }, + "members": { + "title": "Anëtarët", + "tooltip": "Anëtarët", + "tasksByMembers": "Detyrat sipas anëtarëve", + "tasksByMembersTooltip": "Detyrat sipas anëtarëve", + "name": "Emri", + "taskCount": "Numri i Detyrave", + "contribution": "Kontributi", + "completed": "Të Përfunduara", + "incomplete": "Të Papërfunduara", + "overdue": "Të Vonuara", + "progress": "Progresi" + }, + "tasks": { + "overdueTasks": "Detyrat e Vonuara", + "overLoggedTasks": "Detyrat me regjistrim të tepërt", + "tasksCompletedEarly": "Detyrat e përfunduara para afatit", + "tasksCompletedLate": "Detyrat e përfunduara pas afatit", + "overLoggedTasksTooltip": "Detyrat me kohë të regjistruar mbi kohën e vlerësuar", + "overdueTasksTooltip": "Detyrat që kanë kaluar afatin e tyre" + }, + "common": { + "seeAll": "Shiko të gjitha", + "totalLoggedHours": "Orët totale të regjistruara", + "totalEstimation": "Vlerësimi total", + "completedTasks": "Detyrat e përfunduara", + "incompleteTasks": "Detyrat e papërfunduara", + "overdueTasks": "Detyrat e vonuara", + "overdueTasksTooltip": "Detyrat që kanë kaluar afatin e tyre", + "totalLoggedHoursTooltip": "Vlerësimi dhe koha e regjistruar për detyrat.", + "includeArchivedTasks": "Përfshi Detyrat e Arkivuara", + "export": "Eksporto" + } +} diff --git a/worklenz-backend/src/public/locales/alb/project-view-members.json b/worklenz-backend/src/public/locales/alb/project-view-members.json new file mode 100644 index 00000000..239b77e9 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/project-view-members.json @@ -0,0 +1,17 @@ +{ + "nameColumn": "Emri", + "jobTitleColumn": "Titulli i Punës", + "emailColumn": "Email", + "tasksColumn": "Detyrat", + "taskProgressColumn": "Progresi i Detyrave", + "accessColumn": "Qasja", + "fileIconAlt": "Ikona e skedarit", + "deleteConfirmationTitle": "Jeni i sigurt?", + "deleteConfirmationOk": "Po", + "deleteConfirmationCancel": "Anulo", + "refreshButtonTooltip": "Rifresko anëtarët", + "deleteButtonTooltip": "Hiq nga projekti", + "memberCount": "Anëtar", + "membersCountPlural": "Anëtarë", + "emptyText": "Nuk ka bashkëngjitje në projekt." +} diff --git a/worklenz-backend/src/public/locales/alb/project-view-updates.json b/worklenz-backend/src/public/locales/alb/project-view-updates.json new file mode 100644 index 00000000..15a4ec1c --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/project-view-updates.json @@ -0,0 +1,6 @@ +{ + "inputPlaceholder": "Shto një koment..", + "addButton": "Shto", + "cancelButton": "Anulo", + "deleteButton": "Fshi" +} diff --git a/worklenz-backend/src/public/locales/alb/project-view.json b/worklenz-backend/src/public/locales/alb/project-view.json new file mode 100644 index 00000000..2bc256fe --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/project-view.json @@ -0,0 +1,14 @@ +{ + "taskList": "Lista e Detyrave", + "board": "Tabela Kanban", + "insights": "Analiza", + "files": "Skedarë", + "members": "Anëtarë", + "updates": "Përditësime", + "projectView": "Pamja e Projektit", + "loading": "Duke ngarkuar projektin...", + "error": "Gabim në ngarkimin e projektit", + "pinnedTab": "E fiksuar si tab i parazgjedhur", + "pinTab": "Fikso si tab i parazgjedhur", + "unpinTab": "Hiqe fiksimin e tab-it të parazgjedhur" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/alb/project-view/import-task-templates.json b/worklenz-backend/src/public/locales/alb/project-view/import-task-templates.json new file mode 100644 index 00000000..fab0381b --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/project-view/import-task-templates.json @@ -0,0 +1,11 @@ +{ + "importTaskTemplate": "Importo Shabllon Detyrash", + "templateName": "Emri i Shabllonit", + "templateDescription": "Përshkrimi i Shabllonit", + "selectedTasks": "Detyrat e Përzgjedhura", + "tasks": "Detyrat", + "templates": "Shabllonet", + "remove": "Hiq", + "cancel": "Anulo", + "import": "Importo" +} diff --git a/worklenz-backend/src/public/locales/alb/project-view/project-member-drawer.json b/worklenz-backend/src/public/locales/alb/project-view/project-member-drawer.json new file mode 100644 index 00000000..aa6637e1 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/project-view/project-member-drawer.json @@ -0,0 +1,7 @@ +{ + "title": "Anëtarët e Projektit", + "searchLabel": "Shtoni anëtarë duke shkruar emrin ose email-in e tyre", + "searchPlaceholder": "Shkruani emrin ose email-in", + "inviteAsAMember": "Fto si anëtar", + "inviteNewMemberByEmail": "Fto anëtar të ri me email" +} diff --git a/worklenz-backend/src/public/locales/alb/project-view/project-view-header.json b/worklenz-backend/src/public/locales/alb/project-view/project-view-header.json new file mode 100644 index 00000000..51d91ba1 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/project-view/project-view-header.json @@ -0,0 +1,30 @@ +{ + "importTasks": "Importo detyra", + "importTask": "Importo detyrë", + "createTask": "Krijo detyrë", + "settings": "Cilësimet", + "subscribe": "Abonohu", + "unsubscribe": "Çabonohu", + "deleteProject": "Fshi projektin", + "startDate": "Data e fillimit", + "endDate": "Data e mbarimit", + "projectSettings": "Cilësimet e projektit", + "projectSummary": "Përmbledhja e projektit", + "receiveProjectSummary": "Merrni një përmbledhje të projektit çdo mbrëmje.", + "refreshProject": "Rifresko projektin", + "saveAsTemplate": "Ruaj si model", + "invite": "Fto", + "share": "Ndaj", + "subscribeTooltip": "Abonohu tek njoftimet e projektit", + "unsubscribeTooltip": "Çabonohu nga njoftimet e projektit", + "refreshTooltip": "Rifresko të dhënat e projektit", + "settingsTooltip": "Hap cilësimet e projektit", + "saveAsTemplateTooltip": "Ruaj këtë projekt si model", + "inviteTooltip": "Fto anëtarë të ekipit në këtë projekt", + "createTaskTooltip": "Krijo një detyrë të re", + "importTaskTooltip": "Importo detyrë nga modeli", + "navigateBackTooltip": "Kthehu tek lista e projekteve", + "projectStatusTooltip": "Statusi i projektit", + "projectDatesInfo": "Informacion për kohëzgjatjen e projektit", + "projectCategoryTooltip": "Kategoria e projektit" +} diff --git a/worklenz-backend/src/public/locales/alb/project-view/save-as-template.json b/worklenz-backend/src/public/locales/alb/project-view/save-as-template.json new file mode 100644 index 00000000..63d7ace8 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/project-view/save-as-template.json @@ -0,0 +1,27 @@ +{ + "title": "Ruaj si Shabllon", + "templateName": "Emri i Shabllonit", + "includes": "Çfarë duhet të përfshihet në shabllon nga projekti?", + "includesOptions": { + "statuses": "Statuset", + "phases": "Fazat", + "labels": "Etiketat" + }, + "taskIncludes": "Çfarë duhet të përfshihet në shabllon nga detyrat?", + "taskIncludesOptions": { + "statuses": "Statuset", + "phases": "Fazat", + "labels": "Etiketat", + "name": "Emri", + "priority": "Prioriteti", + "status": "Statusi", + "phase": "Faza", + "label": "Etiketa", + "timeEstimate": "Vlerësimi i Kohës", + "description": "Përshkrimi", + "subTasks": "Nëndetyrat" + }, + "cancel": "Anulo", + "save": "Ruaj", + "templateNamePlaceholder": "Shkruani emrin e shabllonit" +} diff --git a/worklenz-backend/src/public/locales/alb/reporting-members-drawer.json b/worklenz-backend/src/public/locales/alb/reporting-members-drawer.json new file mode 100644 index 00000000..899e590e --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/reporting-members-drawer.json @@ -0,0 +1,90 @@ +{ + "exportButton": "Eksporto", + "timeLogsButton": "Regjistrimet e Kohës", + "activityLogsButton": "Regjistrimet e Aktivitetit", + "tasksButton": "Detyrat", + "searchByNameInputPlaceholder": "Kërko sipas emrit", + + "overviewTab": "Përmbledhje", + "timeLogsTab": "Regjistrimet e Kohës", + "activityLogsTab": "Regjistrimet e Aktivitetit", + "tasksTab": "Detyrat", + + "projectsText": "Projektet", + "totalTasksText": "Detyrat Gjithsej", + "assignedTasksText": "Detyrat e Caktuara", + "completedTasksText": "Detyrat e Përfunduara", + "ongoingTasksText": "Detyrat në Vazhdim", + "overdueTasksText": "Detyrat e Vonuara", + "loggedHoursText": "Orët e Regjistruara", + + "tasksText": "Detyrat", + "allText": "Të Gjitha", + + "tasksByProjectsText": "Detyrat Sipas Projekteve", + "tasksByStatusText": "Detyrat Sipas Statusit", + "tasksByPriorityText": "Detyrat Sipas Prioritetit", + + "todoText": "Për Të Bërë", + "doingText": "Duke bërë", + "doneText": "E Përfunduar", + "lowText": "I Ulët", + "mediumText": "I Mesëm", + "highText": "I Lartë", + + "billableButton": "Fakturueshme", + "billableText": "Fakturueshme", + "nonBillableText": "Jo Fakturueshme", + + "timeLogsEmptyPlaceholder": "Asnjë regjistrim kohe për të shfaqur", + "loggedText": "Regjistruar", + "forText": "për", + "inText": "në", + "updatedText": "Përditësuar", + "fromText": "Nga", + "toText": "në", + "withinText": "brenda", + + "activityLogsEmptyPlaceholder": "Asnjë regjistrim aktiviteti për të shfaqur", + + "filterByText": "Filtro sipas:", + "selectProjectPlaceholder": "Zgjidh Projektin", + + "taskColumn": "Detyra", + "nameColumn": "Emri", + "projectColumn": "Projekti", + "statusColumn": "Statusi", + "priorityColumn": "Prioriteti", + "dueDateColumn": "Afati", + "completedDateColumn": "Data e Përfundimit", + "estimatedTimeColumn": "Koha e Vlerësuar", + "loggedTimeColumn": "Koha e Regjistruar", + "overloggedTimeColumn": "Koha e Tepërt", + "daysLeftColumn": "Ditë të Mbetura/Vonuar", + "startDateColumn": "Data e Fillimit", + "endDateColumn": "Data e Përfundimit", + "actualTimeColumn": "Koha Aktuale", + "projectHealthColumn": "Gjendja e Projektit", + "categoryColumn": "Kategoria", + "projectManagerColumn": "Menaxheri i Projektit", + + "tasksStatsOverviewDrawerTitle": "Detyrat e ", + "projectsStatsOverviewDrawerTitle": "Projektet e ", + + "cancelledText": "Anuluar", + "blockedText": "E Bllokuar", + "onHoldText": "Në Pritje", + "proposedText": "E Propozuar", + "inPlanningText": "Në Planifikim", + "inProgressText": "Në Progres", + "completedText": "E Përfunduar", + "continuousText": "E Vazhdueshme", + + "daysLeftText": "ditë të mbetura", + "daysOverdueText": "ditë vonuar", + + "notSetText": "Pa Caktuar", + "needsAttentionText": "Kërkon Vëmendje", + "atRiskText": "Në Rrezik", + "goodText": "Në Rregull" +} diff --git a/worklenz-backend/src/public/locales/alb/reporting-members.json b/worklenz-backend/src/public/locales/alb/reporting-members.json new file mode 100644 index 00000000..d88f0662 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/reporting-members.json @@ -0,0 +1,35 @@ +{ + "yesterdayText": "Dje", + "lastSevenDaysText": "7 Ditët e Fundit", + "lastWeekText": "Javën e Kaluar", + "lastThirtyDaysText": "30 Ditët e Fundit", + "lastMonthText": "Muajin e Kaluar", + "lastThreeMonthsText": "3 Muajt e Fundit", + "allTimeText": "Të Gjitha", + "customRangeText": "Interval i Përshtatur", + "startDateInputPlaceholder": "Data e fillimit", + "EndDateInputPlaceholder": "Data e përfundimit", + "filterButton": "Filtro", + + "membersTitle": "Anëtarët", + "includeArchivedButton": "Përfshij Projektet e Arkivuara", + "exportButton": "Eksporto", + "excelButton": "Excel", + "searchByNameInputPlaceholder": "Kërko sipas emrit", + + "memberColumn": "Anëtari", + "tasksProgressColumn": "Progresi i Detyrave", + "tasksAssignedColumn": "Detyrat e Caktuara", + "completedTasksColumn": "Detyrat e Përfunduara", + "overdueTasksColumn": "Detyrat e Vonuara", + "ongoingTasksColumn": "Detyrat në Vazhdim", + + "tasksAssignedColumnTooltip": "Detyrat e caktuara në intervalin e zgjedhur", + "overdueTasksColumnTooltip": "Detyrat e vonuara deri në fund të intervalit të zgjedhur", + "completedTasksColumnTooltip": "Detyrat e përfunduara në intervalin e zgjedhur", + "ongoingTasksColumnTooltip": "Detyrat e filluara por jo të përfunduara ende", + + "todoText": "Për Të Bërë", + "doingText": "Duke bërë", + "doneText": "E Përfunduar" +} diff --git a/worklenz-backend/src/public/locales/alb/reporting-overview-drawer.json b/worklenz-backend/src/public/locales/alb/reporting-overview-drawer.json new file mode 100644 index 00000000..9e4d9186 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/reporting-overview-drawer.json @@ -0,0 +1,39 @@ +{ + "exportButton": "Eksporto", + "projectsButton": "Projektet", + "membersButton": "Anëtarët", + "searchByNameInputPlaceholder": "Kërko sipas emrit", + + "overviewTab": "Përmbledhje", + "projectsTab": "Projektet", + "membersTab": "Anëtarët", + + "projectsByStatusText": "Projektet Sipas Statusit", + "projectsByCategoryText": "Projektet Sipas Kategorisë", + "projectsByHealthText": "Projektet Sipas Gjendjes", + + "projectsText": "Projektet", + "allText": "Të Gjitha", + + "cancelledText": "Anuluar", + "blockedText": "E Bllokuar", + "onHoldText": "Në Pritje", + "proposedText": "E Propozuar", + "inPlanningText": "Në Planifikim", + "inProgressText": "Në Progres", + "completedText": "E Përfunduar", + "continuousText": "E Vazhdueshme", + + "notSetText": "Pa Caktuar", + "needsAttentionText": "Kërkon Vëmendje", + "atRiskText": "Në Rrezik", + "goodText": "Në Rregull", + + "nameColumn": "Emri", + "emailColumn": "Email", + "projectsColumn": "Projektet", + "tasksColumn": "Detyrat", + "overdueTasksColumn": "Detyrat e Vonuara", + "completedTasksColumn": "Detyrat e Përfunduara", + "ongoingTasksColumn": "Detyrat në Vazhdim" +} diff --git a/worklenz-backend/src/public/locales/alb/reporting-overview.json b/worklenz-backend/src/public/locales/alb/reporting-overview.json new file mode 100644 index 00000000..b8977912 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/reporting-overview.json @@ -0,0 +1,25 @@ +{ + "overviewTitle": "Përmbledhje", + "includeArchivedButton": "Përfshij Projektet e Arkivuara", + + "teamCount": "Ekip", + "teamCountPlural": "Ekipe", + "projectCount": "Projekt", + "projectCountPlural": "Projekte", + "memberCount": "Anëtar", + "memberCountPlural": "Anëtarë", + "activeProjectCount": "Projekt Aktiv", + "activeProjectCountPlural": "Projekte Aktive", + "overdueProjectCount": "Projekt i Vonuar", + "overdueProjectCountPlural": "Projekte të Vonuara", + "unassignedMemberCount": "Anëtar i Pacaktuar", + "unassignedMemberCountPlural": "Anëtarë të Pacaktuar", + "memberWithOverdueTaskCount": "Anëtar me Detyrë të Vonuar", + "memberWithOverdueTaskCountPlural": "Anëtarë me Detyra të Vonuara", + + "teamsText": "Ekipet", + + "nameColumn": "Emri", + "projectsColumn": "Projektet", + "membersColumn": "Anëtarët" +} diff --git a/worklenz-backend/src/public/locales/alb/reporting-projects-drawer.json b/worklenz-backend/src/public/locales/alb/reporting-projects-drawer.json new file mode 100644 index 00000000..fd3a380c --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/reporting-projects-drawer.json @@ -0,0 +1,59 @@ +{ + "exportButton": "Eksporto", + "membersButton": "Anëtarët", + "tasksButton": "Detyrat", + "searchByNameInputPlaceholder": "Kërko sipas emrit", + + "overviewTab": "Përmbledhje", + "membersTab": "Anëtarët", + "tasksTab": "Detyrat", + + "completedTasksText": "Detyrat e Përfunduara", + "incompleteTasksText": "Detyrat e Papërfunduara", + "overdueTasksText": "Detyrat e Vonuara", + "allocatedHoursText": "Orët e Alokuara", + "loggedHoursText": "Orët e Regjistruara", + + "tasksText": "Detyrat", + "allText": "Të Gjitha", + + "tasksByStatusText": "Detyrat Sipas Statusit", + "tasksByPriorityText": "Detyrat Sipas Prioritetit", + "tasksByDueDateText": "Detyrat Sipas Afatit", + + "todoText": "Për Të Bërë", + "doingText": "Duke bërë", + "doneText": "E Përfunduar", + "lowText": "I Ulët", + "mediumText": "I Mesëm", + "highText": "I Lartë", + "completedText": "E Përfunduar", + "upcomingText": "Në Ardhje", + "overdueText": "E Vonuar", + "noDueDateText": "Pa Afat", + + "nameColumn": "Emri", + "tasksCountColumn": "Numri i Detyrave", + "completedTasksColumn": "Detyrat e Përfunduara", + "incompleteTasksColumn": "Detyrat e Papërfunduara", + "overdueTasksColumn": "Detyrat e Vonuara", + "contributionColumn": "Kontributi", + "progressColumn": "Progresi", + "loggedTimeColumn": "Koha e Regjistruar", + "taskColumn": "Detyra", + "projectColumn": "Projekti", + "statusColumn": "Statusi", + "priorityColumn": "Prioriteti", + "phaseColumn": "Faza", + "dueDateColumn": "Afati", + "completedDateColumn": "Data e Përfundimit", + "estimatedTimeColumn": "Koha e Vlerësuar", + "overloggedTimeColumn": "Koha e Tepërt", + "completedOnColumn": "Përfunduar Më", + "daysOverdueColumn": "Ditë vonim", + + "groupByText": "Grupo Sipas:", + "statusText": "Statusi", + "priorityText": "Prioriteti", + "phaseText": "Faza" +} diff --git a/worklenz-backend/src/public/locales/alb/reporting-projects-filters.json b/worklenz-backend/src/public/locales/alb/reporting-projects-filters.json new file mode 100644 index 00000000..614d57f3 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/reporting-projects-filters.json @@ -0,0 +1,35 @@ +{ + "searchByNamePlaceholder": "Kërko sipas emrit", + "searchByCategoryPlaceholder": "Kërko sipas kategorisë", + + "statusText": "Statusi", + "healthText": "Gjendja", + "categoryText": "Kategoria", + "projectManagerText": "Menaxheri i Projektit", + "showFieldsText": "Shfaq fushat", + + "cancelledText": "Anuluar", + "blockedText": "E bllokuar", + "onHoldText": "Në pritje", + "proposedText": "E propozuar", + "inPlanningText": "Në planifikim", + "inProgressText": "Në progres", + "completedText": "E përfunduar", + "continuousText": "E vazhdueshme", + + "notSetText": "Pa caktuar", + "needsAttentionText": "Kërkon vëmendje", + "atRiskText": "Në rrezik", + "goodText": "Në rregull", + + "nameText": "Projekti", + "estimatedVsActualText": "Vlerësuar vs Aktual", + "tasksProgressText": "Progresi i detyrave", + "lastActivityText": "Aktiviteti i fundit", + "datesText": "Datat e Fillimit/Përfundimit", + "daysLeftText": "Ditë të mbetura/vonuar", + "projectHealthText": "Gjendja e projektit", + "projectUpdateText": "Përditësimi i projektit", + "clientText": "Klienti", + "teamText": "Ekipi" +} diff --git a/worklenz-backend/src/public/locales/alb/reporting-projects.json b/worklenz-backend/src/public/locales/alb/reporting-projects.json new file mode 100644 index 00000000..c10e8f7a --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/reporting-projects.json @@ -0,0 +1,52 @@ +{ + "projectCount": "Projekt", + "projectCountPlural": "Projekte", + "includeArchivedButton": "Përfshij Projektet e Arkivuara", + "exportButton": "Eksporto", + "excelButton": "Excel", + + "projectColumn": "Projekti", + "estimatedVsActualColumn": "Vlerësuar vs Aktual", + "tasksProgressColumn": "Progresi i Detyrave", + "lastActivityColumn": "Aktiviteti i Fundit", + "statusColumn": "Statusi", + "datesColumn": "Data e Fillimit/Përfundimit", + "daysLeftColumn": "Ditë të Mbetura/Vonuar", + "projectHealthColumn": "Gjendja e Projektit", + "categoryColumn": "Kategoria", + "projectUpdateColumn": "Përditësimi i Projektit", + "clientColumn": "Klienti", + "teamColumn": "Ekipi", + "projectManagerColumn": "Menaxheri i Projektit", + + "openButton": "Hap", + + "estimatedText": "Vlerësuar", + "actualText": "Aktual", + + "todoText": "Për të Bërë", + "doingText": "duke bërë", + "doneText": "E Përfunduar", + + "cancelledText": "Anuluar", + "blockedText": "E Bllokuar", + "onHoldText": "Në Pritje", + "proposedText": "E Propozuar", + "inPlanningText": "Në Planifikim", + "inProgressText": "Në Progres", + "completedText": "E Përfunduar", + "continuousText": "E Vazhdueshme", + + "daysLeftText": "ditë të mbetura", + "dayLeftText": "ditë e mbetur", + "daysOverdueText": "ditë vonuar", + + "notSetText": "Pa Caktuar", + "needsAttentionText": "Kërkon Vëmendje", + "atRiskText": "Në Rrezik", + "goodText": "Në Rregull", + + "setCategoryText": "Cakto Kategorinë", + "searchByNameInputPlaceholder": "Kërko sipas emrit", + "todayText": "Sot" +} diff --git a/worklenz-backend/src/public/locales/alb/reporting-sidebar.json b/worklenz-backend/src/public/locales/alb/reporting-sidebar.json new file mode 100644 index 00000000..a8c14e68 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/reporting-sidebar.json @@ -0,0 +1,8 @@ +{ + "overview": "Përmbledhje", + "projects": "Projektet", + "members": "Anëtarët", + "timeReports": "Raportet e Kohës", + "estimateVsActual": "Vlerësimi vs Aktual", + "currentOrganizationTooltip": "Organizata aktuale" +} diff --git a/worklenz-backend/src/public/locales/alb/schedule.json b/worklenz-backend/src/public/locales/alb/schedule.json new file mode 100644 index 00000000..a5670aaa --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/schedule.json @@ -0,0 +1,39 @@ +{ + "today": "Sot", + "week": "Javë", + "month": "Muaj", + + "settings": "Cilësimet", + "workingDays": "Ditët e punës", + "monday": "E hënë", + "tuesday": "E martë", + "wednesday": "E mërkurë", + "thursday": "E enjte", + "friday": "E premte", + "saturday": "E shtunë", + "sunday": "E diel", + "workingHours": "Orët e punës", + "hours": "Orë", + "saveButton": "Ruaj", + + "totalAllocation": "Alokimi Total", + "timeLogged": "Koha e Regjistruar", + "remainingTime": "Koha e Mbetur", + "total": "Total", + "perDay": "Në Ditë", + "tasks": "detyra", + "startDate": "Data e Fillimit", + "endDate": "Data e Përfundimit", + + "hoursPerDay": "Orë Në Ditë", + "totalHours": "Orë Totale", + "deleteButton": "Fshi", + "cancelButton": "Anulo", + + "tabTitle": "Detyra pa Data Fillimi & Përfundimi", + + "allocatedTime": "Koha e alokuar", + "totalLogged": "Total i Regjistruar", + "loggedBillable": "Regjistruar Fakturueshme", + "loggedNonBillable": "Regjistruar Jo Fakturueshme" +} diff --git a/worklenz-backend/src/public/locales/alb/settings/categories.json b/worklenz-backend/src/public/locales/alb/settings/categories.json new file mode 100644 index 00000000..62eb60d5 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/settings/categories.json @@ -0,0 +1,10 @@ +{ + "categoryColumn": "Kategoria", + "deleteConfirmationTitle": "Jeni të sigurt?", + "deleteConfirmationOk": "Po", + "deleteConfirmationCancel": "Anulo", + "associatedTaskColumn": "Projektet e Lidhura", + "searchPlaceholder": "Kërko sipas emrit", + "emptyText": "Kategoritë mund të krijohen gjatë përditësimit ose krijimit të projekteve.", + "colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën" +} diff --git a/worklenz-backend/src/public/locales/alb/settings/change-password.json b/worklenz-backend/src/public/locales/alb/settings/change-password.json new file mode 100644 index 00000000..ac1500bd --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/settings/change-password.json @@ -0,0 +1,15 @@ +{ + "title": "Ndrysho Fjalëkalimin", + "currentPassword": "Fjalëkalimi Aktual", + "newPassword": "Fjalëkalimi i Ri", + "confirmPassword": "Konfirmo Fjalëkalimin", + "currentPasswordPlaceholder": "Vendosni fjalëkalimin aktual", + "newPasswordPlaceholder": "Fjalëkalimi i Ri", + "confirmPasswordPlaceholder": "Konfirmo Fjalëkalimin", + "currentPasswordRequired": "Ju lutemi vendosni fjalëkalimin aktual!", + "newPasswordRequired": "Ju lutemi vendosni fjalëkalimin e ri!", + "passwordValidationError": "Fjalëkalimi duhet të përmbajë të paktën 8 karaktere, me një shkronjë të madhe, një numër dhe një simbol.", + "passwordMismatch": "Fjalëkalimet nuk përputhen!", + "passwordRequirements": "Fjalëkalimi i ri duhet të jetë së paku 8 karaktere, me një shkronjë të madhe, një numër dhe një simbol.", + "updateButton": "Përditëso Fjalëkalimin" +} diff --git a/worklenz-backend/src/public/locales/alb/settings/clients.json b/worklenz-backend/src/public/locales/alb/settings/clients.json new file mode 100644 index 00000000..72407a5e --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/settings/clients.json @@ -0,0 +1,22 @@ +{ + "nameColumn": "Emri", + "projectColumn": "Projekti", + "noProjectsAvailable": "Nuk ka projekte të disponueshme", + "deleteConfirmationTitle": "Jeni i sigurt?", + "deleteConfirmationOk": "Po", + "deleteConfirmationCancel": "Anulo", + "searchPlaceholder": "Kërko sipas emrit", + "createClient": "Krijo Klient", + "pinTooltip": "Klikoni për ta fiksuar në menynë kryesore", + "createClientDrawerTitle": "Krijo Klient", + "updateClientDrawerTitle": "Përditëso Klientin", + "nameLabel": "Emri", + "namePlaceholder": "Emri", + "nameRequiredError": "Ju lutemi shkruani një Emër", + "createButton": "Krijo", + "updateButton": "Përditëso", + "createClientSuccessMessage": "Klienti u krijua me sukses!", + "createClientErrorMessage": "Krijimi i klientit dështoi!", + "updateClientSuccessMessage": "Klienti u përditësua me sukses!", + "updateClientErrorMessage": "Përditësimi i klientit dështoi!" +} diff --git a/worklenz-backend/src/public/locales/alb/settings/job-titles.json b/worklenz-backend/src/public/locales/alb/settings/job-titles.json new file mode 100644 index 00000000..a464716a --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/settings/job-titles.json @@ -0,0 +1,20 @@ +{ + "nameColumn": "Emri", + "deleteConfirmationTitle": "Jeni i sigurt?", + "deleteConfirmationOk": "Po", + "deleteConfirmationCancel": "Anulo", + "searchPlaceholder": "Kërko sipas emrit", + "createJobTitleButton": "Krijo Titull Pune", + "pinTooltip": "Klikoni për ta fiksuar në menynë kryesore", + "createJobTitleDrawerTitle": "Krijo Titull Pune", + "updateJobTitleDrawerTitle": "Përditëso Titullin e Punës", + "nameLabel": "Emri", + "namePlaceholder": "Emri", + "nameRequiredError": "Ju lutemi shkruani një Emër", + "createButton": "Krijo", + "updateButton": "Përditëso", + "createJobTitleSuccessMessage": "Titulli i punës u krijua me sukses!", + "createJobTitleErrorMessage": "Krijimi i titullit të punës dështoi!", + "updateJobTitleSuccessMessage": "Titulli i punës u përditësua me sukses!", + "updateJobTitleErrorMessage": "Përditësimi i titullit të punës dështoi!" +} diff --git a/worklenz-backend/src/public/locales/alb/settings/labels.json b/worklenz-backend/src/public/locales/alb/settings/labels.json new file mode 100644 index 00000000..40e6361b --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/settings/labels.json @@ -0,0 +1,11 @@ +{ + "labelColumn": "Etiketa", + "deleteConfirmationTitle": "Jeni i sigurt?", + "deleteConfirmationOk": "Po", + "deleteConfirmationCancel": "Anulo", + "associatedTaskColumn": "Numri i Detyrave të Lidhura", + "searchPlaceholder": "Kërko sipas emrit", + "emptyText": "Etiketat mund të krijohen gjatë përditësimit ose krijimit të detyrave.", + "pinTooltip": "Klikoni për ta fiksuar në menynë kryesore", + "colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën" +} diff --git a/worklenz-backend/src/public/locales/alb/settings/language.json b/worklenz-backend/src/public/locales/alb/settings/language.json new file mode 100644 index 00000000..7c0d3756 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/settings/language.json @@ -0,0 +1,7 @@ +{ + "language": "Gjuha", + "language_required": "Gjuha është e detyrueshme", + "time_zone": "Zona kohore", + "time_zone_required": "Zona kohore është e detyrueshme", + "save_changes": "Ruaj Ndryshimet" +} diff --git a/worklenz-backend/src/public/locales/alb/settings/notifications.json b/worklenz-backend/src/public/locales/alb/settings/notifications.json new file mode 100644 index 00000000..4bfd55b2 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/settings/notifications.json @@ -0,0 +1,11 @@ +{ + "title": "Cilësimet e Njoftimeve", + "emailTitle": "Më dërgo njoftime me email", + "emailDescription": "Kjo përfshin caktimet e reja të detyrave", + "dailyDigestTitle": "Më dërgo një përmbledhje ditore", + "dailyDigestDescription": "Çdo mbrëmje, do të merrni një përmbledhje të aktivitetit të fundit në detyra.", + "popupTitle": "Shfaq njoftimet në kompjuterin tim kur Worklenz është i hapur", + "popupDescription": "Njoftimet e shfaqura mund të çaktivizohen nga shfletuesi juaj. Ndryshoni cilësimet e shfletuesit për t'i lejuar ato.", + "unreadItemsTitle": "Shfaq numrin e artikujve të palexuar", + "unreadItemsDescription": "Do të shihni numërimin për çdo njoftim." +} diff --git a/worklenz-backend/src/public/locales/alb/settings/profile.json b/worklenz-backend/src/public/locales/alb/settings/profile.json new file mode 100644 index 00000000..dcce50d5 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/settings/profile.json @@ -0,0 +1,14 @@ +{ + "uploadError": "Mund të ngarkoni vetëm skedarë JPG/PNG!", + "uploadSizeError": "Imazhi duhet të jetë më i vogël se 2MB!", + "upload": "Ngarko", + "nameLabel": "Emri", + "nameRequiredError": "Emri është i detyrueshëm", + "emailLabel": "Email", + "emailRequiredError": "Email-i është i detyrueshëm", + "saveChanges": "Ruaj Ndryshimet", + "profileJoinedText": "U bashkua një muaj më parë", + "profileLastUpdatedText": "Përditësuar një muaj më parë", + "avatarTooltip": "Klikoni për të ngarkuar një avatar", + "title": "Cilësimet e Profilit" +} diff --git a/worklenz-backend/src/public/locales/alb/settings/project-templates.json b/worklenz-backend/src/public/locales/alb/settings/project-templates.json new file mode 100644 index 00000000..ac0a87ef --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/settings/project-templates.json @@ -0,0 +1,8 @@ +{ + "nameColumn": "Emri", + "editToolTip": "Modifiko", + "deleteToolTip": "Fshi", + "confirmText": "Jeni i sigurt?", + "okText": "Po", + "cancelText": "Anulo" +} diff --git a/worklenz-backend/src/public/locales/alb/settings/sidebar.json b/worklenz-backend/src/public/locales/alb/settings/sidebar.json new file mode 100644 index 00000000..a2b6dd2e --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/settings/sidebar.json @@ -0,0 +1,14 @@ +{ + "profile": "Profili", + "notifications": "Njoftimet", + "clients": "Klientët", + "job-titles": "Tituj Pune", + "labels": "Etiketa", + "categories": "Kategoritë", + "project-templates": "Shabllonet e Projekteve", + "task-templates": "Shabllonet e Detyrave", + "team-members": "Anëtarët e Ekipit", + "teams": "Ekipet", + "change-password": "Ndrysho Fjalëkalimin", + "language-and-region": "Gjuha dhe Rajoni" +} diff --git a/worklenz-backend/src/public/locales/alb/settings/task-templates.json b/worklenz-backend/src/public/locales/alb/settings/task-templates.json new file mode 100644 index 00000000..b053027c --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/settings/task-templates.json @@ -0,0 +1,9 @@ +{ + "nameColumn": "Emri", + "createdColumn": "Krijuar", + "editToolTip": "Redakto", + "deleteToolTip": "Fshi", + "confirmText": "Jeni i sigurt?", + "okText": "Po", + "cancelText": "Anulo" +} diff --git a/worklenz-backend/src/public/locales/alb/settings/team-members.json b/worklenz-backend/src/public/locales/alb/settings/team-members.json new file mode 100644 index 00000000..955954dc --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/settings/team-members.json @@ -0,0 +1,47 @@ +{ + "title": "Anëtarët e Ekipit", + "nameColumn": "Emri", + "projectsColumn": "Projektet", + "emailColumn": "Email", + "teamAccessColumn": "Qasja në Ekip", + "memberCount": "Anëtar", + "membersCountPlural": "Anëtarë", + "searchPlaceholder": "Kërko anëtarë sipas emrit", + "pinTooltip": "Rifresko listën e anëtarëve", + "addMemberButton": "Shto Anëtar të Ri", + "editTooltip": "Modifiko anëtarin", + "deactivateTooltip": "Çaktivizo anëtarin", + "activateTooltip": "Aktivizo anëtarin", + "deleteTooltip": "Fshi anëtarin", + "confirmDeleteTitle": "Jeni i sigurt që doni të fshini këtë anëtar?", + "confirmActivateTitle": "Jeni i sigurt që doni të ndryshoni statusin e këtij anëtari?", + "okText": "Po, vazhdo", + "cancelText": "Jo, anulo", + "deactivatedText": "(Aktualisht i çaktivizuar)", + "pendingInvitationText": "(Ftesë në pritje)", + "addMemberDrawerTitle": "Shto Anëtar të Ri në Ekip", + "updateMemberDrawerTitle": "Përditëso Anëtarin e Ekipit", + "addMemberEmailHint": "Anëtarët do të shtohen në ekip pavarësisht nga statusi i pranimit të ftesës", + "memberEmailLabel": "Email(o)", + "memberEmailPlaceholder": "Vendos adresën email të anëtarit të ekipit", + "memberEmailRequiredError": "Ju lutemi vendosni një adresë email të vlefshme", + "jobTitleLabel": "Titulli i Punës", + "jobTitlePlaceholder": "Zgjidh ose kërko titull pune (Opsionale)", + "memberAccessLabel": "Niveli i Qasjes", + "addToTeamButton": "Shto Anëtar në Ekip", + "updateButton": "Ruaj Ndryshimet", + "resendInvitationButton": "Dërgo Përsëri Email-in e Ftesës", + "invitationSentSuccessMessage": "Ftesa për ekip u dërgua me sukses!", + "createMemberSuccessMessage": "Anëtari i ri i ekipit u shtua me sukses!", + "createMemberErrorMessage": "Dështoi shtimi i anëtarit të ri. Ju lutemi provoni përsëri.", + "updateMemberSuccessMessage": "Anëtari i ekipit u përditësua me sukses!", + "updateMemberErrorMessage": "Dështoi përditësimi i anëtarit. Ju lutemi provoni përsëri.", + "memberText": "Anëtar", + "adminText": "Administrues", + "ownerText": "Pronar i Ekipit", + "addedText": "Shtuar", + "updatedText": "Përditësuar", + "noResultFound": "Shkruani një adresë email dhe shtypni Enter...", + "jobTitlesFetchError": "Dështoi marrja e titujve të punës", + "invitationResent": "Ftesa u dërgua sërish me sukses!" +} diff --git a/worklenz-backend/src/public/locales/alb/settings/teams.json b/worklenz-backend/src/public/locales/alb/settings/teams.json new file mode 100644 index 00000000..30f87d79 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/settings/teams.json @@ -0,0 +1,16 @@ +{ + "title": "Ekipet", + "team": "Ekip", + "teams": "Ekipet", + "name": "Emri", + "created": "Krijuar", + "ownsBy": "I përket", + "edit": "Ndrysho", + "editTeam": "Ndrysho Ekipin", + "pinTooltip": "Kliko për ta fiksuar në menunë kryesore", + "editTeamName": "Ndrysho Emrin e Ekipit", + "updateName": "Përditëso Emrin", + "namePlaceholder": "Emri", + "nameRequired": "Ju lutem shkruani një Emër", + "updateFailed": "Ndryshimi i emrit të ekipit dështoi!" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/alb/task-drawer/task-drawer-info-tab.json b/worklenz-backend/src/public/locales/alb/task-drawer/task-drawer-info-tab.json new file mode 100644 index 00000000..75e1226a --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/task-drawer/task-drawer-info-tab.json @@ -0,0 +1,29 @@ +{ + "details": { + "task-key": "Çelësi i Detyrës", + "phase": "Faza", + "assignees": "Përgjegjësit", + "due-date": "Data e Përfundimit", + "time-estimation": "Vlerësimi i Kohës", + "priority": "Prioriteti", + "labels": "Etiketa", + "billable": "Fakturueshme", + "notify": "Njofto", + "when-done-notify": "Kur të përfundojë, njofto", + "start-date": "Data e Fillimit", + "end-date": "Data e Përfundimit", + "hide-start-date": "Fshih Datën e Fillimit", + "show-start-date": "Shfaq Datën e Fillimit", + "hours": "Orë", + "minutes": "Minuta" + }, + "description": { + "title": "Përshkrimi", + "placeholder": "Shtoni një përshkrim më të detajuar..." + }, + "subTasks": { + "title": "Nën-Detyrat", + "add-sub-task": "+ Shto Nën-Detyrë", + "refresh-sub-tasks": "Rifresko Nën-Detyrat" + } +} diff --git a/worklenz-backend/src/public/locales/alb/task-drawer/task-drawer.json b/worklenz-backend/src/public/locales/alb/task-drawer/task-drawer.json new file mode 100644 index 00000000..9d6c022f --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/task-drawer/task-drawer.json @@ -0,0 +1,123 @@ +{ + "taskHeader": { + "taskNamePlaceholder": "Shkruani Detyrën tuaj", + "deleteTask": "Fshi Detyrën" + }, + "taskInfoTab": { + "title": "Informacioni", + "details": { + "title": "Detajet", + "task-key": "Çelësi i Detyrës", + "phase": "Faza", + "assignees": "Të Caktuar", + "due-date": "Data e Përfundimit", + "time-estimation": "Vlerësimi i Kohës", + "priority": "Prioriteti", + "labels": "Etiketat", + "billable": "E Faturueshme", + "notify": "Njofto", + "when-done-notify": "Kur përfundon, njofto", + "start-date": "Data e Fillimit", + "end-date": "Data e Përfundimit", + "hide-start-date": "Fshih Datën e Fillimit", + "show-start-date": "Shfaq Datën e Fillimit", + "hours": "Orë", + "minutes": "Minuta", + "progressValue": "Vlera e Progresit", + "progressValueTooltip": "Vendosni përqindjen e progresit (0-100%)", + "progressValueRequired": "Ju lutemi vendosni një vlerë progresi", + "progressValueRange": "Progresi duhet të jetë midis 0 dhe 100", + "taskWeight": "Pesha e Detyrës", + "taskWeightTooltip": "Vendosni peshën e kësaj nëndetyre (përqindje)", + "taskWeightRequired": "Ju lutemi vendosni një peshë detyre", + "taskWeightRange": "Pesha duhet të jetë midis 0 dhe 100", + "recurring": "E Përsëritur" + }, + "labels": { + "labelInputPlaceholder": "Kërko ose krijo", + "labelsSelectorInputTip": "Shtyp Enter për të krijuar" + }, + "description": { + "title": "Përshkrimi", + "placeholder": "Shto një përshkrim më të detajuar..." + }, + "subTasks": { + "title": "Nëndetyrat", + "addSubTask": "Shto Nëndetyrë", + "addSubTaskInputPlaceholder": "Shkruani detyrën tuaj dhe shtypni enter", + "refreshSubTasks": "Rifresko Nëndetyrat", + "edit": "Modifiko", + "delete": "Fshi", + "confirmDeleteSubTask": "Jeni i sigurt që doni të fshini këtë nëndetyrë?", + "deleteSubTask": "Fshi Nëndetyrën" + }, + "dependencies": { + "title": "Varësitë", + "addDependency": "+ Shto varësi të re", + "blockedBy": "Bllokuar nga", + "searchTask": "Shkruani për të kërkuar detyrë", + "noTasksFound": "Nuk u gjetën detyra", + "confirmDeleteDependency": "Jeni i sigurt që doni të fshini?" + }, + "attachments": { + "title": "Bashkëngjitjet", + "chooseOrDropFileToUpload": "Zgjidhni ose hidhni skedar për të ngarkuar", + "uploading": "Duke ngarkuar..." + }, + "comments": { + "title": "Komentet", + "addComment": "+ Shto koment të ri", + "noComments": "Ende pa komente. Bëhu i pari që komenton!", + "delete": "Fshi", + "confirmDeleteComment": "Jeni i sigurt që doni të fshini këtë koment?", + "addCommentPlaceholder": "Shto një koment...", + "cancel": "Anulo", + "commentButton": "Komento", + "attachFiles": "Bashkëngjit skedarë", + "addMoreFiles": "Shto më shumë skedarë", + "selectedFiles": "Skedarët e Zgjedhur (Deri në 25MB, Maksimumi {count})", + "maxFilesError": "Mund të ngarkoni maksimum {count} skedarë", + "processFilesError": "Dështoi përpunimi i skedarëve", + "addCommentError": "Ju lutemi shtoni një koment ose bashkëngjitni skedarë", + "createdBy": "Krijuar {{time}} nga {{user}}", + "updatedTime": "Përditësuar {{time}}" + }, + "searchInputPlaceholder": "Kërko sipas emrit", + "pendingInvitation": "Ftesë në Pritje" + }, + "taskTimeLogTab": { + "title": "Regjistri i Kohës", + "addTimeLog": "Shto regjistrim të ri kohe", + "totalLogged": "Totali i Regjistruar", + "exportToExcel": "Eksporto në Excel", + "noTimeLogsFound": "Nuk u gjetën regjistra kohe", + "timeLogForm": { + "date": "Data", + "startTime": "Koha e Fillimit", + "endTime": "Koha e Përfundimit", + "workDescription": "Përshkrimi i Punës", + "descriptionPlaceholder": "Shto një përshkrim", + "logTime": "Regjistro kohën", + "updateTime": "Përditëso kohën", + "cancel": "Anulo", + "selectDateError": "Ju lutemi zgjidhni një datë", + "selectStartTimeError": "Ju lutemi zgjidhni kohën e fillimit", + "selectEndTimeError": "Ju lutemi zgjidhni kohën e përfundimit", + "endTimeAfterStartError": "Koha e përfundimit duhet të jetë pas kohës së fillimit" + } + }, + "taskActivityLogTab": { + "title": "Regjistri i Aktivitetit", + "add": "SHTO", + "remove": "HIQE", + "none": "Asnjë", + "weight": "Pesha", + "createdTask": "krijoi detyrën." + }, + "taskProgress": { + "markAsDoneTitle": "Shëno Detyrën si të Kryer?", + "confirmMarkAsDone": "Po, shëno si të kryer", + "cancelMarkAsDone": "Jo, mbaj statusin aktual", + "markAsDoneDescription": "Keni vendosur progresin në 100%. Doni të përditësoni statusin e detyrës në \"Kryer\"?" + } +} diff --git a/worklenz-backend/src/public/locales/alb/task-list-filters.json b/worklenz-backend/src/public/locales/alb/task-list-filters.json new file mode 100644 index 00000000..c3156498 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/task-list-filters.json @@ -0,0 +1,85 @@ +{ + "searchButton": "Kërko", + "resetButton": "Rivendos", + "searchInputPlaceholder": "Kërko sipas emrit", + + "sortText": "Rendit", + "statusText": "Statusi", + "phaseText": "Faza", + "memberText": "Anëtarët", + "assigneesText": "Përgjegjësit", + "priorityText": "Prioriteti", + "labelsText": "Etiketa", + "membersText": "Anëtarët", + "groupByText": "Grupo sipas", + "showArchivedText": "Shfaq të arkivuara", + "showFieldsText": "Shfaq fushat", + "keyText": "Çelësi", + "taskText": "Detyra", + "descriptionText": "Përshkrimi", + "phasesText": "Fazat", + "listText": "Listë", + "progressText": "Progresi", + "timeTrackingText": "Gjurmimi i Kohës", + "timetrackingText": "Gjurmimi i Kohës", + "estimationText": "Vlerësimi", + "startDateText": "Data e Fillimit", + "startdateText": "Data e Fillimit", + "endDateText": "Data e Përfundimit", + "dueDateText": "Afati", + "duedateText": "Afati", + "completedDateText": "Data e Përfundimit", + "completeddateText": "Data e Përfundimit", + "createdDateText": "Data e Krijimit", + "createddateText": "Data e Krijimit", + "lastUpdatedText": "Përditësuar Së Fundi", + "lastupdatedText": "Përditësuar Së Fundi", + "reporterText": "Raportuesi", + "dueTimeText": "Koha e Afatit", + "duetimeText": "Koha e Afatit", + + "lowText": "I ulët", + "mediumText": "I mesëm", + "highText": "I lartë", + + "createStatusButtonTooltip": "Cilësimet e statusit", + "configPhaseButtonTooltip": "Cilësimet e fazës", + "noLabelsFound": "Nuk u gjetën etiketa", + + "addStatusButton": "Shto Status", + "addPhaseButton": "Shto Fazë", + + "createStatus": "Krijo Status", + "name": "Emri", + "category": "Kategoria", + "selectCategory": "Zgjidh një kategori", + "pleaseEnterAName": "Ju lutemi vendosni një emër", + "pleaseSelectACategory": "Ju lutemi zgjidhni një kategori", + "create": "Krijo", + + "searchTasks": "Kërko detyrat...", + "searchPlaceholder": "Kërko...", + "fieldsText": "Fushat", + "loadingFilters": "Duke ngarkuar filtrat...", + "noOptionsFound": "Nuk u gjetën opsione", + "filtersActive": "filtra aktiv", + "filterActive": "filtër aktiv", + "clearAll": "Pastro të gjitha", + "clearing": "Duke pastruar...", + "cancel": "Anulo", + "search": "Kërko", + "groupedBy": "Grupuar sipas", + "manageStatuses": "Menaxho Statuset", + "managePhases": "Menaxho Fazat", + "dragToReorderStatuses": "Zvarrit statuset për t'i rirenditur. Çdo status mund të ketë një kategori të ndryshme.", + "enterNewStatusName": "Shkruani emrin e statusit të ri...", + "addStatus": "Shto Status", + "noStatusesFound": "Nuk u gjetën statuse. Krijoni statusin tuaj të parë më sipër.", + "deleteStatus": "Fshi Statusin", + "deleteStatusConfirm": "Jeni të sigurt që doni të fshini këtë status? Ky veprim nuk mund të zhbëhet.", + "rename": "Riemëro", + "delete": "Fshi", + "enterStatusName": "Shkruani emrin e statusit", + "selectCategory": "Zgjidh kategorinë", + "close": "Mbyll" +} diff --git a/worklenz-backend/src/public/locales/alb/task-list-table.json b/worklenz-backend/src/public/locales/alb/task-list-table.json new file mode 100644 index 00000000..7e3f83dd --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/task-list-table.json @@ -0,0 +1,136 @@ +{ + "keyColumn": "Çelësi", + "taskColumn": "Detyra", + "descriptionColumn": "Përshkrimi", + "progressColumn": "Progresi", + "membersColumn": "Anëtarët", + "assigneesColumn": "Përgjegjësit", + "labelsColumn": "Etiketa", + "phasesColumn": "Fazat", + "phaseColumn": "Faza", + "statusColumn": "Statusi", + "priorityColumn": "Prioriteti", + "timeTrackingColumn": "Gjurmimi i Kohës", + "timetrackingColumn": "Gjurmimi i Kohës", + "estimationColumn": "Vlerësimi", + "startDateColumn": "Data e Fillimit", + "startdateColumn": "Data e Fillimit", + "dueDateColumn": "Data e Afatit", + "duedateColumn": "Data e Afatit", + "completedDateColumn": "Data e Përfundimit", + "completeddateColumn": "Data e Përfundimit", + "createdDateColumn": "Data e Krijimit", + "createddateColumn": "Data e Krijimit", + "lastUpdatedColumn": "Përditësuar Së Fundi", + "lastupdatedColumn": "Përditësuar Së Fundi", + "reporterColumn": "Raportuesi", + "dueTimeColumn": "Koha e Afatit", + "todoSelectorText": "Për të Bërë", + "doingSelectorText": "Duke bërë", + "doneSelectorText": "E Përfunduar", + + "lowSelectorText": "I ulët", + "mediumSelectorText": "I mesëm", + "highSelectorText": "I lartë", + + "selectText": "Zgjidh", + "labelsSelectorInputTip": "Shtyp Enter për të krijuar!", + + "addTaskText": "Shto Detyrë", + "addSubTaskText": "+ Shto Nën-Detyrë", + "noTasksInGroup": "Nuk ka detyra në këtë grup", + "addTaskInputPlaceholder": "Shkruaj detyrën dhe shtyp Enter", + + "openButton": "Hap", + "okButton": "Në rregull", + + "noLabelsFound": "Nuk u gjetën etiketa", + "searchInputPlaceholder": "Kërko ose krijo", + "assigneeSelectorInviteButton": "Fto një anëtar të ri me email", + "labelInputPlaceholder": "Kërko ose krijo", + "searchLabelsPlaceholder": "Kërko etiketa...", + "createLabelButton": "Krijo \"{{name}}\"", + "manageLabelsPath": "Cilësimet → Etiketat", + + "pendingInvitation": "Ftesë në Pritje", + + "contextMenu": { + "assignToMe": "Cakto mua", + "moveTo": "Zhvendos në", + "unarchive": "Ç'arkivizo", + "archive": "Arkivizo", + "convertToSubTask": "Shndërro në Nën-Detyrë", + "convertToTask": "Shndërro në Detyrë", + "delete": "Fshi", + "searchByNameInputPlaceholder": "Kërko sipas emrit" + }, + "setDueDate": "Cakto datën e afatit", + "setStartDate": "Cakto datën e fillimit", + "clearDueDate": "Pastro datën e afatit", + "clearStartDate": "Pastro datën e fillimit", + "dueDatePlaceholder": "Data e afatit", + "startDatePlaceholder": "Data e fillimit", + + "emptyStates": { + "noTaskGroups": "Nuk u gjetën grupe detyrash", + "noTaskGroupsDescription": "Detyrat do të shfaqen këtu kur krijohen ose kur aplikohen filtra.", + "errorPrefix": "Gabim:", + "dragTaskFallback": "Detyrë" + }, + + "customColumns": { + "addCustomColumn": "Shto një kolonë të personalizuar", + "customColumnHeader": "Kolona e Personalizuar", + "customColumnSettings": "Cilësimet e kolonës së personalizuar", + "noCustomValue": "Asnjë vlerë", + "peopleField": "Fusha e njerëzve", + "noDate": "Asnjë datë", + "unsupportedField": "Lloj fushe i pambështetur", + + "modal": { + "addFieldTitle": "Shto fushë", + "editFieldTitle": "Redakto fushën", + "fieldTitle": "Titulli i fushës", + "fieldTitleRequired": "Titulli i fushës është i kërkuar", + "columnTitlePlaceholder": "Titulli i kolonës", + "type": "Lloji", + "deleteConfirmTitle": "Jeni i sigurt që doni të fshini këtë kolonë të personalizuar?", + "deleteConfirmDescription": "Kjo veprim nuk mund të zhbëhet. Të gjitha të dhënat e lidhura me këtë kolonë do të fshihen përgjithmonë.", + "deleteButton": "Fshi", + "cancelButton": "Anulo", + "createButton": "Krijo", + "updateButton": "Përditëso", + "createSuccessMessage": "Kolona e personalizuar u krijua me sukses", + "updateSuccessMessage": "Kolona e personalizuar u përditësua me sukses", + "deleteSuccessMessage": "Kolona e personalizuar u fshi me sukses", + "deleteErrorMessage": "Dështoi në fshirjen e kolonës së personalizuar", + "createErrorMessage": "Dështoi në krijimin e kolonës së personalizuar", + "updateErrorMessage": "Dështoi në përditësimin e kolonës së personalizuar" + }, + + "fieldTypes": { + "people": "Njerëz", + "number": "Numër", + "date": "Data", + "selection": "Zgjedhje", + "checkbox": "Kutia e kontrollit", + "labels": "Etiketat", + "key": "Çelësi", + "formula": "Formula" + } + }, + + "indicators": { + "tooltips": { + "subtasks": "{{count}} nën-detyrë", + "subtasks_plural": "{{count}} nën-detyra", + "comments": "{{count}} koment", + "comments_plural": "{{count}} komente", + "attachments": "{{count}} bashkëngjitje", + "attachments_plural": "{{count}} bashkëngjitje", + "subscribers": "Detyra ka pajtues", + "dependencies": "Detyra ka varësi", + "recurring": "Detyrë përsëritëse" + } + } +} diff --git a/worklenz-backend/src/public/locales/alb/task-management.json b/worklenz-backend/src/public/locales/alb/task-management.json new file mode 100644 index 00000000..a156ef3f --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/task-management.json @@ -0,0 +1,21 @@ +{ + "noTasksInGroup": "Nuk ka detyra në këtë grup", + "noTasksInGroupDescription": "Shtoni një detyrë për të filluar", + "addFirstTask": "Shtoni detyrën tuaj të parë", + "openTask": "Hap", + "subtask": "nën-detyrë", + "subtasks": "nën-detyra", + "comment": "koment", + "comments": "komente", + "attachment": "bashkëngjitje", + "attachments": "bashkëngjitje", + "enterSubtaskName": "Shkruani emrin e nën-detyrës...", + "add": "Shto", + "cancel": "Anulo", + "renameGroup": "Riemërto Grupin", + "renameStatus": "Riemërto Statusin", + "renamePhase": "Riemërto Fazën", + "changeCategory": "Ndrysho Kategorinë", + "clickToEditGroupName": "Kliko për të ndryshuar emrin e grupit", + "enterGroupName": "Shkruani emrin e grupit" +} diff --git a/worklenz-backend/src/public/locales/alb/task-template-drawer.json b/worklenz-backend/src/public/locales/alb/task-template-drawer.json new file mode 100644 index 00000000..034ac916 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/task-template-drawer.json @@ -0,0 +1,11 @@ +{ + "createTaskTemplate": "Krijo Shabllon Detyre", + "editTaskTemplate": "Modifiko Shabllon Detyre", + "cancelText": "Anulo", + "saveText": "Ruaj", + "templateNameText": "Emri i Shabllonit", + "selectedTasks": "Detyrat e Përzgjedhura", + "removeTask": "Hiq", + "cancelButton": "Anulo", + "saveButton": "Ruaj" +} diff --git a/worklenz-backend/src/public/locales/alb/tasks/task-table-bulk-actions.json b/worklenz-backend/src/public/locales/alb/tasks/task-table-bulk-actions.json new file mode 100644 index 00000000..45980b24 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/tasks/task-table-bulk-actions.json @@ -0,0 +1,26 @@ +{ + "taskSelected": "detyrë e zgjedhur", + "tasksSelected": "detyra të zgjedhura", + "changeStatus": "Ndrysho Statusin/ Prioritetin/ Fazat", + "changeLabel": "Ndrysho Etiketën", + "assignToMe": "Cakto mua", + "changeAssignees": "Ndrysho Përgjegjësit", + "archive": "Arkivo", + "unarchive": "Ç'arkivo", + "delete": "Fshi", + "moreOptions": "Më shumë opsione", + "deselectAll": "Zgjidhja të gjitha", + "status": "Statusi", + "priority": "Prioriteti", + "phase": "Faza", + "member": "Anëtar", + "createTaskTemplate": "Krijo Shabllon Detyre", + "apply": "Apliko", + "createLabel": "+ Krijo Etiketë", + "searchOrCreateLabel": "Kërko ose krijo etiketë...", + "hitEnterToCreate": "Shtyp Enter për të krijuar", + "labelExists": "Etiketa ekziston tashmë", + "pendingInvitation": "Ftesë në Pritje", + "noMatchingLabels": "Asnjë etiketë që përputhet", + "noLabels": "Asnjë etiketë" +} diff --git a/worklenz-backend/src/public/locales/alb/template-drawer.json b/worklenz-backend/src/public/locales/alb/template-drawer.json new file mode 100644 index 00000000..e6174c10 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/template-drawer.json @@ -0,0 +1,19 @@ +{ + "title": "Modifiko Shabllon Detyre", + "cancelText": "Anulo", + "saveText": "Ruaj", + "templateNameText": "Emri i Shabllonit", + "selectedTasks": "Detyrat e Përzgjedhura", + "removeTask": "Hiq", + "description": "Përshkrimi", + "phase": "Faza", + "statuses": "Statuset", + "priorities": "Prioritetet", + "labels": "Etiketa", + "tasks": "Detyrat", + "noTemplateSelected": "Asnjë shabllon i përzgjedhur", + "noDescription": "Pa përshkrim", + "worklenzTemplates": "Shabllonet Worklenz", + "yourTemplatesLibrary": "Biblioteka Juaj", + "searchTemplates": "Kërko Shabllone" +} diff --git a/worklenz-backend/src/public/locales/alb/templateDrawer.json b/worklenz-backend/src/public/locales/alb/templateDrawer.json new file mode 100644 index 00000000..6b2cd828 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/templateDrawer.json @@ -0,0 +1,23 @@ +{ + "bugTracking": "Gjurmimi i Gabimeve", + "construction": "Ndërtim", + "designCreative": "Dizajn & Kreativ", + "education": "Arsim", + "finance": "Financë", + "hrRecruiting": "Burime Njerëzore & Rekrutim", + "informationTechnology": "Teknologji Informacioni", + "legal": "Juridik", + "manufacturing": "Prodhim", + "marketing": "Marketing", + "nonprofit": "Jo-fitimprurës", + "personalUse": "Përdorim Personal", + "salesCRM": "Shitje & CRM", + "serviceConsulting": "Shërbime & Këshillim", + "softwareDevelopment": "Zhvillim Softueri", + "description": "Përshkrimi", + "phase": "Faza", + "statuses": "Statuset", + "priorities": "Prioritetet", + "labels": "Etiketa", + "tasks": "Detyrat" +} diff --git a/worklenz-backend/src/public/locales/alb/time-report.json b/worklenz-backend/src/public/locales/alb/time-report.json new file mode 100644 index 00000000..8a0bb69b --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/time-report.json @@ -0,0 +1,44 @@ +{ + "includeArchivedProjects": "Përfshij Projektet e Arkivuara", + "export": "Eksporto", + "timeSheet": "Fletë Kohore", + + "searchByName": "Kërko sipas emrit", + "selectAll": "Zgjidh të Gjitha", + "teams": "Ekipet", + + "searchByProject": "Kërko sipas emrit të projektit", + "projects": "Projektet", + + "searchByCategory": "Kërko sipas emrit të kategorisë", + "categories": "Kategoritë", + + "billable": "Fakturueshme", + "nonBillable": "Jo Fakturueshme", + + "total": "Total", + + "projectsTimeSheet": "Fletë Kohore e Projekteve", + + "loggedTime": "Koha e Regjistruar(orë)", + + "exportToExcel": "Eksporto në Excel", + "logged": "regjistruar", + "for": "për", + + "membersTimeSheet": "Fletë Kohore e Anëtarëve", + "member": "Anëtar", + + "estimatedVsActual": "Vlerësuar vs Aktual", + "workingDays": "Ditë Pune", + "manDays": "Ditë Njeri", + "days": "Ditë", + "estimatedDays": "Ditë të Vlerësuara", + "actualDays": "Ditë Aktuale", + + "noCategories": "Nuk u gjetën kategori", + "noCategory": "Pa Kategori", + "noProjects": "Nuk u gjetën projekte", + "noTeams": "Nuk u gjetën ekipe", + "noData": "Nuk u gjetën të dhëna" +} diff --git a/worklenz-backend/src/public/locales/alb/unauthorized.json b/worklenz-backend/src/public/locales/alb/unauthorized.json new file mode 100644 index 00000000..a731e676 --- /dev/null +++ b/worklenz-backend/src/public/locales/alb/unauthorized.json @@ -0,0 +1,5 @@ +{ + "title": "E paautorizuar!", + "subtitle": "Nuk jeni të autorizuar të hyni në këtë faqe", + "button": "Kthehu në Faqen Kryesore" +} diff --git a/worklenz-backend/src/public/locales/de/404-page.json b/worklenz-backend/src/public/locales/de/404-page.json new file mode 100644 index 00000000..641a1847 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/404-page.json @@ -0,0 +1,4 @@ +{ + "doesNotExistText": "Entschuldigung, die von Ihnen besuchte Seite existiert nicht.", + "backHomeButton": "Zurück zur Startseite" +} diff --git a/worklenz-backend/src/public/locales/de/account-setup.json b/worklenz-backend/src/public/locales/de/account-setup.json new file mode 100644 index 00000000..ddfb7b80 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/account-setup.json @@ -0,0 +1,31 @@ +{ + "continue": "Weiter", + + "setupYourAccount": "Richten Sie Ihr Worklenz-Konto ein.", + "organizationStepTitle": "Organisation benennen", + "organizationStepLabel": "Wählen Sie einen Namen für Ihr Worklenz-Konto.", + + "projectStepTitle": "Erstellen Sie Ihr erstes Projekt", + "projectStepLabel": "An welchem Projekt arbeiten Sie gerade?", + "projectStepPlaceholder": "z.B. Marketingplan", + + "tasksStepTitle": "Erstellen Sie Ihre ersten Aufgaben", + "tasksStepLabel": "Geben Sie einige Aufgaben ein, die Sie in", + "tasksStepAddAnother": "Weitere hinzufügen", + + "emailPlaceholder": "E-Mail-Adresse", + "invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein", + "or": "oder", + "templateButton": "Aus Vorlage importieren", + "goBack": "Zurück", + "cancel": "Abbrechen", + "create": "Erstellen", + "templateDrawerTitle": "Aus Vorlagen auswählen", + "step3InputLabel": "Per E-Mail einladen", + "addAnother": "Weitere hinzufügen", + "skipForNow": "Jetzt überspringen", + "formTitle": "Erstellen Sie Ihre erste Aufgabe.", + "step3Title": "Laden Sie Ihr Team zur Zusammenarbeit ein", + "maxMembers": " (Sie können bis zu 5 Mitglieder einladen)", + "maxTasks": " (Sie können bis zu 5 Aufgaben erstellen)" +} diff --git a/worklenz-backend/src/public/locales/de/admin-center/current-bill.json b/worklenz-backend/src/public/locales/de/admin-center/current-bill.json new file mode 100644 index 00000000..fcf2c636 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/admin-center/current-bill.json @@ -0,0 +1,113 @@ +{ + "title": "Abrechnungen", + "currentBill": "Aktuelle Rechnung", + "configuration": "Konfiguration", + "currentPlanDetails": "Aktuelle Plan Details", + "upgradePlan": "Plan upgraden", + "cardBodyText01": "Kostenlose Testversion", + "cardBodyText02": "(Ihr Testplan läuft in 1 Monat 19 Tagen ab)", + "redeemCode": "Gutscheincode einlösen", + "accountStorage": "Kontospeicher", + "used": "Verwendet:", + "remaining": "Verbleibend:", + "charges": "Gebühren", + "tooltip": "Gebühren für den aktuellen Abrechnungszeitraum", + "description": "Beschreibung", + "billingPeriod": "Abrechnungszeitraum", + "billStatus": "Rechnungsstatus", + "perUserValue": "Pro Benutzer Wert", + "users": "Benutzer", + + "amount": "Betrag", + "invoices": "Rechnungen", + "transactionId": "Transaktions-ID", + "transactionDate": "Transaktionsdatum", + "paymentMethod": "Zahlungsmethode", + "status": "Status", + "ltdUsers": "Sie können bis zu {{ltd_users}} Benutzer hinzufügen.", + + "totalSeats": "Gesamte Plätze", + "availableSeats": "Verfügbare Plätze", + "addMoreSeats": "Weitere Plätze hinzufügen", + + "drawerTitle": "Gutscheincode einlösen", + "label": "Gutscheincode", + "drawerPlaceholder": "Geben Sie Ihren Gutscheincode ein", + "redeemSubmit": "Einreichen", + + "modalTitle": "Wählen Sie den besten Plan für Ihr Team", + "seatLabel": "Anzahl der Plätze", + "freePlan": "Kostenloser Plan", + "startup": "Startup", + "business": "Business", + "tag": "Am beliebtesten", + "enterprise": "Enterprise", + + "freeSubtitle": "kostenlos für immer", + "freeUsers": "Ideal für die persönliche Nutzung", + "freeText01": "100MB Speicher", + "freeText02": "3 Projekte", + "freeText03": "5 Teammitglieder", + + "startupSubtitle": "PAUSCHALPREIS / Monat", + "startupUsers": "Bis zu 15 Benutzer", + "startupText01": "25GB Speicher", + "startupText02": "Unbegrenzte aktive Projekte", + "startupText03": "Zeitplan", + "startupText04": "Berichterstattung", + "startupText05": "Projekte abonnieren", + + "businessSubtitle": "Benutzer / Monat", + "businessUsers": "16 - 200 Benutzer", + + "enterpriseUsers": "200 - 500+ Benutzer", + + "footerTitle": "Bitte geben Sie uns eine Kontaktnummer, unter der wir Sie erreichen können.", + "footerLabel": "Kontaktnummer", + "footerButton": "Kontaktieren Sie uns", + + "redeemCodePlaceHolder": "Geben Sie Ihren Gutscheincode ein", + "submit": "Einreichen", + + "trialPlan": "Kostenlose Testversion", + "trialExpireDate": "Gültig bis {{trial_expire_date}}", + "trialExpired": "Ihre kostenlose Testversion ist {{trial_expire_string}} abgelaufen", + "trialInProgress": "Ihre kostenlose Testversion läuft {{trial_expire_string}} ab", + + "required": "Dieses Feld ist erforderlich", + "invalidCode": "Ungültiger Code", + + "selectPlan": "Wählen Sie den besten Plan für Ihr Team", + "changeSubscriptionPlan": "Ändern Sie Ihren Abonnementplan", + "noOfSeats": "Anzahl der Plätze", + "annualPlan": "Pro - Jährlich", + "monthlyPlan": "Pro - Monatlich", + "freeForever": "Kostenlos für immer", + "bestForPersonalUse": "Ideal für die persönliche Nutzung", + "storage": "Speicher", + "projects": "Projekte", + "teamMembers": "Teammitglieder", + "unlimitedTeamMembers": "Unbegrenzte Teammitglieder", + "unlimitedActiveProjects": "Unbegrenzte aktive Projekte", + "schedule": "Zeitplan", + "reporting": "Berichterstattung", + "subscribeToProjects": "Projekte abonnieren", + "billedAnnually": "Jährlich abgerechnet", + "billedMonthly": "Monatlich abgerechnet", + + "pausePlan": "Plan pausieren", + "resumePlan": "Plan fortsetzen", + "changePlan": "Plan ändern", + "cancelPlan": "Plan kündigen", + + "perMonthPerUser": "pro Benutzer/Monat", + "viewInvoice": "Rechnung anzeigen", + "switchToFreePlan": "Wechsel zum kostenlosen Plan", + + "expirestoday": "heute", + "expirestomorrow": "morgen", + "expiredDaysAgo": "vor {{days}} Tagen", + + "continueWith": "Fortfahren mit {{plan}}", + "changeToPlan": "Wechseln zu {{plan}}" +} diff --git a/worklenz-backend/src/public/locales/de/admin-center/overview.json b/worklenz-backend/src/public/locales/de/admin-center/overview.json new file mode 100644 index 00000000..0330d788 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/admin-center/overview.json @@ -0,0 +1,8 @@ +{ + "overview": "Übersicht", + "name": "Organisationsname", + "owner": "Organisationsinhaber", + "admins": "Organisationsadministratoren", + "contactNumber": "Kontaktnummer hinzufügen", + "edit": "Bearbeiten" +} diff --git a/worklenz-backend/src/public/locales/de/admin-center/projects.json b/worklenz-backend/src/public/locales/de/admin-center/projects.json new file mode 100644 index 00000000..2d4f3534 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/admin-center/projects.json @@ -0,0 +1,12 @@ +{ + "membersCount": "Mitgliederanzahl", + "createdAt": "Erstellt am", + "projectName": "Projektname", + "teamName": "Teamname", + "refreshProjects": "Projekte aktualisieren", + "searchPlaceholder": "Nach Projektname suchen", + "deleteProject": "Sind Sie sicher, dass Sie dieses Projekt löschen möchten?", + "confirm": "Bestätigen", + "cancel": "Abbrechen", + "delete": "Projekt löschen" +} diff --git a/worklenz-backend/src/public/locales/de/admin-center/sidebar.json b/worklenz-backend/src/public/locales/de/admin-center/sidebar.json new file mode 100644 index 00000000..670595a3 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/admin-center/sidebar.json @@ -0,0 +1,8 @@ +{ + "overview": "Übersicht", + "users": "Benutzer", + "teams": "Teams", + "billing": "Abrechnung", + "projects": "Projekte", + "adminCenter": "Admin-Center" +} diff --git a/worklenz-backend/src/public/locales/de/admin-center/teams.json b/worklenz-backend/src/public/locales/de/admin-center/teams.json new file mode 100644 index 00000000..7ab2831f --- /dev/null +++ b/worklenz-backend/src/public/locales/de/admin-center/teams.json @@ -0,0 +1,33 @@ +{ + "title": "Teams", + "subtitle": "Teams", + "tooltip": "Teams aktualisieren", + "placeholder": "Nach Namen suchen", + "addTeam": "Team hinzufügen", + "team": "Team", + "membersCount": "Mitgliederanzahl", + "members": "Mitglieder", + "drawerTitle": "Neues Team erstellen", + "label": "Teamname", + "drawerPlaceholder": "Name", + "create": "Erstellen", + "delete": "Löschen", + "settings": "Einstellungen", + "popTitle": "Sind Sie sicher?", + "message": "Bitte geben Sie einen Namen ein", + "teamSettings": "Team-Einstellungen", + "teamName": "Teamname", + "teamDescription": "Teambeschreibung", + "teamMembers": "Teammitglieder", + "teamMembersCount": "Anzahl der Teammitglieder", + "teamMembersPlaceholder": "Nach Namen suchen", + "addMember": "Mitglied hinzufügen", + "add": "Hinzufügen", + "update": "Aktualisieren", + "teamNamePlaceholder": "Name des Teams", + "user": "Benutzer", + "role": "Rolle", + "owner": "Besitzer", + "admin": "Administrator", + "member": "Mitglied" +} diff --git a/worklenz-backend/src/public/locales/de/admin-center/users.json b/worklenz-backend/src/public/locales/de/admin-center/users.json new file mode 100644 index 00000000..47de9a59 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/admin-center/users.json @@ -0,0 +1,9 @@ +{ + "title": "Benutzer", + "subTitle": "Benutzer", + "placeholder": "Nach Namen suchen", + "user": "Benutzer", + "email": "E-Mail", + "lastActivity": "Letzte Aktivität", + "refresh": "Benutzer aktualisieren" +} diff --git a/worklenz-backend/src/public/locales/de/all-project-list.json b/worklenz-backend/src/public/locales/de/all-project-list.json new file mode 100644 index 00000000..89a9803d --- /dev/null +++ b/worklenz-backend/src/public/locales/de/all-project-list.json @@ -0,0 +1,34 @@ +{ + "name": "Name", + "client": "Kunde", + "category": "Kategorie", + "status": "Status", + "tasksProgress": "Aufgabenfortschritt", + "updated_at": "Zuletzt aktualisiert", + "members": "Mitglieder", + "setting": "Einstellungen", + "projects": "Projekte", + "refreshProjects": "Projekte aktualisieren", + "all": "Alle", + "favorites": "Favoriten", + "archived": "Archiviert", + "placeholder": "Nach Namen suchen", + "archive": "Archivieren", + "unarchive": "Dearchivieren", + "archiveConfirm": "Sind Sie sicher, dass Sie dieses Projekt archivieren möchten?", + "unarchiveConfirm": "Sind Sie sicher, dass Sie dieses Projekt dearchivieren möchten?", + "yes": "Ja", + "no": "Nein", + "clickToFilter": "Zum Filtern klicken nach", + "noProjects": "Keine Projekte gefunden", + "addToFavourites": "Zu Favoriten hinzufügen", + "list": "Liste", + "group": "Gruppe", + "listView": "Listenansicht", + "groupView": "Gruppenansicht", + "groupBy": { + "category": "Kategorie", + "client": "Kunde" + }, + "noPermission": "Sie haben keine Berechtigung, diese Aktion durchzuführen" +} diff --git a/worklenz-backend/src/public/locales/de/auth/auth-common.json b/worklenz-backend/src/public/locales/de/auth/auth-common.json new file mode 100644 index 00000000..dab26d10 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/auth/auth-common.json @@ -0,0 +1,5 @@ +{ + "loggingOut": "Abmelden...", + "authenticating": "Authentifizierung läuft...", + "gettingThingsReady": "Bereite alles für Sie vor..." +} diff --git a/worklenz-backend/src/public/locales/de/auth/forgot-password.json b/worklenz-backend/src/public/locales/de/auth/forgot-password.json new file mode 100644 index 00000000..a94c7463 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/auth/forgot-password.json @@ -0,0 +1,12 @@ +{ + "headerDescription": "Passwort zurücksetzen", + "emailLabel": "E-Mail", + "emailPlaceholder": "Ihre E-Mail eingeben", + "emailRequired": "Bitte geben Sie Ihre E-Mail-Adresse ein!", + "resetPasswordButton": "Passwort zurücksetzen", + "returnToLoginButton": "Zurück zum Login", + "passwordResetSuccessMessage": "Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail gesendet.", + "orText": "ODER", + "successTitle": "Anweisung zum Zurücksetzen gesendet!", + "successMessage": "Die Informationen zum Zurücksetzen wurden an Ihre E-Mail gesendet. Bitte überprüfen Sie Ihr E-Mail-Postfach." +} diff --git a/worklenz-backend/src/public/locales/de/auth/login.json b/worklenz-backend/src/public/locales/de/auth/login.json new file mode 100644 index 00000000..f42d0db9 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/auth/login.json @@ -0,0 +1,27 @@ +{ + "headerDescription": "Melden Sie sich an", + "emailLabel": "E-Mail", + "emailPlaceholder": "Ihre E-Mail-Adresse eingeben", + "emailRequired": "Bitte geben Sie Ihre E-Mail-Adresse ein!", + "passwordLabel": "Passwort", + "passwordPlaceholder": "Ihr Passwort eingeben", + "passwordRequired": "Bitte geben Sie Ihr Passwort ein!", + "rememberMe": "Erinnere dich an mich", + "loginButton": "Anmelden", + "signupButton": "Registrieren", + "forgotPasswordButton": "Passwort vergessen?", + "signInWithGoogleButton": "Mit Google anmelden", + "dontHaveAccountText": "Noch kein Konto?", + "orText": "ODER", + "successMessage": "Sie haben sich erfolgreich angemeldet!", + "loginError": "Anmeldung fehlgeschlagen", + "googleLoginError": "Google-Anmeldung fehlgeschlagen", + "validationMessages": { + "email": "Bitte geben Sie eine gültige E-Mail-Adresse ein", + "password": "Das Passwort muss mindestens 8 Zeichen lang sein" + }, + "errorMessages": { + "loginErrorTitle": "Anmeldung fehlgeschlagen", + "loginErrorMessage": "Bitte überprüfen Sie Ihre E-Mail-Adresse und Ihr Passwort und versuchen Sie es erneut" + } +} diff --git a/worklenz-backend/src/public/locales/de/auth/signup.json b/worklenz-backend/src/public/locales/de/auth/signup.json new file mode 100644 index 00000000..55a63a23 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/auth/signup.json @@ -0,0 +1,29 @@ +{ + "headerDescription": "Registrieren Sie sich, um loszulegen", + "nameLabel": "Vollständiger Name", + "namePlaceholder": "Ihren vollständigen Namen eingeben", + "nameRequired": "Bitte geben Sie Ihren vollständigen Namen ein!", + "nameMinCharacterRequired": "Der Name muss mindestens 4 Zeichen lang sein!", + "emailLabel": "E-Mail", + "emailPlaceholder": "Ihre E-Mail-Adresse eingeben", + "emailRequired": "Bitte geben Sie Ihre E-Mail-Adresse ein!", + "passwordLabel": "Passwort", + "passwordPlaceholder": "Ihr Passwort eingeben", + "passwordRequired": "Bitte geben Sie Ihr Passwort ein!", + "passwordMinCharacterRequired": "Das Passwort muss mindestens 8 Zeichen lang sein!", + "passwordPatternRequired": "Das Passwort erfüllt nicht die Anforderungen!", + "strongPasswordPlaceholder": "Ein stärkeres Passwort eingeben", + "passwordValidationAltText": "Das Passwort muss mindestens 8 Zeichen enthalten, mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.", + "signupSuccessMessage": "Sie haben sich erfolgreich registriert!", + "privacyPolicyLink": "Datenschutzrichtlinie", + "termsOfUseLink": "Nutzungsbedingungen", + "bySigningUpText": "Mit der Registrierung stimmen Sie unseren", + "andText": "und", + "signupButton": "Registrieren", + "signInWithGoogleButton": "Mit Google anmelden", + "alreadyHaveAccountText": "Sie haben bereits ein Konto?", + "loginButton": "Anmelden", + "orText": "ODER", + "reCAPTCHAVerificationError": "reCAPTCHA-Verifizierungsfehler", + "reCAPTCHAVerificationErrorMessage": "Wir konnten Ihre reCAPTCHA nicht verifizieren. Bitte versuchen Sie es erneut." +} diff --git a/worklenz-backend/src/public/locales/de/auth/verify-reset-email.json b/worklenz-backend/src/public/locales/de/auth/verify-reset-email.json new file mode 100644 index 00000000..323c685f --- /dev/null +++ b/worklenz-backend/src/public/locales/de/auth/verify-reset-email.json @@ -0,0 +1,14 @@ +{ + "title": "E-Mail zurücksetzen bestätigen", + "description": "Geben Sie Ihr neues Passwort ein", + "placeholder": "Neues Passwort eingeben", + "confirmPasswordPlaceholder": "Neues Passwort bestätigen", + "passwordHint": "Mindestens 8 Zeichen, mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.", + "resetPasswordButton": "Passwort zurücksetzen", + "orText": "Oder", + "resendResetEmail": "Zurücksetz-E-Mail erneut senden", + "passwordRequired": "Bitte geben Sie Ihr neues Passwort ein", + "returnToLoginButton": "Zurück zur Anmeldung", + "confirmPasswordRequired": "Bitte bestätigen Sie Ihr neues Passwort", + "passwordMismatch": "Die beiden Passwörter stimmen nicht überein" +} diff --git a/worklenz-backend/src/public/locales/de/common.json b/worklenz-backend/src/public/locales/de/common.json new file mode 100644 index 00000000..937ad4a9 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/common.json @@ -0,0 +1,9 @@ +{ + "login-success": "Anmeldung erfolgreich!", + "login-failed": "Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten und versuchen Sie es erneut.", + "signup-success": "Registrierung erfolgreich! Willkommen an Bord.", + "signup-failed": "Registrierung fehlgeschlagen. Bitte füllen Sie alle erforderlichen Felder aus und versuchen Sie es erneut.", + "reconnecting": "Vom Server getrennt.", + "connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.", + "connection-restored": "Erfolgreich mit dem Server verbunden" +} diff --git a/worklenz-backend/src/public/locales/de/create-first-project-form.json b/worklenz-backend/src/public/locales/de/create-first-project-form.json new file mode 100644 index 00000000..02ce495e --- /dev/null +++ b/worklenz-backend/src/public/locales/de/create-first-project-form.json @@ -0,0 +1,13 @@ +{ + "formTitle": "Erstellen Sie Ihr erstes Projekt", + "inputLabel": "An welchem Projekt arbeiten Sie gerade?", + "or": "oder", + "templateButton": "Aus Vorlage importieren", + "createFromTemplate": "Aus Vorlage erstellen", + "goBack": "Zurück", + "continue": "Weitermachen", + "cancel": "Abbrechen", + "create": "Erstellen", + "templateDrawerTitle": "Aus Vorlagen auswählen", + "createProject": "Projekt erstellen" +} diff --git a/worklenz-backend/src/public/locales/de/create-first-tasks.json b/worklenz-backend/src/public/locales/de/create-first-tasks.json new file mode 100644 index 00000000..ae7a4256 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/create-first-tasks.json @@ -0,0 +1,7 @@ +{ + "formTitle": "Erstellen Sie Ihre erste Aufgabe.", + "inputLabel": "Geben Sie einige Aufgaben ein, die Sie erledigen werden in", + "addAnother": "Einen weiteren hinzufügen", + "goBack": "Zurück", + "continue": "Weiter" +} diff --git a/worklenz-backend/src/public/locales/de/home.json b/worklenz-backend/src/public/locales/de/home.json new file mode 100644 index 00000000..cc868952 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/home.json @@ -0,0 +1,46 @@ +{ + "todoList": { + "title": "Aufgabenliste", + "refreshTasks": "Aufgaben aktualisieren", + "addTask": "+ Aufgabe hinzufügen", + "noTasks": "Keine Aufgaben", + "pressEnter": "Drücken Sie", + "toCreate": "zum Erstellen.", + "markAsDone": "Als erledigt markieren" + }, + "projects": { + "title": "Projekte", + "refreshProjects": "Projekte aktualisieren", + "noRecentProjects": "Sie sind aktuell keinem Projekt zugewiesen.", + "noFavouriteProjects": "Keine Projekte als Favoriten markiert.", + "recent": "Kürzlich", + "favourites": "Favoriten" + }, + "tasks": { + "assignedToMe": "Mir zugewiesen", + "assignedByMe": "Von mir zugewiesen", + "all": "Alle", + "today": "Heute", + "upcoming": "Bevorstehend", + "overdue": "Überfällig", + "noDueDate": "Kein Fälligkeitsdatum", + "noTasks": "Keine Aufgaben zum Anzeigen.", + "addTask": "+ Aufgabe hinzufügen", + "name": "Name", + "project": "Projekt", + "status": "Status", + "dueDate": "Fälligkeitsdatum", + "dueDatePlaceholder": "Fälligkeitsdatum festlegen", + "tomorrow": "Morgen", + "nextWeek": "Nächste Woche", + "nextMonth": "Nächster Monat", + "projectRequired": "Bitte wählen Sie ein Projekt aus", + "pressTabToSelectDueDateAndProject": "Drücken Sie Tab, um ein Fälligkeitsdatum und ein Projekt auszuwählen", + "dueOn": "Fällige Aufgaben am", + "taskRequired": "Bitte fügen Sie eine Aufgabe hinzu", + "list": "Liste", + "calendar": "Kalender", + "tasks": "Aufgaben", + "refresh": "Aktualisieren" + } +} diff --git a/worklenz-backend/src/public/locales/de/invite-initial-team-members.json b/worklenz-backend/src/public/locales/de/invite-initial-team-members.json new file mode 100644 index 00000000..e3ba64ae --- /dev/null +++ b/worklenz-backend/src/public/locales/de/invite-initial-team-members.json @@ -0,0 +1,8 @@ +{ + "formTitle": "Laden Sie Ihr Team zur Zusammenarbeit ein", + "inputLabel": "Per E-Mail einladen", + "addAnother": "Weitere hinzufügen", + "goBack": "Zurück", + "continue": "Weitermachen", + "skipForNow": "Vorerst überspringen" +} diff --git a/worklenz-backend/src/public/locales/de/kanban-board.json b/worklenz-backend/src/public/locales/de/kanban-board.json new file mode 100644 index 00000000..70e1f6ca --- /dev/null +++ b/worklenz-backend/src/public/locales/de/kanban-board.json @@ -0,0 +1,30 @@ +{ + "rename": "Umbenennen", + "delete": "Löschen", + "addTask": "Aufgabe hinzufügen", + "addSectionButton": "Abschnitt hinzufügen", + "changeCategory": "Kategorie ändern", + + "deleteTooltip": "Löschen", + "deleteConfirmationTitle": "Sind Sie sicher?", + "deleteConfirmationOk": "Ja", + "deleteConfirmationCancel": "Abbrechen", + + "dueDate": "Fälligkeitsdatum", + "cancel": "Abbrechen", + + "today": "Heute", + "tomorrow": "Morgen", + "assignToMe": "Mir zuweisen", + "archive": "Archivieren", + + "newTaskNamePlaceholder": "Aufgabenname eingeben", + "newSubtaskNamePlaceholder": "Unteraufgabenname eingeben", + "untitledSection": "Unbenannter Abschnitt", + "unmapped": "Nicht zugeordnet", + "clickToChangeDate": "Klicken Sie, um das Datum zu ändern", + "noDueDate": "Kein Fälligkeitsdatum", + "save": "Speichern", + "clear": "Löschen", + "nextWeek": "Nächste Woche" +} diff --git a/worklenz-backend/src/public/locales/de/license-expired.json b/worklenz-backend/src/public/locales/de/license-expired.json new file mode 100644 index 00000000..437ddeb2 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/license-expired.json @@ -0,0 +1,6 @@ +{ + "title": "Ihre Worklenz-Testversion ist abgelaufen!", + "subtitle": "Bitte führen Sie jetzt ein Upgrade durch.", + "button": "Jetzt upgraden", + "checking": "Überprüfen des Abonnementstatus..." +} diff --git a/worklenz-backend/src/public/locales/de/navbar.json b/worklenz-backend/src/public/locales/de/navbar.json new file mode 100644 index 00000000..c84912e4 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/navbar.json @@ -0,0 +1,31 @@ +{ + "logoAlt": "Worklenz-Logo", + "home": "Startseite", + "projects": "Projekte", + "schedule": "Zeitplan", + "reporting": "Berichterstattung", + "clients": "Kunden", + "teams": "Teams", + "labels": "Labels", + "jobTitles": "Jobtitel", + "upgradePlan": "Plan upgraden", + "upgradePlanTooltip": "Plan upgraden", + "invite": "Einladen", + "inviteTooltip": "Teammitglieder zur Teilnahme einladen", + "switchTeamTooltip": "Team wechseln", + "help": "Hilfe", + "notificationTooltip": "Benachrichtigungen anzeigen", + "profileTooltip": "Profil anzeigen", + "adminCenter": "Admin-Center", + "settings": "Einstellungen", + "logOut": "Abmelden", + "notificationsDrawer": { + "read": "Gelesene Benachrichtigungen", + "unread": "Ungelesene Benachrichtigungen", + "markAsRead": "Als gelesen markieren", + "readAndJoin": "Lesen & Beitreten", + "accept": "Annehmen", + "acceptAndJoin": "Annehmen & Beitreten", + "noNotifications": "Keine Benachrichtigungen" + } +} diff --git a/worklenz-backend/src/public/locales/de/organization-name-form.json b/worklenz-backend/src/public/locales/de/organization-name-form.json new file mode 100644 index 00000000..06d3efcf --- /dev/null +++ b/worklenz-backend/src/public/locales/de/organization-name-form.json @@ -0,0 +1,5 @@ +{ + "nameYourOrganization": "Benennen Sie Ihre Organisation.", + "worklenzAccountTitle": "Wählen Sie einen Namen für Ihr Worklenz-Konto.", + "continue": "Weiter" +} diff --git a/worklenz-backend/src/public/locales/de/phases-drawer.json b/worklenz-backend/src/public/locales/de/phases-drawer.json new file mode 100644 index 00000000..c9e41e09 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/phases-drawer.json @@ -0,0 +1,19 @@ +{ + "configurePhases": "Phasen konfigurieren", + "phaseLabel": "Phasenbezeichnung", + "enterPhaseName": "Namen für Phasenbezeichnung eingeben", + "addOption": "Option hinzufügen", + "phaseOptions": "Phasenoptionen:", + "dragToReorderPhases": "Ziehen Sie Phasen, um sie neu zu ordnen. Jede Phase kann eine andere Farbe haben.", + "enterNewPhaseName": "Neuen Phasennamen eingeben...", + "addPhase": "Phase hinzufügen", + "noPhasesFound": "Keine Phasen gefunden. Erstellen Sie Ihre erste Phase oben.", + "deletePhase": "Phase löschen", + "deletePhaseConfirm": "Sind Sie sicher, dass Sie diese Phase löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "rename": "Umbenennen", + "delete": "Löschen", + "enterPhaseName": "Phasennamen eingeben", + "selectColor": "Farbe auswählen", + "managePhases": "Phasen verwalten", + "close": "Schließen" +} diff --git a/worklenz-backend/src/public/locales/de/project-drawer.json b/worklenz-backend/src/public/locales/de/project-drawer.json new file mode 100644 index 00000000..d20f220b --- /dev/null +++ b/worklenz-backend/src/public/locales/de/project-drawer.json @@ -0,0 +1,42 @@ +{ + "createProject": "Projekt erstellen", + "editProject": "Projekt bearbeiten", + "enterCategoryName": "Namen für die Kategorie eingeben", + "hitEnterToCreate": "Enter drücken zum Erstellen!", + "enterNotes": "Notizen", + "youCanManageClientsUnderSettings": "Kunden können Sie unter Einstellungen verwalten", + "addCategory": "Kategorie zum Projekt hinzufügen", + "newCategory": "Neue Kategorie", + "notes": "Notizen", + "startDate": "Startdatum", + "endDate": "Enddatum", + "estimateWorkingDays": "Arbeitstage schätzen", + "estimateManDays": "Personentage schätzen", + "hoursPerDay": "Stunden pro Tag", + "create": "Erstellen", + "update": "Aktualisieren", + "delete": "Löschen", + "typeToSearchClients": "Kundensuche", + "projectColor": "Projektfarbe", + "pleaseEnterAName": "Bitte geben Sie einen Namen ein", + "enterProjectName": "Projektnamen eingeben", + "name": "Name", + "status": "Status", + "health": "Gesundheit", + "category": "Kategorie", + "projectManager": "Projektleiter", + "client": "Kunde", + "deleteConfirmation": "Sind Sie sicher, dass Sie löschen möchten?", + "deleteConfirmationDescription": "Dies entfernt alle zugehörigen Daten und kann nicht rückgängig gemacht werden.", + "yes": "Ja", + "no": "Nein", + "createdAt": "Erstellt am", + "updatedAt": "Aktualisiert am", + "by": "von", + "add": "Hinzufügen", + "asClient": "als Kunde", + "createClient": "Kunde erstellen", + "searchInputPlaceholder": "Nach Name oder E-Mail suchen", + "hoursPerDayValidationMessage": "Stunden pro Tag müssen zwischen 1 und 24", + "noPermission": "Keine Berechtigung" +} diff --git a/worklenz-backend/src/public/locales/de/project-view-files.json b/worklenz-backend/src/public/locales/de/project-view-files.json new file mode 100644 index 00000000..8408df16 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/project-view-files.json @@ -0,0 +1,14 @@ +{ + "nameColumn": "Name", + "attachedTaskColumn": "Zugehörige Aufgabe", + "sizeColumn": "Größe", + "uploadedByColumn": "Hochgeladen von", + "uploadedAtColumn": "Hochgeladen am", + "fileIconAlt": "Dateisymbol", + "titleDescriptionText": "Alle Anhänge zu Aufgaben in diesem Projekt werden hier angezeigt.", + "deleteConfirmationTitle": "Sind Sie sicher?", + "deleteConfirmationOk": "Ja", + "deleteConfirmationCancel": "Abbrechen", + "segmentedTooltip": "Demnächst verfügbar! Wechseln zwischen Listenansicht und Miniaturansicht.", + "emptyText": "Es gibt keine Anhänge in diesem Projekt." +} diff --git a/worklenz-backend/src/public/locales/de/project-view-insights.json b/worklenz-backend/src/public/locales/de/project-view-insights.json new file mode 100644 index 00000000..5f11df54 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/project-view-insights.json @@ -0,0 +1,41 @@ +{ + "overview": { + "title": "Übersicht", + "statusOverview": "Statusübersicht", + "priorityOverview": "Prioritätenübersicht", + "lastUpdatedTasks": "Zuletzt aktualisierte Aufgaben" + }, + "members": { + "title": "Mitglieder", + "tooltip": "Mitglieder", + "tasksByMembers": "Aufgaben nach Mitgliedern", + "tasksByMembersTooltip": "Aufgaben nach Mitgliedern", + "name": "Name", + "taskCount": "Anzahl Aufgaben", + "contribution": "Beitrag", + "completed": "Abgeschlossen", + "incomplete": "Unvollständig", + "overdue": "Überfällig", + "progress": "Fortschritt" + }, + "tasks": { + "overdueTasks": "Überfällige Aufgaben", + "overLoggedTasks": "Aufgaben mit zu viel erfasster Zeit", + "tasksCompletedEarly": "Vorzeitig abgeschlossene Aufgaben", + "tasksCompletedLate": "Verspätet abgeschlossene Aufgaben", + "overLoggedTasksTooltip": "Aufgaben, bei denen mehr Zeit erfasst wurde als geschätzt", + "overdueTasksTooltip": "Aufgaben, deren Fälligkeitsdatum überschritten wurde" + }, + "common": { + "seeAll": "Alle anzeigen", + "totalLoggedHours": "Gesamterfasste Stunden", + "totalEstimation": "Gesamtschätzung", + "completedTasks": "Abgeschlossene Aufgaben", + "incompleteTasks": "Unvollständige Aufgaben", + "overdueTasks": "Überfällige Aufgaben", + "overdueTasksTooltip": "Aufgaben, deren Fälligkeitsdatum überschritten wurde", + "totalLoggedHoursTooltip": "Zeitschätzung und erfasste Zeit für Aufgaben.", + "includeArchivedTasks": "Archivierte Aufgaben einbeziehen", + "export": "Exportieren" + } +} diff --git a/worklenz-backend/src/public/locales/de/project-view-members.json b/worklenz-backend/src/public/locales/de/project-view-members.json new file mode 100644 index 00000000..eee5d0a1 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/project-view-members.json @@ -0,0 +1,17 @@ +{ + "nameColumn": "Name", + "jobTitleColumn": "Jobtitel", + "emailColumn": "E-Mail", + "tasksColumn": "Aufgaben", + "taskProgressColumn": "Aufgabenfortschritt", + "accessColumn": "Zugriff", + "fileIconAlt": "Dateisymbol", + "deleteConfirmationTitle": "Sind Sie sicher?", + "deleteConfirmationOk": "Ja", + "deleteConfirmationCancel": "Abbrechen", + "refreshButtonTooltip": "Mitglieder aktualisieren", + "deleteButtonTooltip": "Aus Projekt entfernen", + "memberCount": "Mitglied", + "membersCountPlural": "Mitglieder", + "emptyText": "Es gibt keine Anhänge in diesem Projekt." +} diff --git a/worklenz-backend/src/public/locales/de/project-view-updates.json b/worklenz-backend/src/public/locales/de/project-view-updates.json new file mode 100644 index 00000000..d32cf352 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/project-view-updates.json @@ -0,0 +1,6 @@ +{ + "inputPlaceholder": "Kommentar hinzufügen..", + "addButton": "Hinzufügen", + "cancelButton": "Abbrechen", + "deleteButton": "Löschen" +} diff --git a/worklenz-backend/src/public/locales/de/project-view.json b/worklenz-backend/src/public/locales/de/project-view.json new file mode 100644 index 00000000..448a7249 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/project-view.json @@ -0,0 +1,14 @@ +{ + "taskList": "Aufgabenliste", + "board": "Kanban-Board", + "insights": "Insights", + "files": "Dateien", + "members": "Mitglieder", + "updates": "Aktualisierungen", + "projectView": "Projektansicht", + "loading": "Projekt wird geladen...", + "error": "Fehler beim Laden des Projekts", + "pinnedTab": "Als Standard-Registerkarte festgesetzt", + "pinTab": "Als Standard-Registerkarte festsetzen", + "unpinTab": "Standard-Registerkarte lösen" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/de/project-view/import-task-templates.json b/worklenz-backend/src/public/locales/de/project-view/import-task-templates.json new file mode 100644 index 00000000..c5af50a0 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/project-view/import-task-templates.json @@ -0,0 +1,11 @@ +{ + "importTaskTemplate": "Aufgabenvorlage importieren", + "templateName": "Vorlagenname", + "templateDescription": "Vorlagenbeschreibung", + "selectedTasks": "Ausgewählte Aufgaben", + "tasks": "Aufgaben", + "templates": "Vorlagen", + "remove": "Entfernen", + "cancel": "Abbrechen", + "import": "Importieren" +} diff --git a/worklenz-backend/src/public/locales/de/project-view/project-member-drawer.json b/worklenz-backend/src/public/locales/de/project-view/project-member-drawer.json new file mode 100644 index 00000000..cb391b2c --- /dev/null +++ b/worklenz-backend/src/public/locales/de/project-view/project-member-drawer.json @@ -0,0 +1,7 @@ +{ + "title": "Projektmitglieder", + "searchLabel": "Mitglieder hinzufügen durch Eingabe von Name oder E-Mail", + "searchPlaceholder": "Name oder E-Mail eingeben", + "inviteAsAMember": "Als Mitglied einladen", + "inviteNewMemberByEmail": "Neues Mitglied per E-Mail einladen" +} diff --git a/worklenz-backend/src/public/locales/de/project-view/project-view-header.json b/worklenz-backend/src/public/locales/de/project-view/project-view-header.json new file mode 100644 index 00000000..c52c6052 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/project-view/project-view-header.json @@ -0,0 +1,30 @@ +{ + "importTasks": "Aufgaben importieren", + "importTask": "Aufgabe importieren", + "createTask": "Aufgabe erstellen", + "settings": "Einstellungen", + "subscribe": "Abonnieren", + "unsubscribe": "Abonnement beenden", + "deleteProject": "Projekt löschen", + "startDate": "Startdatum", + "endDate": "Enddatum", + "projectSettings": "Projekteinstellungen", + "projectSummary": "Projektzusammenfassung", + "receiveProjectSummary": "Erhalten Sie jeden Abend eine Projektzusammenfassung.", + "refreshProject": "Projekt aktualisieren", + "saveAsTemplate": "Als Vorlage speichern", + "invite": "Einladen", + "share": "Teilen", + "subscribeTooltip": "Projektbenachrichtigungen abonnieren", + "unsubscribeTooltip": "Projektbenachrichtigungen beenden", + "refreshTooltip": "Projektdaten aktualisieren", + "settingsTooltip": "Projekteinstellungen öffnen", + "saveAsTemplateTooltip": "Dieses Projekt als Vorlage speichern", + "inviteTooltip": "Teammitglieder zu diesem Projekt einladen", + "createTaskTooltip": "Neue Aufgabe erstellen", + "importTaskTooltip": "Aufgabe aus Vorlage importieren", + "navigateBackTooltip": "Zurück zur Projektliste", + "projectStatusTooltip": "Projektstatus", + "projectDatesInfo": "Informationen zum Projektzeitraum", + "projectCategoryTooltip": "Projektkategorie" +} diff --git a/worklenz-backend/src/public/locales/de/project-view/save-as-template.json b/worklenz-backend/src/public/locales/de/project-view/save-as-template.json new file mode 100644 index 00000000..7b732962 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/project-view/save-as-template.json @@ -0,0 +1,27 @@ +{ + "title": "Als Vorlage speichern", + "templateName": "Vorlagenname", + "includes": "Was soll aus dem Projekt in die Vorlage aufgenommen werden?", + "includesOptions": { + "statuses": "Status", + "phases": "Phasen", + "labels": "Labels" + }, + "taskIncludes": "Was soll aus den Aufgaben in die Vorlage aufgenommen werden?", + "taskIncludesOptions": { + "statuses": "Status", + "phases": "Phasen", + "labels": "Labels", + "name": "Name", + "priority": "Priorität", + "status": "Status", + "phase": "Phase", + "label": "Label", + "timeEstimate": "Zeitschätzung", + "description": "Beschreibung", + "subTasks": "Unteraufgaben" + }, + "cancel": "Abbrechen", + "save": "Speichern", + "templateNamePlaceholder": "Vorlagennamen eingeben" +} diff --git a/worklenz-backend/src/public/locales/de/reporting-members-drawer.json b/worklenz-backend/src/public/locales/de/reporting-members-drawer.json new file mode 100644 index 00000000..807f43c7 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/reporting-members-drawer.json @@ -0,0 +1,90 @@ +{ + "exportButton": "Exportieren", + "timeLogsButton": "Zeiterfassungen", + "activityLogsButton": "Aktivitätsprotokolle", + "tasksButton": "Aufgaben", + "searchByNameInputPlaceholder": "Nach Namen suchen", + + "overviewTab": "Übersicht", + "timeLogsTab": "Zeiterfassungen", + "activityLogsTab": "Aktivitätsprotokolle", + "tasksTab": "Aufgaben", + + "projectsText": "Projekte", + "totalTasksText": "Gesamtaufgaben", + "assignedTasksText": "Zugewiesene Aufgaben", + "completedTasksText": "Abgeschlossene Aufgaben", + "ongoingTasksText": "Laufende Aufgaben", + "overdueTasksText": "Überfällige Aufgaben", + "loggedHoursText": "Erfasste Stunden", + + "tasksText": "Aufgaben", + "allText": "Alle", + + "tasksByProjectsText": "Aufgaben nach Projekten", + "tasksByStatusText": "Aufgaben nach Status", + "tasksByPriorityText": "Aufgaben nach Priorität", + + "todoText": "Zu erledigen", + "doingText": "Tun", + "doneText": "Erledigt", + "lowText": "Niedrig", + "mediumText": "Mittel", + "highText": "Hoch", + + "billableButton": "Abrechenbar", + "billableText": "Abrechenbar", + "nonBillableText": "Nicht abrechenbar", + + "timeLogsEmptyPlaceholder": "Keine Zeiterfassungen vorhanden", + "loggedText": "Erfasst", + "forText": "für", + "inText": "in", + "updatedText": "Aktualisiert", + "fromText": "Von", + "toText": "bis", + "withinText": "innerhalb", + + "activityLogsEmptyPlaceholder": "Keine Aktivitätsprotokolle vorhanden", + + "filterByText": "Filtern nach:", + "selectProjectPlaceholder": "Projekt auswählen", + + "taskColumn": "Aufgabe", + "nameColumn": "Name", + "projectColumn": "Projekt", + "statusColumn": "Status", + "priorityColumn": "Priorität", + "dueDateColumn": "Fälligkeitsdatum", + "completedDateColumn": "Abschlussdatum", + "estimatedTimeColumn": "Geschätzte Zeit", + "loggedTimeColumn": "Erfasste Zeit", + "overloggedTimeColumn": "Übererfasste Zeit", + "daysLeftColumn": "Tage übrig/überfällig", + "startDateColumn": "Startdatum", + "endDateColumn": "Enddatum", + "actualTimeColumn": "Tatsächliche Zeit", + "projectHealthColumn": "Projektstatus", + "categoryColumn": "Kategorie", + "projectManagerColumn": "Projektleiter", + + "tasksStatsOverviewDrawerTitle": "Aufgaben von", + "projectsStatsOverviewDrawerTitle": "Projekte von", + + "cancelledText": "Abgebrochen", + "blockedText": "Blockiert", + "onHoldText": "Pausiert", + "proposedText": "Vorgeschlagen", + "inPlanningText": "In Planung", + "inProgressText": "In Bearbeitung", + "completedText": "Abgeschlossen", + "continuousText": "Kontinuierlich", + + "daysLeftText": "Tage übrig", + "daysOverdueText": "Tage überfällig", + + "notSetText": "Nicht festgelegt", + "needsAttentionText": "Benötigt Aufmerksamkeit", + "atRiskText": "Gefährdet", + "goodText": "Gut" +} diff --git a/worklenz-backend/src/public/locales/de/reporting-members.json b/worklenz-backend/src/public/locales/de/reporting-members.json new file mode 100644 index 00000000..5454d2a8 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/reporting-members.json @@ -0,0 +1,35 @@ +{ + "yesterdayText": "Gestern", + "lastSevenDaysText": "Letzte 7 Tage", + "lastWeekText": "Letzte Woche", + "lastThirtyDaysText": "Letzte 30 Tage", + "lastMonthText": "Letzter Monat", + "lastThreeMonthsText": "Letzte 3 Monate", + "allTimeText": "Gesamter Zeitraum", + "customRangeText": "Benutzerdefinierter Bereich", + "startDateInputPlaceholder": "Startdatum", + "EndDateInputPlaceholder": "Enddatum", + "filterButton": "Filtern", + + "membersTitle": "Mitglieder", + "includeArchivedButton": "Archivierte Projekte einschließen", + "exportButton": "Exportieren", + "excelButton": "Excel", + "searchByNameInputPlaceholder": "Nach Namen suchen", + + "memberColumn": "Mitglied", + "tasksProgressColumn": "Aufgabenfortschritt", + "tasksAssignedColumn": "Zugewiesene Aufgaben", + "completedTasksColumn": "Abgeschlossene Aufgaben", + "overdueTasksColumn": "Überfällige Aufgaben", + "ongoingTasksColumn": "Laufende Aufgaben", + + "tasksAssignedColumnTooltip": "Im ausgewählten Zeitraum zugewiesene Aufgaben", + "overdueTasksColumnTooltip": "Zum Ende des ausgewählten Zeitraums überfällige Aufgaben", + "completedTasksColumnTooltip": "Im ausgewählten Zeitraum abgeschlossene Aufgaben", + "ongoingTasksColumnTooltip": "Begonnene, aber noch nicht abgeschlossene Aufgaben", + + "todoText": "Zu erledigen", + "doingText": "Tun", + "doneText": "Erledigt" +} diff --git a/worklenz-backend/src/public/locales/de/reporting-overview-drawer.json b/worklenz-backend/src/public/locales/de/reporting-overview-drawer.json new file mode 100644 index 00000000..6bf4678a --- /dev/null +++ b/worklenz-backend/src/public/locales/de/reporting-overview-drawer.json @@ -0,0 +1,39 @@ +{ + "exportButton": "Exportieren", + "projectsButton": "Projekte", + "membersButton": "Mitglieder", + "searchByNameInputPlaceholder": "Nach Namen suchen", + + "overviewTab": "Übersicht", + "projectsTab": "Projekte", + "membersTab": "Mitglieder", + + "projectsByStatusText": "Projekte nach Status", + "projectsByCategoryText": "Projekte nach Kategorie", + "projectsByHealthText": "Projekte nach Gesundheit", + + "projectsText": "Projekte", + "allText": "Alle", + + "cancelledText": "Abgebrochen", + "blockedText": "Blockiert", + "onHoldText": "Pausiert", + "proposedText": "Vorgeschlagen", + "inPlanningText": "In Planung", + "inProgressText": "In Bearbeitung", + "completedText": "Abgeschlossen", + "continuousText": "Kontinuierlich", + + "notSetText": "Nicht festgelegt", + "needsAttentionText": "Benötigt Aufmerksamkeit", + "atRiskText": "Gefährdet", + "goodText": "Gut", + + "nameColumn": "Name", + "emailColumn": "E-Mail", + "projectsColumn": "Projekte", + "tasksColumn": "Aufgaben", + "overdueTasksColumn": "Überfällige Aufgaben", + "completedTasksColumn": "Abgeschlossene Aufgaben", + "ongoingTasksColumn": "Laufende Aufgaben" +} diff --git a/worklenz-backend/src/public/locales/de/reporting-overview.json b/worklenz-backend/src/public/locales/de/reporting-overview.json new file mode 100644 index 00000000..411ec83a --- /dev/null +++ b/worklenz-backend/src/public/locales/de/reporting-overview.json @@ -0,0 +1,25 @@ +{ + "overviewTitle": "Übersicht", + "includeArchivedButton": "Archivierte Projekte einschließen", + + "teamCount": "Team", + "teamCountPlural": "Teams", + "projectCount": "Projekt", + "projectCountPlural": "Projekte", + "memberCount": "Mitglied", + "memberCountPlural": "Mitglieder", + "activeProjectCount": "Aktives Projekt", + "activeProjectCountPlural": "Aktive Projekte", + "overdueProjectCount": "Überfälliges Projekt", + "overdueProjectCountPlural": "Überfällige Projekte", + "unassignedMemberCount": "Nicht zugewiesenes Mitglied", + "unassignedMemberCountPlural": "Nicht zugewiesene Mitglieder", + "memberWithOverdueTaskCount": "Mitglied mit überfälliger Aufgabe", + "memberWithOverdueTaskCountPlural": "Mitglieder mit überfälligen Aufgaben", + + "teamsText": "Teams", + + "nameColumn": "Name", + "projectsColumn": "Projekte", + "membersColumn": "Mitglieder" +} diff --git a/worklenz-backend/src/public/locales/de/reporting-projects-drawer.json b/worklenz-backend/src/public/locales/de/reporting-projects-drawer.json new file mode 100644 index 00000000..3f335a6c --- /dev/null +++ b/worklenz-backend/src/public/locales/de/reporting-projects-drawer.json @@ -0,0 +1,59 @@ +{ + "exportButton": "Exportieren", + "membersButton": "Mitglieder", + "tasksButton": "Aufgaben", + "searchByNameInputPlaceholder": "Nach Namen suchen", + + "overviewTab": "Übersicht", + "membersTab": "Mitglieder", + "tasksTab": "Aufgaben", + + "completedTasksText": "Abgeschlossene Aufgaben", + "incompleteTasksText": "Unvollständige Aufgaben", + "overdueTasksText": "Überfällige Aufgaben", + "allocatedHoursText": "Zugewiesene Stunden", + "loggedHoursText": "Erfasste Stunden", + + "tasksText": "Aufgaben", + "allText": "Alle", + + "tasksByStatusText": "Aufgaben nach Status", + "tasksByPriorityText": "Aufgaben nach Priorität", + "tasksByDueDateText": "Aufgaben nach Fälligkeit", + + "todoText": "Zu erledigen", + "doingText": "Tun", + "doneText": "Erledigt", + "lowText": "Niedrig", + "mediumText": "Mittel", + "highText": "Hoch", + "completedText": "Abgeschlossen", + "upcomingText": "Bevorstehend", + "overdueText": "Überfällig", + "noDueDateText": "Kein Fälligkeitsdatum", + + "nameColumn": "Name", + "tasksCountColumn": "Anzahl Aufgaben", + "completedTasksColumn": "Abgeschlossene Aufgaben", + "incompleteTasksColumn": "Unvollständige Aufgaben", + "overdueTasksColumn": "Überfällige Aufgaben", + "contributionColumn": "Beitrag", + "progressColumn": "Fortschritt", + "loggedTimeColumn": "Erfasste Zeit", + "taskColumn": "Aufgabe", + "projectColumn": "Projekt", + "statusColumn": "Status", + "priorityColumn": "Priorität", + "phaseColumn": "Phase", + "dueDateColumn": "Fälligkeitsdatum", + "completedDateColumn": "Abschlussdatum", + "estimatedTimeColumn": "Geschätzte Zeit", + "overloggedTimeColumn": "Übererfasste Zeit", + "completedOnColumn": "Abgeschlossen am", + "daysOverdueColumn": "Tage überfällig", + + "groupByText": "Gruppieren nach:", + "statusText": "Status", + "priorityText": "Priorität", + "phaseText": "Phase" +} diff --git a/worklenz-backend/src/public/locales/de/reporting-projects-filters.json b/worklenz-backend/src/public/locales/de/reporting-projects-filters.json new file mode 100644 index 00000000..c48fa256 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/reporting-projects-filters.json @@ -0,0 +1,35 @@ +{ + "searchByNamePlaceholder": "Nach Namen suchen", + "searchByCategoryPlaceholder": "Nach Kategorie suchen", + + "statusText": "Status", + "healthText": "Gesundheit", + "categoryText": "Kategorie", + "projectManagerText": "Projektleiter", + "showFieldsText": "Felder anzeigen", + + "cancelledText": "Abgebrochen", + "blockedText": "Blockiert", + "onHoldText": "Pausiert", + "proposedText": "Vorgeschlagen", + "inPlanningText": "In Planung", + "inProgressText": "In Bearbeitung", + "completedText": "Abgeschlossen", + "continuousText": "Kontinuierlich", + + "notSetText": "Nicht festgelegt", + "needsAttentionText": "Benötigt Aufmerksamkeit", + "atRiskText": "Gefährdet", + "goodText": "Gut", + + "nameText": "Projekt", + "estimatedVsActualText": "Geplant vs. Tatsächlich", + "tasksProgressText": "Aufgabenfortschritt", + "lastActivityText": "Letzte Aktivität", + "datesText": "Start-/Enddatum", + "daysLeftText": "Tage übrig/überfällig", + "projectHealthText": "Projektstatus", + "projectUpdateText": "Projektupdate", + "clientText": "Kunde", + "teamText": "Team" +} diff --git a/worklenz-backend/src/public/locales/de/reporting-projects.json b/worklenz-backend/src/public/locales/de/reporting-projects.json new file mode 100644 index 00000000..0f63310b --- /dev/null +++ b/worklenz-backend/src/public/locales/de/reporting-projects.json @@ -0,0 +1,52 @@ +{ + "projectCount": "Projekt", + "projectCountPlural": "Projekte", + "includeArchivedButton": "Archivierte Projekte einschließen", + "exportButton": "Exportieren", + "excelButton": "Excel", + + "projectColumn": "Projekt", + "estimatedVsActualColumn": "Geschätzt vs. Tatsächlich", + "tasksProgressColumn": "Aufgabenfortschritt", + "lastActivityColumn": "Letzte Aktivität", + "statusColumn": "Status", + "datesColumn": "Start-/Enddatum", + "daysLeftColumn": "Tage übrig/überfällig", + "projectHealthColumn": "Projektzustand", + "categoryColumn": "Kategorie", + "projectUpdateColumn": "Projektupdate", + "clientColumn": "Kunde", + "teamColumn": "Team", + "projectManagerColumn": "Projektleiter", + + "openButton": "Öffnen", + + "estimatedText": "Geschätzt", + "actualText": "Tatsächlich", + + "todoText": "Zu erledigen", + "doingText": "Tun", + "doneText": "Erledigt", + + "cancelledText": "Abgebrochen", + "blockedText": "Blockiert", + "onHoldText": "Pausiert", + "proposedText": "Vorgeschlagen", + "inPlanningText": "In Planung", + "inProgressText": "In Bearbeitung", + "completedText": "Abgeschlossen", + "continuousText": "Kontinuierlich", + + "daysLeftText": "Tage übrig", + "dayLeftText": "Tag übrig", + "daysOverdueText": "Tage überfällig", + + "notSetText": "Nicht festgelegt", + "needsAttentionText": "Benötigt Aufmerksamkeit", + "atRiskText": "Gefährdet", + "goodText": "Gut", + + "setCategoryText": "Kategorie festlegen", + "searchByNameInputPlaceholder": "Nach Namen suchen", + "todayText": "Heute" +} diff --git a/worklenz-backend/src/public/locales/de/reporting-sidebar.json b/worklenz-backend/src/public/locales/de/reporting-sidebar.json new file mode 100644 index 00000000..74d6bfb9 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/reporting-sidebar.json @@ -0,0 +1,8 @@ +{ + "overview": "Übersicht", + "projects": "Projekte", + "members": "Mitglieder", + "timeReports": "Zeitberichte", + "estimateVsActual": "Schätzen vs. Tatsächlich", + "currentOrganizationTooltip": "Aktuelle Organisation" +} diff --git a/worklenz-backend/src/public/locales/de/schedule.json b/worklenz-backend/src/public/locales/de/schedule.json new file mode 100644 index 00000000..046c7bb0 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/schedule.json @@ -0,0 +1,39 @@ +{ + "today": "Heute", + "week": "Woche", + "month": "Monat", + + "settings": "Einstellungen", + "workingDays": "Arbeitstage", + "monday": "Montag", + "tuesday": "Dienstag", + "wednesday": "Mittwoch", + "thursday": "Donnerstag", + "friday": "Freitag", + "saturday": "Samstag", + "sunday": "Sonntag", + "workingHours": "Arbeitsstunden", + "hours": "Stunden", + "saveButton": "Speichern", + + "totalAllocation": "Gesamtzuteilung", + "timeLogged": "Zeiterfassung", + "remainingTime": "Verbleibende Zeit", + "total": "Gesamt", + "perDay": "Pro Tag", + "tasks": "Aufgaben", + "startDate": "Startdatum", + "endDate": "Enddatum", + + "hoursPerDay": "Stunden pro Tag", + "totalHours": "Gesamtstunden", + "deleteButton": "Löschen", + "cancelButton": "Abbrechen", + + "tabTitle": "Aufgaben ohne Start- & Enddatum", + + "allocatedTime": "Zugewiesene Zeit", + "totalLogged": "Gesamterfasst", + "loggedBillable": "Abrechenbar erfasst", + "loggedNonBillable": "Nicht abrechenbar erfasst" +} diff --git a/worklenz-backend/src/public/locales/de/settings/categories.json b/worklenz-backend/src/public/locales/de/settings/categories.json new file mode 100644 index 00000000..5694d11d --- /dev/null +++ b/worklenz-backend/src/public/locales/de/settings/categories.json @@ -0,0 +1,10 @@ +{ + "categoryColumn": "Kategorie", + "deleteConfirmationTitle": "Sind Sie sicher?", + "deleteConfirmationOk": "Ja", + "deleteConfirmationCancel": "Abbrechen", + "associatedTaskColumn": "Zugehörige Projekte", + "searchPlaceholder": "Nach Namen suchen", + "emptyText": "Kategorien können beim Aktualisieren oder Erstellen von Projekten angelegt werden.", + "colorChangeTooltip": "Zum Farbwechsel klicken" +} diff --git a/worklenz-backend/src/public/locales/de/settings/change-password.json b/worklenz-backend/src/public/locales/de/settings/change-password.json new file mode 100644 index 00000000..6b65a8cf --- /dev/null +++ b/worklenz-backend/src/public/locales/de/settings/change-password.json @@ -0,0 +1,15 @@ +{ + "title": "Passwort ändern", + "currentPassword": "Aktuelles Passwort", + "newPassword": "Neues Passwort", + "confirmPassword": "Passwort bestätigen", + "currentPasswordPlaceholder": "Aktuelles Passwort eingeben", + "newPasswordPlaceholder": "Neues Passwort", + "confirmPasswordPlaceholder": "Passwort bestätigen", + "currentPasswordRequired": "Bitte geben Sie Ihr aktuelles Passwort ein!", + "newPasswordRequired": "Bitte geben Sie Ihr neues Passwort ein!", + "passwordValidationError": "Das Passwort muss mindestens 8 Zeichen lang sein und einen Großbuchstaben, eine Zahl und ein Sonderzeichen enthalten.", + "passwordMismatch": "Die Passwörter stimmen nicht überein!", + "passwordRequirements": "Das neue Passwort muss mindestens 8 Zeichen lang sein und einen Großbuchstaben, eine Zahl und ein Sonderzeichen enthalten.", + "updateButton": "Passwort aktualisieren" +} diff --git a/worklenz-backend/src/public/locales/de/settings/clients.json b/worklenz-backend/src/public/locales/de/settings/clients.json new file mode 100644 index 00000000..d2982bdb --- /dev/null +++ b/worklenz-backend/src/public/locales/de/settings/clients.json @@ -0,0 +1,22 @@ +{ + "nameColumn": "Name", + "projectColumn": "Projekt", + "noProjectsAvailable": "Keine Projekte verfügbar", + "deleteConfirmationTitle": "Sind Sie sicher?", + "deleteConfirmationOk": "Ja", + "deleteConfirmationCancel": "Abbrechen", + "searchPlaceholder": "Nach Namen suchen", + "createClient": "Kunde anlegen", + "pinTooltip": "Zum Anheften an das Hauptmenü klicken", + "createClientDrawerTitle": "Kunde anlegen", + "updateClientDrawerTitle": "Kunde aktualisieren", + "nameLabel": "Name", + "namePlaceholder": "Name", + "nameRequiredError": "Bitte geben Sie einen Namen ein", + "createButton": "Anlegen", + "updateButton": "Aktualisieren", + "createClientSuccessMessage": "Kunde erfolgreich angelegt!", + "createClientErrorMessage": "Anlegen des Kunden fehlgeschlagen!", + "updateClientSuccessMessage": "Kunde erfolgreich aktualisiert!", + "updateClientErrorMessage": "Aktualisierung des Kunden fehlgeschlagen!" +} diff --git a/worklenz-backend/src/public/locales/de/settings/job-titles.json b/worklenz-backend/src/public/locales/de/settings/job-titles.json new file mode 100644 index 00000000..f4403ad1 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/settings/job-titles.json @@ -0,0 +1,20 @@ +{ + "nameColumn": "Name", + "deleteConfirmationTitle": "Sind Sie sicher?", + "deleteConfirmationOk": "Ja", + "deleteConfirmationCancel": "Abbrechen", + "searchPlaceholder": "Nach Namen suchen", + "createJobTitleButton": "Jobtitel erstellen", + "pinTooltip": "Zum Anheften an das Hauptmenü klicken", + "createJobTitleDrawerTitle": "Jobtitel erstellen", + "updateJobTitleDrawerTitle": "Jobtitel aktualisieren", + "nameLabel": "Name", + "namePlaceholder": "Name", + "nameRequiredError": "Bitte geben Sie einen Namen ein", + "createButton": "Erstellen", + "updateButton": "Aktualisieren", + "createJobTitleSuccessMessage": "Jobtitel erfolgreich erstellt!", + "createJobTitleErrorMessage": "Erstellung des Jobtitels fehlgeschlagen!", + "updateJobTitleSuccessMessage": "Jobtitel erfolgreich aktualisiert!", + "updateJobTitleErrorMessage": "Aktualisierung des Jobtitels fehlgeschlagen!" +} diff --git a/worklenz-backend/src/public/locales/de/settings/labels.json b/worklenz-backend/src/public/locales/de/settings/labels.json new file mode 100644 index 00000000..18b6a021 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/settings/labels.json @@ -0,0 +1,11 @@ +{ + "labelColumn": "Label", + "deleteConfirmationTitle": "Sind Sie sicher?", + "deleteConfirmationOk": "Ja", + "deleteConfirmationCancel": "Abbrechen", + "associatedTaskColumn": "Zugeordnete Aufgabenanzahl", + "searchPlaceholder": "Nach Name suchen", + "emptyText": "Labels können beim Aktualisieren oder Erstellen von Aufgaben erstellt werden.", + "pinTooltip": "Zum Anheften an das Hauptmenü klicken", + "colorChangeTooltip": "Zum Ändern der Farbe klicken" +} diff --git a/worklenz-backend/src/public/locales/de/settings/language.json b/worklenz-backend/src/public/locales/de/settings/language.json new file mode 100644 index 00000000..9e6bc27d --- /dev/null +++ b/worklenz-backend/src/public/locales/de/settings/language.json @@ -0,0 +1,7 @@ +{ + "language": "Sprache", + "language_required": "Sprache ist erforderlich", + "time_zone": "Zeitzone", + "time_zone_required": "Zeitzone ist erforderlich", + "save_changes": "Änderungen speichern" +} diff --git a/worklenz-backend/src/public/locales/de/settings/notifications.json b/worklenz-backend/src/public/locales/de/settings/notifications.json new file mode 100644 index 00000000..272542a0 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/settings/notifications.json @@ -0,0 +1,11 @@ +{ + "title": "Benachrichtigungseinstellungen", + "emailTitle": "E-Mail-Benachrichtigungen erhalten", + "emailDescription": "Dies beinhaltet neue Aufgaben-Zuweisungen", + "dailyDigestTitle": "Tägliche Übersicht erhalten", + "dailyDigestDescription": "Jeden Abend erhalten Sie eine Zusammenfassung der letzten Aktivitäten in Aufgaben.", + "popupTitle": "Popup-Benachrichtigungen auf meinem Computer anzeigen, wenn Worklenz geöffnet ist", + "popupDescription": "Popup-Benachrichtigungen können von Ihrem Browser blockiert werden. Ändern Sie Ihre Browser-Einstellungen, um diese zu erlauben.", + "unreadItemsTitle": "Anzahl ungelesener Elemente anzeigen", + "unreadItemsDescription": "Sie sehen Zähler für jede Benachrichtigung." +} diff --git a/worklenz-backend/src/public/locales/de/settings/profile.json b/worklenz-backend/src/public/locales/de/settings/profile.json new file mode 100644 index 00000000..4d7fc4cd --- /dev/null +++ b/worklenz-backend/src/public/locales/de/settings/profile.json @@ -0,0 +1,14 @@ +{ + "uploadError": "Sie können nur JPG/PNG-Dateien hochladen!", + "uploadSizeError": "Bilder müssen kleiner als 2MB sein!", + "upload": "Hochladen", + "nameLabel": "Name", + "nameRequiredError": "Name ist erforderlich", + "emailLabel": "E-Mail", + "emailRequiredError": "E-Mail ist erforderlich", + "saveChanges": "Änderungen speichern", + "profileJoinedText": "Vor einem Monat beigetreten", + "profileLastUpdatedText": "Vor einem Monat aktualisiert", + "avatarTooltip": "Klicken Sie zum Hochladen eines Avatars", + "title": "Profil-Einstellungen" +} diff --git a/worklenz-backend/src/public/locales/de/settings/project-templates.json b/worklenz-backend/src/public/locales/de/settings/project-templates.json new file mode 100644 index 00000000..6c6e23ca --- /dev/null +++ b/worklenz-backend/src/public/locales/de/settings/project-templates.json @@ -0,0 +1,8 @@ +{ + "nameColumn": "Name", + "editToolTip": "Bearbeiten", + "deleteToolTip": "Löschen", + "confirmText": "Sind Sie sicher?", + "okText": "Ja", + "cancelText": "Abbrechen" +} diff --git a/worklenz-backend/src/public/locales/de/settings/sidebar.json b/worklenz-backend/src/public/locales/de/settings/sidebar.json new file mode 100644 index 00000000..d4d8754d --- /dev/null +++ b/worklenz-backend/src/public/locales/de/settings/sidebar.json @@ -0,0 +1,14 @@ +{ + "profile": "Profil", + "notifications": "Benachrichtigungen", + "clients": "Kunden", + "job-titles": "Jobbezeichnungen", + "labels": "Labels", + "categories": "Kategorien", + "project-templates": "Projektvorlagen", + "task-templates": "Aufgabenvorlagen", + "team-members": "Teammitglieder", + "teams": "Teams", + "change-password": "Passwort ändern", + "language-and-region": "Sprache und Region" +} diff --git a/worklenz-backend/src/public/locales/de/settings/task-templates.json b/worklenz-backend/src/public/locales/de/settings/task-templates.json new file mode 100644 index 00000000..ffe93318 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/settings/task-templates.json @@ -0,0 +1,9 @@ +{ + "nameColumn": "Name", + "createdColumn": "Erstellt", + "editToolTip": "Bearbeiten", + "deleteToolTip": "Löschen", + "confirmText": "Sind Sie sicher?", + "okText": "Ja", + "cancelText": "Abbrechen" +} diff --git a/worklenz-backend/src/public/locales/de/settings/team-members.json b/worklenz-backend/src/public/locales/de/settings/team-members.json new file mode 100644 index 00000000..d223f08e --- /dev/null +++ b/worklenz-backend/src/public/locales/de/settings/team-members.json @@ -0,0 +1,47 @@ +{ + "title": "Teammitglieder", + "nameColumn": "Name", + "projectsColumn": "Projekte", + "emailColumn": "E-Mail", + "teamAccessColumn": "Team-Zugriff", + "memberCount": "Mitglied", + "membersCountPlural": "Mitglieder", + "searchPlaceholder": "Mitglieder nach Namen suchen", + "pinTooltip": "Mitgliederliste aktualisieren", + "addMemberButton": "Neues Mitglied hinzufügen", + "editTooltip": "Mitglied bearbeiten", + "deactivateTooltip": "Mitglied deaktivieren", + "activateTooltip": "Mitglied aktivieren", + "deleteTooltip": "Mitglied löschen", + "confirmDeleteTitle": "Sind Sie sicher, dass Sie dieses Mitglied löschen möchten?", + "confirmActivateTitle": "Sind Sie sicher, dass Sie den Status dieses Mitglieds ändern möchten?", + "okText": "Ja, fortfahren", + "cancelText": "Nein, abbrechen", + "deactivatedText": "(Aktuell deaktiviert)", + "pendingInvitationText": "(Einladung ausstehend)", + "addMemberDrawerTitle": "Neues Teammitglied hinzufügen", + "updateMemberDrawerTitle": "Teammitglied aktualisieren", + "addMemberEmailHint": "Mitglieder werden dem Team hinzugefügt, unabhängig vom Status der Einladungsannahme", + "memberEmailLabel": "E-Mail(s)", + "memberEmailPlaceholder": "E-Mail-Adresse des Teammitglieds eingeben", + "memberEmailRequiredError": "Bitte geben Sie eine gültige E-Mail-Adresse ein", + "jobTitleLabel": "Jobtitel", + "jobTitlePlaceholder": "Jobtitel auswählen oder suchen (optional)", + "memberAccessLabel": "Zugriffslevel", + "addToTeamButton": "Mitglied zum Team hinzufügen", + "updateButton": "Änderungen speichern", + "resendInvitationButton": "Einladungs-E-Mail erneut senden", + "invitationSentSuccessMessage": "Team-Einladung erfolgreich versendet!", + "createMemberSuccessMessage": "Neues Teammitglied erfolgreich hinzugefügt!", + "createMemberErrorMessage": "Hinzufügen des Teammitglieds fehlgeschlagen. Bitte versuchen Sie es erneut.", + "updateMemberSuccessMessage": "Teammitglied erfolgreich aktualisiert!", + "updateMemberErrorMessage": "Aktualisierung des Teammitglieds fehlgeschlagen. Bitte versuchen Sie es erneut.", + "memberText": "Mitglied", + "adminText": "Administrator", + "ownerText": "Team-Besitzer", + "addedText": "Hinzugefügt", + "updatedText": "Aktualisiert", + "noResultFound": "Geben Sie eine E-Mail-Adresse ein und drücken Sie Enter...", + "jobTitlesFetchError": "Fehler beim Abrufen der Jobtitel", + "invitationResent": "Einladung erfolgreich erneut gesendet!" +} diff --git a/worklenz-backend/src/public/locales/de/settings/teams.json b/worklenz-backend/src/public/locales/de/settings/teams.json new file mode 100644 index 00000000..bf39215d --- /dev/null +++ b/worklenz-backend/src/public/locales/de/settings/teams.json @@ -0,0 +1,16 @@ +{ + "title": "Teams", + "team": "Team", + "teams": "Teams", + "name": "Name", + "created": "Erstellt", + "ownsBy": "Gehört zu", + "edit": "Bearbeiten", + "editTeam": "Team bearbeiten", + "pinTooltip": "Klicken Sie hier, um dies im Hauptmenü zu fixieren", + "editTeamName": "Team-Name bearbeiten", + "updateName": "Name aktualisieren", + "namePlaceholder": "Name", + "nameRequired": "Bitte geben Sie einen Namen ein", + "updateFailed": "Änderung des Team-Namens fehlgeschlagen!" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/de/task-drawer/task-drawer-info-tab.json b/worklenz-backend/src/public/locales/de/task-drawer/task-drawer-info-tab.json new file mode 100644 index 00000000..aece79f0 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/task-drawer/task-drawer-info-tab.json @@ -0,0 +1,29 @@ +{ + "details": { + "task-key": "Aufgabenschlüssel", + "phase": "Phase", + "assignees": "Zugewiesene", + "due-date": "Fälligkeitsdatum", + "time-estimation": "Zeitschätzung", + "priority": "Priorität", + "labels": "Labels", + "billable": "Abrechenbar", + "notify": "Benachrichtigen", + "when-done-notify": "Bei Fertigstellung benachrichtigen", + "start-date": "Startdatum", + "end-date": "Enddatum", + "hide-start-date": "Startdatum ausblenden", + "show-start-date": "Startdatum anzeigen", + "hours": "Stunden", + "minutes": "Minuten" + }, + "description": { + "title": "Beschreibung", + "placeholder": "Fügen Sie eine detailliertere Beschreibung hinzu..." + }, + "subTasks": { + "title": "Unteraufgaben", + "add-sub-task": "+ Unteraufgabe hinzufügen", + "refresh-sub-tasks": "Unteraufgaben aktualisieren" + } +} diff --git a/worklenz-backend/src/public/locales/de/task-drawer/task-drawer.json b/worklenz-backend/src/public/locales/de/task-drawer/task-drawer.json new file mode 100644 index 00000000..62e3f881 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/task-drawer/task-drawer.json @@ -0,0 +1,123 @@ +{ + "taskHeader": { + "taskNamePlaceholder": "Geben Sie Ihre Aufgabe ein", + "deleteTask": "Aufgabe löschen" + }, + "taskInfoTab": { + "title": "Info", + "details": { + "title": "Details", + "task-key": "Aufgaben-Schlüssel", + "phase": "Phase", + "assignees": "Beauftragte", + "due-date": "Fälligkeitsdatum", + "time-estimation": "Zeitschätzung", + "priority": "Priorität", + "labels": "Labels", + "billable": "Abrechenbar", + "notify": "Benachrichtigen", + "when-done-notify": "Bei Abschluss benachrichtigen", + "start-date": "Startdatum", + "end-date": "Enddatum", + "hide-start-date": "Startdatum ausblenden", + "show-start-date": "Startdatum anzeigen", + "hours": "Stunden", + "minutes": "Minuten", + "progressValue": "Fortschrittswert", + "progressValueTooltip": "Fortschritt in Prozent einstellen (0-100%)", + "progressValueRequired": "Bitte geben Sie einen Fortschrittswert ein", + "progressValueRange": "Fortschritt muss zwischen 0 und 100 liegen", + "taskWeight": "Aufgabengewicht", + "taskWeightTooltip": "Gewicht dieser Teilaufgabe festlegen (Prozent)", + "taskWeightRequired": "Bitte geben Sie ein Aufgabengewicht ein", + "taskWeightRange": "Gewicht muss zwischen 0 und 100 liegen", + "recurring": "Wiederkehrend" + }, + "labels": { + "labelInputPlaceholder": "Suchen oder erstellen", + "labelsSelectorInputTip": "Enter drücken zum Erstellen" + }, + "description": { + "title": "Beschreibung", + "placeholder": "Detailliertere Beschreibung hinzufügen..." + }, + "subTasks": { + "title": "Teilaufgaben", + "addSubTask": "Teilaufgabe hinzufügen", + "addSubTaskInputPlaceholder": "Geben Sie Ihre Aufgabe ein und drücken Sie Enter", + "refreshSubTasks": "Teilaufgaben aktualisieren", + "edit": "Bearbeiten", + "delete": "Löschen", + "confirmDeleteSubTask": "Sind Sie sicher, dass Sie diese Teilaufgabe löschen möchten?", + "deleteSubTask": "Teilaufgabe löschen" + }, + "dependencies": { + "title": "Abhängigkeiten", + "addDependency": "+ Neue Abhängigkeit hinzufügen", + "blockedBy": "Blockiert von", + "searchTask": "Aufgabe suchen", + "noTasksFound": "Keine Aufgaben gefunden", + "confirmDeleteDependency": "Sind Sie sicher, dass Sie löschen möchten?" + }, + "attachments": { + "title": "Anhänge", + "chooseOrDropFileToUpload": "Datei zum Hochladen wählen oder ablegen", + "uploading": "Wird hochgeladen..." + }, + "comments": { + "title": "Kommentare", + "addComment": "+ Neuen Kommentar hinzufügen", + "noComments": "Noch keine Kommentare. Seien Sie der Erste!", + "delete": "Löschen", + "confirmDeleteComment": "Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?", + "addCommentPlaceholder": "Kommentar hinzufügen...", + "cancel": "Abbrechen", + "commentButton": "Kommentieren", + "attachFiles": "Dateien anhängen", + "addMoreFiles": "Weitere Dateien hinzufügen", + "selectedFiles": "Ausgewählte Dateien (Bis zu 25MB, Maximum {count})", + "maxFilesError": "Sie können maximal {count} Dateien hochladen", + "processFilesError": "Fehler beim Verarbeiten der Dateien", + "addCommentError": "Bitte fügen Sie einen Kommentar hinzu oder hängen Sie Dateien an", + "createdBy": "Erstellt {{time}} von {{user}}", + "updatedTime": "Aktualisiert {{time}}" + }, + "searchInputPlaceholder": "Nach Name suchen", + "pendingInvitation": "Ausstehende Einladung" + }, + "taskTimeLogTab": { + "title": "Zeiterfassung", + "addTimeLog": "Neuen Zeiteintrag hinzufügen", + "totalLogged": "Gesamt erfasst", + "exportToExcel": "Nach Excel exportieren", + "noTimeLogsFound": "Keine Zeiteinträge gefunden", + "timeLogForm": { + "date": "Datum", + "startTime": "Startzeit", + "endTime": "Endzeit", + "workDescription": "Arbeitsbeschreibung", + "descriptionPlaceholder": "Beschreibung hinzufügen", + "logTime": "Zeit erfassen", + "updateTime": "Zeit aktualisieren", + "cancel": "Abbrechen", + "selectDateError": "Bitte wählen Sie ein Datum", + "selectStartTimeError": "Bitte wählen Sie eine Startzeit", + "selectEndTimeError": "Bitte wählen Sie eine Endzeit", + "endTimeAfterStartError": "Endzeit muss nach der Startzeit liegen" + } + }, + "taskActivityLogTab": { + "title": "Aktivitätsprotokoll", + "add": "HINZUFÜGEN", + "remove": "ENTFERNEN", + "none": "Keine", + "weight": "Gewicht", + "createdTask": "hat die Aufgabe erstellt." + }, + "taskProgress": { + "markAsDoneTitle": "Aufgabe als erledigt markieren?", + "confirmMarkAsDone": "Ja, als erledigt markieren", + "cancelMarkAsDone": "Nein, aktuellen Status beibehalten", + "markAsDoneDescription": "Sie haben den Fortschritt auf 100% gesetzt. Möchten Sie den Aufgabenstatus auf \"Erledigt\" aktualisieren?" + } +} diff --git a/worklenz-backend/src/public/locales/de/task-list-filters.json b/worklenz-backend/src/public/locales/de/task-list-filters.json new file mode 100644 index 00000000..0854c34f --- /dev/null +++ b/worklenz-backend/src/public/locales/de/task-list-filters.json @@ -0,0 +1,85 @@ +{ + "searchButton": "Suchen", + "resetButton": "Zurücksetzen", + "searchInputPlaceholder": "Nach Namen suchen", + + "sortText": "Sortieren", + "statusText": "Status", + "phaseText": "Phase", + "memberText": "Mitglieder", + "assigneesText": "Zugewiesene", + "priorityText": "Priorität", + "labelsText": "Labels", + "membersText": "Mitglieder", + "groupByText": "Gruppieren nach", + "showArchivedText": "Archivierte anzeigen", + "showFieldsText": "Felder anzeigen", + "keyText": "Schlüssel", + "taskText": "Aufgabe", + "descriptionText": "Beschreibung", + "phasesText": "Phasen", + "listText": "Liste", + "progressText": "Fortschritt", + "timeTrackingText": "Zeiterfassung", + "timetrackingText": "Zeiterfassung", + "estimationText": "Schätzung", + "startDateText": "Startdatum", + "startdateText": "Startdatum", + "endDateText": "Enddatum", + "dueDateText": "Fälligkeitsdatum", + "duedateText": "Fälligkeitsdatum", + "completedDateText": "Abschlussdatum", + "completeddateText": "Abschlussdatum", + "createdDateText": "Erstellungsdatum", + "createddateText": "Erstellungsdatum", + "lastUpdatedText": "Zuletzt aktualisiert", + "lastupdatedText": "Zuletzt aktualisiert", + "reporterText": "Melder", + "dueTimeText": "Fällige Zeit", + "duetimeText": "Fällige Zeit", + + "lowText": "Niedrig", + "mediumText": "Mittel", + "highText": "Hoch", + + "createStatusButtonTooltip": "Status-Einstellungen", + "configPhaseButtonTooltip": "Phasen-Einstellungen", + "noLabelsFound": "Keine Labels gefunden", + + "addStatusButton": "Status hinzufügen", + "addPhaseButton": "Phase hinzufügen", + + "createStatus": "Status erstellen", + "name": "Name", + "category": "Kategorie", + "selectCategory": "Kategorie auswählen", + "pleaseEnterAName": "Bitte geben Sie einen Namen ein", + "pleaseSelectACategory": "Bitte wählen Sie eine Kategorie aus", + "create": "Erstellen", + + "searchTasks": "Aufgaben suchen...", + "searchPlaceholder": "Suchen...", + "fieldsText": "Felder", + "loadingFilters": "Filter werden geladen...", + "noOptionsFound": "Keine Optionen gefunden", + "filtersActive": "Filter aktiv", + "filterActive": "Filter aktiv", + "clearAll": "Alle löschen", + "clearing": "Wird gelöscht...", + "cancel": "Stornieren", + "search": "Suchen", + "groupedBy": "Gruppiert nach", + "manageStatuses": "Status verwalten", + "managePhases": "Phasen verwalten", + "dragToReorderStatuses": "Ziehen Sie Status, um sie neu zu ordnen. Jeder Status kann eine andere Kategorie haben.", + "enterNewStatusName": "Neuen Statusnamen eingeben...", + "addStatus": "Status hinzufügen", + "noStatusesFound": "Keine Status gefunden. Erstellen Sie Ihren ersten Status oben.", + "deleteStatus": "Status löschen", + "deleteStatusConfirm": "Sind Sie sicher, dass Sie diesen Status löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "rename": "Umbenennen", + "delete": "Löschen", + "enterStatusName": "Statusnamen eingeben", + "selectCategory": "Kategorie auswählen", + "close": "Schließen" +} diff --git a/worklenz-backend/src/public/locales/de/task-list-table.json b/worklenz-backend/src/public/locales/de/task-list-table.json new file mode 100644 index 00000000..9c2ff314 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/task-list-table.json @@ -0,0 +1,136 @@ +{ + "keyColumn": "Schlüssel", + "taskColumn": "Aufgabe", + "descriptionColumn": "Beschreibung", + "progressColumn": "Fortschritt", + "membersColumn": "Mitglieder", + "assigneesColumn": "Zugewiesene", + "labelsColumn": "Labels", + "phasesColumn": "Phasen", + "phaseColumn": "Phase", + "statusColumn": "Status", + "priorityColumn": "Priorität", + "timeTrackingColumn": "Zeiterfassung", + "timetrackingColumn": "Zeiterfassung", + "estimationColumn": "Schätzung", + "startDateColumn": "Startdatum", + "startdateColumn": "Startdatum", + "dueDateColumn": "Fälligkeitsdatum", + "duedateColumn": "Fälligkeitsdatum", + "completedDateColumn": "Abschlussdatum", + "completeddateColumn": "Abschlussdatum", + "createdDateColumn": "Erstellungsdatum", + "createddateColumn": "Erstellungsdatum", + "lastUpdatedColumn": "Zuletzt aktualisiert", + "lastupdatedColumn": "Zuletzt aktualisiert", + "reporterColumn": "Melder", + "dueTimeColumn": "Fällige Zeit", + "todoSelectorText": "Zu erledigen", + "doingSelectorText": "Tun", + "doneSelectorText": "Erledigt", + + "lowSelectorText": "Niedrig", + "mediumSelectorText": "Mittel", + "highSelectorText": "Hoch", + + "selectText": "Auswählen", + "labelsSelectorInputTip": "Enter drücken zum Erstellen!", + + "addTaskText": "Aufgabe hinzufügen", + "addSubTaskText": "+ Unteraufgabe hinzufügen", + "addTaskInputPlaceholder": "Aufgabe eingeben und Enter drücken", + "noTasksInGroup": "Keine Aufgaben in dieser Gruppe", + + "openButton": "Öffnen", + "okButton": "OK", + + "noLabelsFound": "Keine Labels gefunden", + "searchInputPlaceholder": "Suchen oder erstellen", + "assigneeSelectorInviteButton": "Neues Mitglied per E-Mail einladen", + "labelInputPlaceholder": "Suchen oder erstellen", + "searchLabelsPlaceholder": "Labels suchen...", + "createLabelButton": "\"{{name}}\" erstellen", + "manageLabelsPath": "Einstellungen → Labels", + + "pendingInvitation": "Einladung ausstehend", + + "contextMenu": { + "assignToMe": "Mir zuweisen", + "moveTo": "Verschieben nach", + "unarchive": "Dearchivieren", + "archive": "Archivieren", + "convertToSubTask": "In Unteraufgabe umwandeln", + "convertToTask": "In Aufgabe umwandeln", + "delete": "Löschen", + "searchByNameInputPlaceholder": "Nach Namen suchen" + }, + "setDueDate": "Fälligkeitsdatum festlegen", + "setStartDate": "Startdatum festlegen", + "clearDueDate": "Fälligkeitsdatum löschen", + "clearStartDate": "Startdatum löschen", + "dueDatePlaceholder": "Fälligkeitsdatum", + "startDatePlaceholder": "Startdatum", + + "emptyStates": { + "noTaskGroups": "Keine Aufgabengruppen gefunden", + "noTaskGroupsDescription": "Aufgaben werden hier angezeigt, wenn sie erstellt oder Filter angewendet werden.", + "errorPrefix": "Fehler:", + "dragTaskFallback": "Aufgabe" + }, + + "customColumns": { + "addCustomColumn": "Benutzerdefinierte Spalte hinzufügen", + "customColumnHeader": "Benutzerdefinierte Spalte", + "customColumnSettings": "Einstellungen für benutzerdefinierte Spalte", + "noCustomValue": "Kein Wert", + "peopleField": "Personenfeld", + "noDate": "Kein Datum", + "unsupportedField": "Nicht unterstützter Feldtyp", + + "modal": { + "addFieldTitle": "Feld hinzufügen", + "editFieldTitle": "Feld bearbeiten", + "fieldTitle": "Feldtitel", + "fieldTitleRequired": "Feldtitel ist erforderlich", + "columnTitlePlaceholder": "Spaltentitel", + "type": "Typ", + "deleteConfirmTitle": "Sind Sie sicher, dass Sie diese benutzerdefinierte Spalte löschen möchten?", + "deleteConfirmDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Alle mit dieser Spalte verbundenen Daten werden dauerhaft gelöscht.", + "deleteButton": "Löschen", + "cancelButton": "Abbrechen", + "createButton": "Erstellen", + "updateButton": "Aktualisieren", + "createSuccessMessage": "Benutzerdefinierte Spalte erfolgreich erstellt", + "updateSuccessMessage": "Benutzerdefinierte Spalte erfolgreich aktualisiert", + "deleteSuccessMessage": "Benutzerdefinierte Spalte erfolgreich gelöscht", + "deleteErrorMessage": "Fehler beim Löschen der benutzerdefinierten Spalte", + "createErrorMessage": "Fehler beim Erstellen der benutzerdefinierten Spalte", + "updateErrorMessage": "Fehler beim Aktualisieren der benutzerdefinierten Spalte" + }, + + "fieldTypes": { + "people": "Personen", + "number": "Zahl", + "date": "Datum", + "selection": "Auswahl", + "checkbox": "Kontrollkästchen", + "labels": "Etiketten", + "key": "Schlüssel", + "formula": "Formel" + } + }, + + "indicators": { + "tooltips": { + "subtasks": "{{count}} Unteraufgabe", + "subtasks_plural": "{{count}} Unteraufgaben", + "comments": "{{count}} Kommentar", + "comments_plural": "{{count}} Kommentare", + "attachments": "{{count}} Anhang", + "attachments_plural": "{{count}} Anhänge", + "subscribers": "Aufgabe hat Abonnenten", + "dependencies": "Aufgabe hat Abhängigkeiten", + "recurring": "Wiederkehrende Aufgabe" + } + } +} diff --git a/worklenz-backend/src/public/locales/de/task-management.json b/worklenz-backend/src/public/locales/de/task-management.json new file mode 100644 index 00000000..b20d94a4 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/task-management.json @@ -0,0 +1,21 @@ +{ + "noTasksInGroup": "Keine Aufgaben in dieser Gruppe", + "noTasksInGroupDescription": "Fügen Sie eine Aufgabe hinzu, um zu beginnen", + "addFirstTask": "Fügen Sie Ihre erste Aufgabe hinzu", + "openTask": "Öffnen", + "subtask": "Unteraufgabe", + "subtasks": "Unteraufgaben", + "comment": "Kommentar", + "comments": "Kommentare", + "attachment": "Anhang", + "attachments": "Anhänge", + "enterSubtaskName": "Unteraufgabenname eingeben...", + "add": "Hinzufügen", + "cancel": "Abbrechen", + "renameGroup": "Gruppe umbenennen", + "renameStatus": "Status umbenennen", + "renamePhase": "Phase umbenennen", + "changeCategory": "Kategorie ändern", + "clickToEditGroupName": "Klicken Sie, um den Gruppennamen zu bearbeiten", + "enterGroupName": "Gruppennamen eingeben" +} diff --git a/worklenz-backend/src/public/locales/de/task-template-drawer.json b/worklenz-backend/src/public/locales/de/task-template-drawer.json new file mode 100644 index 00000000..21dbe369 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/task-template-drawer.json @@ -0,0 +1,11 @@ +{ + "createTaskTemplate": "Aufgabenvorlage erstellen", + "editTaskTemplate": "Aufgabenvorlage bearbeiten", + "cancelText": "Abbrechen", + "saveText": "Speichern", + "templateNameText": "Vorlagenname", + "selectedTasks": "Ausgewählte Aufgaben", + "removeTask": "Entfernen", + "cancelButton": "Abbrechen", + "saveButton": "Speichern" +} diff --git a/worklenz-backend/src/public/locales/de/tasks/task-table-bulk-actions.json b/worklenz-backend/src/public/locales/de/tasks/task-table-bulk-actions.json new file mode 100644 index 00000000..e8b039f2 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/tasks/task-table-bulk-actions.json @@ -0,0 +1,41 @@ +{ + "taskSelected": "Aufgabe ausgewählt", + "tasksSelected": "Aufgaben ausgewählt", + "changeStatus": "Status/Priorität/Phase ändern", + "changeLabel": "Label ändern", + "assignToMe": "Mir zuweisen", + "changeAssignees": "Zuständige ändern", + "archive": "Archivieren", + "unarchive": "Dearchivieren", + "delete": "Löschen", + "moreOptions": "Weitere Optionen", + "deselectAll": "Alle abwählen", + "status": "Status", + "priority": "Priorität", + "phase": "Phase", + "member": "Mitglied", + "createTaskTemplate": "Aufgabenvorlage erstellen", + "apply": "Anwenden", + "createLabel": "+ Label erstellen", + "searchOrCreateLabel": "Label suchen oder erstellen...", + "hitEnterToCreate": "Enter drücken zum Erstellen", + "labelExists": "Label existiert bereits", + "pendingInvitation": "Einladung ausstehend", + "noMatchingLabels": "Keine passenden Labels", + "noLabels": "Keine Labels", + "CHANGE_STATUS": "Status ändern", + "CHANGE_PRIORITY": "Priorität ändern", + "CHANGE_PHASE": "Phase ändern", + "ADD_LABELS": "Labels hinzufügen", + "ASSIGN_TO_ME": "Mir zuweisen", + "ASSIGN_MEMBERS": "Mitglieder zuweisen", + "ARCHIVE": "Archivieren", + "DELETE": "Löschen", + "CANCEL": "Abbrechen", + "CLEAR_SELECTION": "Auswahl löschen", + "TASKS_SELECTED": "{{count}} Aufgabe ausgewählt", + "TASKS_SELECTED_plural": "{{count}} Aufgaben ausgewählt", + "DELETE_TASKS_CONFIRM": "{{count}} Aufgabe löschen?", + "DELETE_TASKS_CONFIRM_plural": "{{count}} Aufgaben löschen?", + "DELETE_TASKS_WARNING": "Diese Aktion kann nicht rückgängig gemacht werden." +} diff --git a/worklenz-backend/src/public/locales/de/template-drawer.json b/worklenz-backend/src/public/locales/de/template-drawer.json new file mode 100644 index 00000000..8655504d --- /dev/null +++ b/worklenz-backend/src/public/locales/de/template-drawer.json @@ -0,0 +1,19 @@ +{ + "title": "Aufgabenvorlage bearbeiten", + "cancelText": "Abbrechen", + "saveText": "Speichern", + "templateNameText": "Vorlagenname", + "selectedTasks": "Ausgewählte Aufgaben", + "removeTask": "Entfernen", + "description": "Beschreibung", + "phase": "Phase", + "statuses": "Status", + "priorities": "Prioritäten", + "labels": "Labels", + "tasks": "Aufgaben", + "noTemplateSelected": "Keine Vorlage ausgewählt", + "noDescription": "Keine Beschreibung", + "worklenzTemplates": "Worklenz-Vorlagen", + "yourTemplatesLibrary": "Ihre Bibliothek", + "searchTemplates": "Vorlagen durchsuchen" +} diff --git a/worklenz-backend/src/public/locales/de/templateDrawer.json b/worklenz-backend/src/public/locales/de/templateDrawer.json new file mode 100644 index 00000000..571ed15f --- /dev/null +++ b/worklenz-backend/src/public/locales/de/templateDrawer.json @@ -0,0 +1,23 @@ +{ + "bugTracking": "Fehlerverfolgung", + "construction": "Bauwesen", + "designCreative": "Design & Kreatives", + "education": "Bildung", + "finance": "Finanzen", + "hrRecruiting": "Personalwesen & Recruiting", + "informationTechnology": "Informationstechnologie", + "legal": "Rechtliches", + "manufacturing": "Produktion", + "marketing": "Marketing", + "nonprofit": "Gemeinnützig", + "personalUse": "Persönliche Nutzung", + "salesCRM": "Vertrieb & CRM", + "serviceConsulting": "Dienstleistungen & Beratung", + "softwareDevelopment": "Softwareentwicklung", + "description": "Beschreibung", + "phase": "Phase", + "statuses": "Status", + "priorities": "Prioritäten", + "labels": "Labels", + "tasks": "Aufgaben" +} diff --git a/worklenz-backend/src/public/locales/de/time-report.json b/worklenz-backend/src/public/locales/de/time-report.json new file mode 100644 index 00000000..efadbb8a --- /dev/null +++ b/worklenz-backend/src/public/locales/de/time-report.json @@ -0,0 +1,44 @@ +{ + "includeArchivedProjects": "Archivierte Projekte einschließen", + "export": "Exportieren", + "timeSheet": "Stundenzettel", + + "searchByName": "Nach Namen suchen", + "selectAll": "Alle auswählen", + "teams": "Teams", + + "searchByProject": "Nach Projektnamen suchen", + "projects": "Projekte", + + "searchByCategory": "Nach Kategorienamen suchen", + "categories": "Kategorien", + + "billable": "Abrechenbar", + "nonBillable": "Nicht abrechenbar", + + "total": "Gesamt", + + "projectsTimeSheet": "Projekt-Zeiterfassung", + + "loggedTime": "Erfasste Zeit (Stunden)", + + "exportToExcel": "Nach Excel exportieren", + "logged": "erfasst", + "for": "für", + + "membersTimeSheet": "Mitglieder-Zeiterfassung", + "member": "Mitglied", + + "estimatedVsActual": "Geschätzt vs. Tatsächlich", + "workingDays": "Arbeitstage", + "manDays": "Manntage", + "days": "Tage", + "estimatedDays": "Geschätzt Tage", + "actualDays": "Tatsächliche Tage", + + "noCategories": "Keine Kategorien gefunden", + "noCategory": "Keine Kategorie", + "noProjects": "Keine Projekte gefunden", + "noTeams": "Keine Teams gefunden", + "noData": "Keine Daten gefunden" +} diff --git a/worklenz-backend/src/public/locales/de/unauthorized.json b/worklenz-backend/src/public/locales/de/unauthorized.json new file mode 100644 index 00000000..4384ad80 --- /dev/null +++ b/worklenz-backend/src/public/locales/de/unauthorized.json @@ -0,0 +1,5 @@ +{ + "title": "Unbefugt!", + "subtitle": "Sie sind nicht berechtigt, auf diese Seite zuzugreifen", + "button": "Zur Startseite" +} diff --git a/worklenz-backend/src/public/locales/en/404-page.json b/worklenz-backend/src/public/locales/en/404-page.json new file mode 100644 index 00000000..a93627f1 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/404-page.json @@ -0,0 +1,4 @@ +{ + "doesNotExistText": "Sorry, the page you visited does not exist.", + "backHomeButton": "Back Home" +} diff --git a/worklenz-backend/src/public/locales/en/account-setup.json b/worklenz-backend/src/public/locales/en/account-setup.json new file mode 100644 index 00000000..5e71ca40 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/account-setup.json @@ -0,0 +1,31 @@ +{ + "continue": "Continue", + + "setupYourAccount": "Setup Your Worklenz Account.", + "organizationStepTitle": "Name Your Organization", + "organizationStepLabel": "Pick a name for your Worklenz account.", + + "projectStepTitle": "Create your first project", + "projectStepLabel": "What project are you working on right now?", + "projectStepPlaceholder": "e.g. Marketing Plan", + + "tasksStepTitle": "Create your first tasks", + "tasksStepLabel": "Type a few tasks that you are going to do in", + "tasksStepAddAnother": "Add another", + + "emailPlaceholder": "Email address", + "invalidEmail": "Please enter a valid email address", + "or": "or", + "templateButton": "Import from template", + "goBack": "Go Back", + "cancel": "Cancel", + "create": "Create", + "templateDrawerTitle": "Select from templates", + "step3InputLabel": "Invite with email", + "addAnother": "Add another", + "skipForNow": "Skip for now", + "formTitle": "Create your first task.", + "step3Title": "Invite your team to work with", + "maxMembers": " (You can invite up to 5 members)", + "maxTasks": " (You can create up to 5 tasks)" +} diff --git a/worklenz-backend/src/public/locales/en/admin-center/current-bill.json b/worklenz-backend/src/public/locales/en/admin-center/current-bill.json new file mode 100644 index 00000000..fe840789 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/admin-center/current-bill.json @@ -0,0 +1,121 @@ +{ + "title": "Billings", + "currentBill": "Current Bill", + "configuration": "Configuration", + "currentPlanDetails": "Current Plan Details", + "upgradePlan": "Upgrade Plan", + "cardBodyText01": "Free trial", + "cardBodyText02": "(Your trial plan expires in 1 month 19 days)", + "redeemCode": "Redeem Code", + "accountStorage": "Account Storage", + "used": "Used:", + "remaining": "Remaining:", + "charges": "Charges", + "tooltip": "Charges for the current billing cycle", + "description": "Description", + "billingPeriod": "Billing Period", + "billStatus": "Bill Status", + "perUserValue": "Per User Value", + "users": "Users", + + "amount": "Amount", + "invoices": "Invoices", + "transactionId": "Transaction ID", + "transactionDate": "Transaction Date", + "paymentMethod": "Payment Method", + "status": "Status", + "ltdUsers": "You can add up to {{ltd_users}} users.", + + "totalSeats": "Total seats", + "availableSeats": "Available seats", + "addMoreSeats": "Add more seats", + + "drawerTitle": "Redeem Code", + "label": "Redeem Code", + "drawerPlaceholder": "Enter your redeem code", + "redeemSubmit": "Submit", + + "modalTitle": "Select the best plan for your team", + "seatLabel": "No of seats", + "freePlan": "Free Plan", + "startup": "Startup", + "business": "Business", + "tag": "Most Popular", + "enterprise": "Enterprise", + + "freeSubtitle": "free forever", + "freeUsers": "Best for personal use", + "freeText01": "100MB storage", + "freeText02": "3 projects", + "freeText03": "5 team members", + + "startupSubtitle": "FLAT RATE / month", + "startupUsers": "Upto 15 users", + "startupText01": "25GB storage", + "startupText02": "Unlimited active projects", + "startupText03": "Schedule", + "startupText04": "Reporting", + "startupText05": "Subscribe to projects", + + "businessSubtitle": "user / month", + "businessUsers": "16 - 200 users", + + "enterpriseUsers": "200 - 500+ users", + + "footerTitle": "Please provide us with a contact number we can use to reach you.", + "footerLabel": "Contact Number", + "footerButton": "Contact us", + + "redeemCodePlaceHolder": "Enter your redeem code", + "submit": "Submit", + + "trialPlan": "Free Trial", + "trialExpireDate": "Valid until {{trial_expire_date}}", + "trialExpired": "Your free trial expired {{trial_expire_string}}", + "trialInProgress": "Your free trial expires {{trial_expire_string}}", + + "required": "This field is required", + "invalidCode": "Invalid code", + + "selectPlan": "Select the best plan for your team", + "changeSubscriptionPlan": "Change your subscription plan", + "noOfSeats": "Number of seats", + "annualPlan": "Pro - Annual", + "monthlyPlan": "Pro - Monthly", + "freeForever": "Free Forever", + "bestForPersonalUse": "Best for personal use", + "storage": "Storage", + "projects": "Projects", + "teamMembers": "Team Members", + "unlimitedTeamMembers": "Unlimited Team Members", + "unlimitedActiveProjects": "Unlimited active projects", + "schedule": "Schedule", + "reporting": "Reporting", + "subscribeToProjects": "Subscribe to projects", + "billedAnnually": "Billed Annually", + "billedMonthly": "Billed Monthly", + + "pausePlan": "Pause Plan", + "resumePlan": "Resume Plan", + "changePlan": "Change Plan", + "cancelPlan": "Cancel Plan", + + "perMonthPerUser": "per user/month", + "viewInvoice": "View Invoice", + "switchToFreePlan": "Switch to Free Plan", + + "expirestoday": "today", + "expirestomorrow": "tomorrow", + "expiredDaysAgo": "{{days}} days ago", + + "continueWith": "Continue with {{plan}}", + "changeToPlan": "Change to {{plan}}", + "creditPlan": "Credit Plan", + "customPlan": "Custom Plan", + "planValidTill": "Your plan is valid till {{date}}", + "purchaseSeatsText": "To continue, you'll need to purchase additional seats.", + "currentSeatsText": "You currently have {{seats}} seats available.", + "selectSeatsText": "Please select the number of additional seats to purchase.", + "purchase": "Purchase", + "contactSales": "Contact sales" +} diff --git a/worklenz-backend/src/public/locales/en/admin-center/overview.json b/worklenz-backend/src/public/locales/en/admin-center/overview.json new file mode 100644 index 00000000..efc42855 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/admin-center/overview.json @@ -0,0 +1,8 @@ +{ + "overview": "Overview", + "name": "Organization Name", + "owner": "Organization Owner", + "admins": "Organization Admins", + "contactNumber": "Add Contact Number", + "edit": "Edit" +} diff --git a/worklenz-backend/src/public/locales/en/admin-center/projects.json b/worklenz-backend/src/public/locales/en/admin-center/projects.json new file mode 100644 index 00000000..4e491d73 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/admin-center/projects.json @@ -0,0 +1,12 @@ +{ + "membersCount": "Members Count", + "createdAt": "Created at", + "projectName": "Project Name", + "teamName": "Team Name", + "refreshProjects": "Refresh projects", + "searchPlaceholder": "Search by project name", + "deleteProject": "Are you sure you want to delete this project?", + "confirm": "Confirm", + "cancel": "Cancel", + "delete": "Delete Project" +} diff --git a/worklenz-backend/src/public/locales/en/admin-center/sidebar.json b/worklenz-backend/src/public/locales/en/admin-center/sidebar.json new file mode 100644 index 00000000..3b03d499 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/admin-center/sidebar.json @@ -0,0 +1,8 @@ +{ + "overview": "Overview", + "users": "Users", + "teams": "Teams", + "billing": "Billing", + "projects": "Projects", + "adminCenter": "Admin Center" +} diff --git a/worklenz-backend/src/public/locales/en/admin-center/teams.json b/worklenz-backend/src/public/locales/en/admin-center/teams.json new file mode 100644 index 00000000..bf829a87 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/admin-center/teams.json @@ -0,0 +1,35 @@ +{ + "title": "Teams", + "subtitle": "teams", + "tooltip": "Refresh teams", + "placeholder": "Search by name", + "addTeam": "Add Team", + "team": "Team", + "membersCount": "Members Count", + "members": "Members", + "drawerTitle": "Create New Team", + "label": "Team Name", + "drawerPlaceholder": "Name", + "create": "Create", + "delete": "Delete", + "settings": "Settings", + "popTitle": "Are you sure?", + "message": "Please enter a Name", + "teamSettings": "Team Settings", + "teamName": "Team Name", + "teamDescription": "Team Description", + "teamMembers": "Team Members", + "teamMembersCount": "Team Members Count", + "teamMembersPlaceholder": "Search by name", + "addMember": "Add Member", + "add": "Add", + "update": "Update", + "teamNamePlaceholder": "Name of the team", + "user": "User", + "role": "Role", + "owner": "Owner", + "admin": "Admin", + "member": "Member", + "cannotChangeOwnerRole": "Owner role cannot be changed", + "pendingInvitation": "Pending invitation" +} diff --git a/worklenz-backend/src/public/locales/en/admin-center/users.json b/worklenz-backend/src/public/locales/en/admin-center/users.json new file mode 100644 index 00000000..7e462ef6 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/admin-center/users.json @@ -0,0 +1,9 @@ +{ + "title": "Users", + "subTitle": "users", + "placeholder": "Search by name", + "user": "User", + "email": "Email", + "lastActivity": "Last Activity", + "refresh": "Refresh users" +} diff --git a/worklenz-backend/src/public/locales/en/all-project-list.json b/worklenz-backend/src/public/locales/en/all-project-list.json new file mode 100644 index 00000000..ab98cb6b --- /dev/null +++ b/worklenz-backend/src/public/locales/en/all-project-list.json @@ -0,0 +1,34 @@ +{ + "name": "Name", + "client": "Client", + "category": "Category", + "status": "Status", + "tasksProgress": "Tasks Progress", + "updated_at": "Last Updated", + "members": "Members", + "setting": "Settings", + "projects": "Projects", + "refreshProjects": "Refresh projects", + "all": "All", + "favorites": "Favorites", + "archived": "Archived", + "placeholder": "Search by name", + "archive": "Archive", + "unarchive": "Unarchive", + "archiveConfirm": "Are you sure you want to archive this project?", + "unarchiveConfirm": "Are you sure you want to unarchive this project?", + "yes": "Yes", + "no": "No", + "clickToFilter": "Click to filter by", + "noProjects": "No projects found", + "addToFavourites": "Add to favourites", + "list": "List", + "group": "Group", + "listView": "List View", + "groupView": "Group View", + "groupBy": { + "category": "Category", + "client": "Client" + }, + "noPermission": "You don't have permission to perform this action" +} diff --git a/worklenz-backend/src/public/locales/en/auth/auth-common.json b/worklenz-backend/src/public/locales/en/auth/auth-common.json new file mode 100644 index 00000000..94803a12 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/auth/auth-common.json @@ -0,0 +1,5 @@ +{ + "loggingOut": "Logging out...", + "authenticating": "Authenticating...", + "gettingThingsReady": "Getting things ready for you..." +} diff --git a/worklenz-backend/src/public/locales/en/auth/forgot-password.json b/worklenz-backend/src/public/locales/en/auth/forgot-password.json new file mode 100644 index 00000000..3534c388 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/auth/forgot-password.json @@ -0,0 +1,12 @@ +{ + "headerDescription": "Reset your password", + "emailLabel": "Email", + "emailPlaceholder": "Enter your email", + "emailRequired": "Please enter your Email!", + "resetPasswordButton": "Reset Password", + "returnToLoginButton": "Return to Login", + "passwordResetSuccessMessage": "A password reset link has been sent to your email.", + "orText": "OR", + "successTitle": "Reset instruction sent!", + "successMessage": "Reset information has been sent to your email. Please check your email." +} diff --git a/worklenz-backend/src/public/locales/en/auth/login.json b/worklenz-backend/src/public/locales/en/auth/login.json new file mode 100644 index 00000000..b77e7fba --- /dev/null +++ b/worklenz-backend/src/public/locales/en/auth/login.json @@ -0,0 +1,27 @@ +{ + "headerDescription": "Login to your account", + "emailLabel": "Email", + "emailPlaceholder": "Enter your email", + "emailRequired": "Please enter your Email!", + "passwordLabel": "Password", + "passwordPlaceholder": "Enter your password", + "passwordRequired": "Please enter your Password!", + "rememberMe": "Remember me", + "loginButton": "Log in", + "signupButton": "Sign up", + "forgotPasswordButton": "Forgot password?", + "signInWithGoogleButton": "Sign in with Google", + "dontHaveAccountText": "Don’t have an account?", + "orText": "OR", + "successMessage": "You have successfully logged in!", + "loginError": "Login failed", + "googleLoginError": "Google login failed", + "validationMessages": { + "email": "Please enter a valid email address", + "password": "Password must be at least 8 characters long" + }, + "errorMessages": { + "loginErrorTitle": "Login failed", + "loginErrorMessage": "Please check your email and password and try again" + } +} diff --git a/worklenz-backend/src/public/locales/en/auth/signup.json b/worklenz-backend/src/public/locales/en/auth/signup.json new file mode 100644 index 00000000..af4611ba --- /dev/null +++ b/worklenz-backend/src/public/locales/en/auth/signup.json @@ -0,0 +1,29 @@ +{ + "headerDescription": "Sign up to get started", + "nameLabel": "Full Name", + "namePlaceholder": "Enter your full name", + "nameRequired": "Please enter your full name!", + "nameMinCharacterRequired": "Full name must be at least 4 characters!", + "emailLabel": "Email", + "emailPlaceholder": "Enter your email", + "emailRequired": "Please enter your Email!", + "passwordLabel": "Password", + "passwordPlaceholder": "Enter your password", + "passwordRequired": "Please enter your Password!", + "passwordMinCharacterRequired": "Password must be at least 8 characters!", + "passwordPatternRequired": "Password does not meet the requirements!", + "strongPasswordPlaceholder": "Enter a stronger password", + "passwordValidationAltText": "Password must include at least 8 characters with upper and lower case letters, a number, and a symbol.", + "signupSuccessMessage": "You have successfully signed up!", + "privacyPolicyLink": "Privacy Policy", + "termsOfUseLink": "Terms of Use", + "bySigningUpText": "By signing up, you agree to our", + "andText": "and", + "signupButton": "Sign up", + "signInWithGoogleButton": "Sign in with Google", + "alreadyHaveAccountText": "Already have an account?", + "loginButton": "Login", + "orText": "OR", + "reCAPTCHAVerificationError": "reCAPTCHA Verification Error", + "reCAPTCHAVerificationErrorMessage": "We were unable to verify your reCAPTCHA. Please try again." +} diff --git a/worklenz-backend/src/public/locales/en/auth/verify-reset-email.json b/worklenz-backend/src/public/locales/en/auth/verify-reset-email.json new file mode 100644 index 00000000..e685e193 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/auth/verify-reset-email.json @@ -0,0 +1,14 @@ +{ + "title": "Verify Reset Email", + "description": "Enter your new password", + "placeholder": "Enter your new password", + "confirmPasswordPlaceholder": "Confirm your new password", + "passwordHint": "Minimum of 8 characters, with upper and lowercase and a number and a symbol.", + "resetPasswordButton": "Reset password", + "orText": "Or", + "resendResetEmail": "Resend reset email", + "passwordRequired": "Please enter your new password", + "returnToLoginButton": "Return to Login", + "confirmPasswordRequired": "Please confirm your new password", + "passwordMismatch": "The two passwords do not match" +} diff --git a/worklenz-backend/src/public/locales/en/common.json b/worklenz-backend/src/public/locales/en/common.json new file mode 100644 index 00000000..815560be --- /dev/null +++ b/worklenz-backend/src/public/locales/en/common.json @@ -0,0 +1,9 @@ +{ + "login-success": "Login successful!", + "login-failed": "Login failed. Please check your credentials and try again.", + "signup-success": "Signup successful! Welcome aboard.", + "signup-failed": "Signup failed. Please ensure all required fields are filled and try again.", + "reconnecting": "Disconnected from server.", + "connection-lost": "Failed to connect to server. Please check your internet connection.", + "connection-restored": "Connected to server successfully" +} diff --git a/worklenz-backend/src/public/locales/en/create-first-project-form.json b/worklenz-backend/src/public/locales/en/create-first-project-form.json new file mode 100644 index 00000000..337f4a10 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/create-first-project-form.json @@ -0,0 +1,13 @@ +{ + "formTitle": "Create your first project", + "inputLabel": "What project are you working on right now?", + "or": "or", + "templateButton": "Import from template", + "createFromTemplate": "Create from template", + "goBack": "Go Back", + "continue": "Continue", + "cancel": "Cancel", + "create": "Create", + "templateDrawerTitle": "Select from templates", + "createProject": "Create Project" +} diff --git a/worklenz-backend/src/public/locales/en/create-first-tasks.json b/worklenz-backend/src/public/locales/en/create-first-tasks.json new file mode 100644 index 00000000..1447f355 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/create-first-tasks.json @@ -0,0 +1,7 @@ +{ + "formTitle": "Create your first task.", + "inputLable": "Type a few tasks that you are going to do in", + "addAnother": "Add another", + "goBack": "Go back", + "continue": "Continue" +} diff --git a/worklenz-backend/src/public/locales/en/home.json b/worklenz-backend/src/public/locales/en/home.json new file mode 100644 index 00000000..ccf40936 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/home.json @@ -0,0 +1,46 @@ +{ + "todoList": { + "title": "To do list", + "refreshTasks": "Refresh tasks", + "addTask": "+ Add Task", + "noTasks": "No tasks", + "pressEnter": "Press", + "toCreate": "to create.", + "markAsDone": "Mark as done" + }, + "projects": { + "title": "Projects", + "refreshProjects": "Refresh projects", + "noRecentProjects": "You are currently not assigned to any project.", + "noFavouriteProjects": "No projects have been marked as favorites.", + "recent": "Recent", + "favourites": "Favourites" + }, + "tasks": { + "assignedToMe": "Assigned to me", + "assignedByMe": "Assigned by me", + "all": "All", + "today": "Today", + "upcoming": "Upcoming", + "overdue": "Overdue", + "noDueDate": "No due date", + "noTasks": "No tasks to show.", + "addTask": "+ Add task", + "name": "Name", + "project": "Project", + "status": "Status", + "dueDate": "Due Date", + "dueDatePlaceholder": "Set Due Date", + "tomorrow": "Tomorrow", + "nextWeek": "Next Week", + "nextMonth": "Next Month", + "projectRequired": "Please select a project", + "pressTabToSelectDueDateAndProject": "Press Tab to select a due date and a project", + "dueOn": "Tasks due on", + "taskRequired": "Please add a task", + "list": "List", + "calendar": "Calendar", + "tasks": "Tasks", + "refresh": "Refresh" + } +} diff --git a/worklenz-backend/src/public/locales/en/invite-initial-team-members.json b/worklenz-backend/src/public/locales/en/invite-initial-team-members.json new file mode 100644 index 00000000..09f23e87 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/invite-initial-team-members.json @@ -0,0 +1,8 @@ +{ + "formTitle": "Invite your team to work with", + "inputLable": "Invite with email", + "addAnother": "Add another", + "goBack": "Go back", + "continue": "Continue", + "skipForNow": "Skip for now" +} diff --git a/worklenz-backend/src/public/locales/en/kanban-board.json b/worklenz-backend/src/public/locales/en/kanban-board.json new file mode 100644 index 00000000..e295a6c6 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/kanban-board.json @@ -0,0 +1,33 @@ +{ + "rename": "Rename", + "delete": "Delete", + "addTask": "Add Task", + "addSectionButton": "Add Section", + "changeCategory": "Change category", + + "deleteTooltip": "Delete", + "deleteConfirmationTitle": "Are you sure?", + "deleteConfirmationOk": "Yes", + "deleteConfirmationCancel": "Cancel", + + "dueDate": "Due date", + "cancel": "Cancel", + + "today": "Today", + "tomorrow": "Tomorrow", + "assignToMe": "Assign to me", + "archive": "Archive", + + "newTaskNamePlaceholder": "Write a task Name", + "newSubtaskNamePlaceholder": "Write a subtask Name", + "untitledSection": "Untitled section", + "unmapped": "Unmapped", + "clickToChangeDate": "Click to change date", + "noDueDate": "No due date", + "save": "Save", + "clear": "Clear", + "nextWeek": "Next week", + "noSubtasks": "No subtasks", + "showSubtasks": "Show subtasks", + "hideSubtasks": "Hide subtasks" +} diff --git a/worklenz-backend/src/public/locales/en/license-expired.json b/worklenz-backend/src/public/locales/en/license-expired.json new file mode 100644 index 00000000..e4556064 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/license-expired.json @@ -0,0 +1,6 @@ +{ + "title": "Your Worklenz trial has expired!", + "subtitle": "Please upgrade now.", + "button": "Upgrade now", + "checking": "Checking subscription status..." +} diff --git a/worklenz-backend/src/public/locales/en/navbar.json b/worklenz-backend/src/public/locales/en/navbar.json new file mode 100644 index 00000000..e7e22cb3 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/navbar.json @@ -0,0 +1,31 @@ +{ + "logoAlt": "Worklenz Logo", + "home": "Home", + "projects": "Projects", + "schedule": "Schedule", + "reporting": "Reporting", + "clients": "Clients", + "teams": "Teams", + "labels": "Labels", + "jobTitles": "Job Titles", + "upgradePlan": "Upgrade Plan", + "upgradePlanTooltip": "Upgrade Plan", + "invite": "Invite", + "inviteTooltip": "Invite team members to join", + "switchTeamTooltip": "Switch team", + "help": "Help", + "notificationTooltip": "View notifications", + "profileTooltip": "View profile", + "adminCenter": "Admin Center", + "settings": "Settings", + "logOut": "Log Out", + "notificationsDrawer": { + "read": "Read notifications", + "unread": "Unread notifications", + "markAsRead": "Mark as read", + "readAndJoin": "Read & Join", + "accept": "Accept", + "acceptAndJoin": "Accept & Join", + "noNotifications": "No notifications" + } +} diff --git a/worklenz-backend/src/public/locales/en/organization-name-form.json b/worklenz-backend/src/public/locales/en/organization-name-form.json new file mode 100644 index 00000000..26ffa973 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/organization-name-form.json @@ -0,0 +1,5 @@ +{ + "nameYourOrganization": "Name your organization.", + "worklenzAccountTitle": "Pick a name for your Worklenz account.", + "continue": "Continue" +} diff --git a/worklenz-backend/src/public/locales/en/phases-drawer.json b/worklenz-backend/src/public/locales/en/phases-drawer.json new file mode 100644 index 00000000..10ad78a4 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/phases-drawer.json @@ -0,0 +1,19 @@ +{ + "configurePhases": "Configure Phases", + "phaseLabel": "Phase Label", + "enterPhaseName": "Enter a name for phase label", + "addOption": "Add Option", + "phaseOptions": "Phase Options:", + "dragToReorderPhases": "Drag phases to reorder them. Each phase can have a different color.", + "enterNewPhaseName": "Enter new phase name...", + "addPhase": "Add Phase", + "noPhasesFound": "No phases found. Create your first phase above.", + "deletePhase": "Delete Phase", + "deletePhaseConfirm": "Are you sure you want to delete this phase? This action cannot be undone.", + "rename": "Rename", + "delete": "Delete", + "enterPhaseName": "Enter phase name", + "selectColor": "Select color", + "managePhases": "Manage Phases", + "close": "Close" +} diff --git a/worklenz-backend/src/public/locales/en/project-drawer.json b/worklenz-backend/src/public/locales/en/project-drawer.json new file mode 100644 index 00000000..be553a01 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/project-drawer.json @@ -0,0 +1,52 @@ +{ + "createProject": "Create Project", + "editProject": "Edit Project", + "enterCategoryName": "Enter a name for the category", + "hitEnterToCreate": "Hit enter to create!", + "enterNotes": "Notes", + "youCanManageClientsUnderSettings": "You can manage clients under Settings", + "addCategory": "Add a category to the project", + "newCategory": "New Category", + "notes": "Notes", + "startDate": "Start Date", + "endDate": "End Date", + "estimateWorkingDays": "Estimate working days", + "estimateManDays": "Estimate man days", + "hoursPerDay": "Hours per day", + "create": "Create", + "update": "Update", + "delete": "Delete", + "typeToSearchClients": "Type to search clients", + "projectColor": "Project Color", + "pleaseEnterAName": "Please enter a name", + "enterProjectName": "Enter project name", + "name": "Name", + "status": "Status", + "health": "Health", + "category": "Category", + "projectManager": "Project Manager", + "client": "Client", + "deleteConfirmation": "Are you sure you want to delete?", + "deleteConfirmationDescription": "This will remove all associated data and cannot be undone.", + "yes": "Yes", + "no": "No", + "createdAt": "Created", + "updatedAt": "Updated", + "by": "by", + "add": "Add", + "asClient": "as client", + "createClient": "Create client", + "searchInputPlaceholder": "Search by name or email", + "hoursPerDayValidationMessage": "Hours per day must be a number between 1 and 24", + "workingDaysValidationMessage": "Working days must be a positive number", + "manDaysValidationMessage": "Man days must be a positive number", + "noPermission": "No permission", + "progressSettings": "Progress Settings", + "manualProgress": "Manual Progress", + "manualProgressTooltip": "Allow manual progress updates for tasks without subtasks", + "weightedProgress": "Weighted Progress", + "weightedProgressTooltip": "Calculate progress based on subtask weights", + "timeProgress": "Time-based Progress", + "timeProgressTooltip": "Calculate progress based on estimated time", + "enterProjectKey": "Enter project key" +} diff --git a/worklenz-backend/src/public/locales/en/project-view-files.json b/worklenz-backend/src/public/locales/en/project-view-files.json new file mode 100644 index 00000000..12672620 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/project-view-files.json @@ -0,0 +1,14 @@ +{ + "nameColumn": "Name", + "attachedTaskColumn": "Attached Task", + "sizeColumn": "Size", + "uploadedByColumn": "Uploaded By", + "uploadedAtColumn": "Uploaded At", + "fileIconAlt": "File icon", + "titleDescriptionText": "All attachments to tasks in this project will appear here.", + "deleteConfirmationTitle": "Are you sure?", + "deleteConfirmationOk": "Yes", + "deleteConfirmationCancel": "Cancel", + "segmentedTooltip": "Coming soon! Switch between list view and thumbnail view.", + "emptyText": "There are no attachments in the project." +} diff --git a/worklenz-backend/src/public/locales/en/project-view-insights.json b/worklenz-backend/src/public/locales/en/project-view-insights.json new file mode 100644 index 00000000..1b174a85 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/project-view-insights.json @@ -0,0 +1,41 @@ +{ + "overview": { + "title": "Overview", + "statusOverview": "Status Overview", + "priorityOverview": "Priority Overview", + "lastUpdatedTasks": "Last Updated Tasks" + }, + "members": { + "title": "Members", + "tooltip": "Members", + "tasksByMembers": "Tasks by members", + "tasksByMembersTooltip": "Tasks by members", + "name": "Name", + "taskCount": "Task Count", + "contribution": "Contribution", + "completed": "Completed", + "incomplete": "Incomplete", + "overdue": "Overdue", + "progress": "Progress" + }, + "tasks": { + "overdueTasks": "Overdue Tasks", + "overLoggedTasks": "Over logged Tasks", + "tasksCompletedEarly": "Tasks completed early", + "tasksCompletedLate": "Tasks completed late", + "overLoggedTasksTooltip": "Tasks that has time logged past their estimated time", + "overdueTasksTooltip": "Tasks that are past their due date" + }, + "common": { + "seeAll": "See all", + "totalLoggedHours": "Total logged hours", + "totalEstimation": "Total estimation", + "completedTasks": "Completed tasks", + "incompleteTasks": "Incomplete tasks", + "overdueTasks": "Overdue tasks", + "overdueTasksTooltip": "Tasks that are past their due date", + "totalLoggedHoursTooltip": "Task estimation and logged time for tasks.", + "includeArchivedTasks": "Include Archived Tasks", + "export": "Export" + } +} diff --git a/worklenz-backend/src/public/locales/en/project-view-members.json b/worklenz-backend/src/public/locales/en/project-view-members.json new file mode 100644 index 00000000..6ed8ddf0 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/project-view-members.json @@ -0,0 +1,17 @@ +{ + "nameColumn": "Name", + "jobTitleColumn": "Job Title", + "emailColumn": "Email", + "tasksColumn": "Tasks", + "taskProgressColumn": "Task Progress", + "accessColumn": "Access", + "fileIconAlt": "File icon", + "deleteConfirmationTitle": "Are you sure?", + "deleteConfirmationOk": "Yes", + "deleteConfirmationCancel": "Cancel", + "refreshButtonTooltip": "Refresh members", + "deleteButtonTooltip": "Remove from project", + "memberCount": "Member", + "membersCountPlural": "Members", + "emptyText": "There are no attachments in the project." +} diff --git a/worklenz-backend/src/public/locales/en/project-view-updates.json b/worklenz-backend/src/public/locales/en/project-view-updates.json new file mode 100644 index 00000000..d7140ad8 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/project-view-updates.json @@ -0,0 +1,6 @@ +{ + "inputPlaceholder": "Add a comment..", + "addButton": "Add", + "cancelButton": "Cancel", + "deleteButton": "Delete" +} diff --git a/worklenz-backend/src/public/locales/en/project-view.json b/worklenz-backend/src/public/locales/en/project-view.json new file mode 100644 index 00000000..82ab21f2 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/project-view.json @@ -0,0 +1,14 @@ +{ + "taskList": "Task List", + "board": "Board", + "insights": "Insights", + "files": "Files", + "members": "Members", + "updates": "Updates", + "projectView": "Project View", + "loading": "Loading project...", + "error": "Error loading project", + "pinnedTab": "Pinned as default tab", + "pinTab": "Pin as default tab", + "unpinTab": "Unpin default tab" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/en/project-view/import-task-templates.json b/worklenz-backend/src/public/locales/en/project-view/import-task-templates.json new file mode 100644 index 00000000..d732aa08 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/project-view/import-task-templates.json @@ -0,0 +1,11 @@ +{ + "importTaskTemplate": "Import Task Template", + "templateName": "Template Name", + "templateDescription": "Template Description", + "selectedTasks": "Selected Tasks", + "tasks": "Tasks", + "templates": "Templates", + "remove": "Remove", + "cancel": "Cancel", + "import": "Import" +} diff --git a/worklenz-backend/src/public/locales/en/project-view/project-member-drawer.json b/worklenz-backend/src/public/locales/en/project-view/project-member-drawer.json new file mode 100644 index 00000000..ad2d60c8 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/project-view/project-member-drawer.json @@ -0,0 +1,7 @@ +{ + "title": "Project Members", + "searchLabel": "Add members by adding their name or email", + "searchPlaceholder": "Type name or email", + "inviteAsAMember": "Invite as a member", + "inviteNewMemberByEmail": "Invite new member by email" +} diff --git a/worklenz-backend/src/public/locales/en/project-view/project-view-header.json b/worklenz-backend/src/public/locales/en/project-view/project-view-header.json new file mode 100644 index 00000000..1bbb6c15 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/project-view/project-view-header.json @@ -0,0 +1,30 @@ +{ + "importTasks": "Import tasks", + "importTask": "Import task", + "createTask": "Create task", + "settings": "Settings", + "subscribe": "Subscribe", + "unsubscribe": "Unsubscribe", + "deleteProject": "Delete project", + "startDate": "Start date", + "endDate": "End date", + "projectSettings": "Project settings", + "projectSummary": "Project summary", + "receiveProjectSummary": "Receive a project summary every evening.", + "refreshProject": "Refresh project", + "saveAsTemplate": "Save as template", + "invite": "Invite", + "share": "Share", + "subscribeTooltip": "Subscribe to project notifications", + "unsubscribeTooltip": "Unsubscribe from project notifications", + "refreshTooltip": "Refresh project data", + "settingsTooltip": "Open project settings", + "saveAsTemplateTooltip": "Save this project as a template", + "inviteTooltip": "Invite team members to this project", + "createTaskTooltip": "Create a new task", + "importTaskTooltip": "Import task from template", + "navigateBackTooltip": "Go back to projects list", + "projectStatusTooltip": "Project status", + "projectDatesInfo": "Project timeline information", + "projectCategoryTooltip": "Project category" +} diff --git a/worklenz-backend/src/public/locales/en/project-view/save-as-template.json b/worklenz-backend/src/public/locales/en/project-view/save-as-template.json new file mode 100644 index 00000000..2b3e7564 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/project-view/save-as-template.json @@ -0,0 +1,27 @@ +{ + "title": "Save as Template", + "templateName": "Template Name", + "includes": "What should be included in the template from the project ?", + "includesOptions": { + "statuses": "Statuses", + "phases": "Phases", + "labels": "Labels" + }, + "taskIncludes": "What should be included in the template from the tasks ?", + "taskIncludesOptions": { + "statuses": "Statuses", + "phases": "Phases", + "labels": "Labels", + "name": "Name", + "priority": "Priority", + "status": "Status", + "phase": "Phase", + "label": "Label", + "timeEstimate": "Time Estimate", + "description": "Description", + "subTasks": "Sub Tasks" + }, + "cancel": "Cancel", + "save": "Save", + "templateNamePlaceholder": "Enter template name" +} diff --git a/worklenz-backend/src/public/locales/en/reporting-members-drawer.json b/worklenz-backend/src/public/locales/en/reporting-members-drawer.json new file mode 100644 index 00000000..cca01177 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/reporting-members-drawer.json @@ -0,0 +1,90 @@ +{ + "exportButton": "Export", + "timeLogsButton": "TimeLogs", + "activityLogsButton": "Activity Logs", + "tasksButton": "Tasks", + "searchByNameInputPlaceholder": "Search by name", + + "overviewTab": "Overview", + "timeLogsTab": "Time Logs", + "activityLogsTab": "Activity Logs", + "tasksTab": "Tasks", + + "projectsText": "Projects", + "totalTasksText": "Total Tasks", + "assignedTasksText": "Assigned Tasks", + "completedTasksText": "Completed Tasks", + "ongoingTasksText": "Ongoing Tasks", + "overdueTasksText": "Overdue Tasks", + "loggedHoursText": "Logged Hours", + + "tasksText": "Tasks", + "allText": "All", + + "tasksByProjectsText": "Tasks By Projects", + "tasksByStatusText": "Tasks By Status", + "tasksByPriorityText": "Tasks By Priority", + + "todoText": "To Do", + "doingText": "Doing", + "doneText": "Done", + "lowText": "Low", + "mediumText": "Medium", + "highText": "High", + + "billableButton": "Billable", + "billableText": "Billable", + "nonBillableText": "Non Billable", + + "timeLogsEmptyPlaceholder": "No time logs to show", + "loggedText": "Logged", + "forText": "for", + "inText": "in", + "updatedText": "Updated", + "fromText": "From", + "toText": "to", + "withinText": "within", + + "activityLogsEmptyPlaceholder": "No activity logs to show", + + "filterByText": "Filter by:", + "selectProjectPlaceholder": "Select Project", + + "taskColumn": "Task", + "nameColumn": "Name", + "projectColumn": "Project", + "statusColumn": "Status", + "priorityColumn": "Priority", + "dueDateColumn": "Due Date", + "completedDateColumn": "Completed Date", + "estimatedTimeColumn": "Estimated Time", + "loggedTimeColumn": "Logged Time", + "overloggedTimeColumn": "Overlogged Time", + "daysLeftColumn": "Days Left/Overdue", + "startDateColumn": "Start Date", + "endDateColumn": "End Date", + "actualTimeColumn": "Actual Time", + "projectHealthColumn": "Project Health", + "categoryColumn": "Category", + "projectManagerColumn": "Project Manager", + + "tasksStatsOverviewDrawerTitle": "'s Tasks", + "projectsStatsOverviewDrawerTitle": "'s Projects", + + "cancelledText": "Cancelled", + "blockedText": "Blocked", + "onHoldText": "On Hold", + "proposedText": "Proposed", + "inPlanningText": "In Planning", + "inProgressText": "In Progress", + "completedText": "Completed", + "continuousText": "Continuous", + + "daysLeftText": "days left", + "daysOverdueText": "days overdue", + + "notSetText": "NotSet", + "needsAttentionText": "Needs Attention", + "atRiskText": "At Risk", + "goodText": "Good" +} diff --git a/worklenz-backend/src/public/locales/en/reporting-members.json b/worklenz-backend/src/public/locales/en/reporting-members.json new file mode 100644 index 00000000..a8035dcd --- /dev/null +++ b/worklenz-backend/src/public/locales/en/reporting-members.json @@ -0,0 +1,35 @@ +{ + "yesterdayText": "Yesterday", + "lastSevenDaysText": "Last 7 Days", + "lastWeekText": "Last Week", + "lastThirtyDaysText": "Last 30 Days", + "lastMonthText": "Last Month", + "lastThreeMonthsText": "Last 3 Months", + "allTimeText": "All Time", + "customRangeText": "Custom range", + "startDateInputPlaceholder": "Start date", + "EndDateInputPlaceholder": "End date", + "filterButton": "Filter", + + "membersTitle": "Members", + "includeArchivedButton": "Include Archived Projects", + "exportButton": "Export", + "excelButton": "Excel", + "searchByNameInputPlaceholder": "Search by name", + + "memberColumn": "Member", + "tasksProgressColumn": "Tasks Progress", + "tasksAssignedColumn": "Tasks Assigned", + "completedTasksColumn": "Completed Tasks", + "overdueTasksColumn": "Overdue Tasks", + "ongoingTasksColumn": "Ongoing Tasks", + + "tasksAssignedColumnTooltip": "Tasks assigned on selected date range", + "overdueTasksColumnTooltip": "Tasks overdue for end of the selected date range", + "completedTasksColumnTooltip": "Tasks completed on selected date range", + "ongoingTasksColumnTooltip": "Started tasks not completed yet", + + "todoText": "To Do", + "doingText": "Doing", + "doneText": "Done" +} diff --git a/worklenz-backend/src/public/locales/en/reporting-overview-drawer.json b/worklenz-backend/src/public/locales/en/reporting-overview-drawer.json new file mode 100644 index 00000000..84fab1b8 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/reporting-overview-drawer.json @@ -0,0 +1,39 @@ +{ + "exportButton": "Export", + "projectsButton": "Projects", + "membersButton": "Members", + "searchByNameInputPlaceholder": "Search by name", + + "overviewTab": "Overview", + "projectsTab": "Projects", + "membersTab": "Members", + + "projectsByStatusText": "Projects By Status", + "projectsByCategoryText": "Projects By Category", + "projectsByHealthText": "Projects By Health", + + "projectsText": "Projects", + "allText": "All", + + "cancelledText": "Cancelled", + "blockedText": "Blocked", + "onHoldText": "On Hold", + "proposedText": "Proposed", + "inPlanningText": "In Planning", + "inProgressText": "In Progress", + "completedText": "Completed", + "continuousText": "Continuous", + + "notSetText": "Not Set", + "needsAttentionText": "Needs Attention", + "atRiskText": "At Risk", + "goodText": "Good", + + "nameColumn": "Name", + "emailColumn": "Email", + "projectsColumn": "Projects", + "tasksColumn": "Tasks", + "overdueTasksColumn": "Overdue Tasks", + "completedTasksColumn": "Completed Tasks", + "ongoingTasksColumn": "Ongoing Tasks" +} diff --git a/worklenz-backend/src/public/locales/en/reporting-overview.json b/worklenz-backend/src/public/locales/en/reporting-overview.json new file mode 100644 index 00000000..73faffd3 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/reporting-overview.json @@ -0,0 +1,25 @@ +{ + "overviewTitle": "Overview", + "includeArchivedButton": "Include Archived Projects", + + "teamCount": "Team", + "teamCountPlural": "Teams", + "projectCount": "Project", + "projectCountPlural": "Projects", + "memberCount": "Member", + "memberCountPlural": "Members", + "activeProjectCount": "Active Project", + "activeProjectCountPlural": "Active Projects", + "overdueProjectCount": "Overdue Project", + "overdueProjectCountPlural": "Overdue Projects", + "unassignedMemberCount": "Unassigned Member", + "unassignedMemberCountPlural": "Unassigned Members", + "memberWithOverdueTaskCount": "Member With Overdue Task", + "memberWithOverdueTaskCountPlural": "Member With Overdue Tasks", + + "teamsText": "Teams", + + "nameColumn": "Name", + "projectsColumn": "Projects", + "membersColumn": "Members" +} diff --git a/worklenz-backend/src/public/locales/en/reporting-projects-drawer.json b/worklenz-backend/src/public/locales/en/reporting-projects-drawer.json new file mode 100644 index 00000000..243bb411 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/reporting-projects-drawer.json @@ -0,0 +1,59 @@ +{ + "exportButton": "Export", + "membersButton": "Members", + "tasksButton": "Tasks", + "searchByNameInputPlaceholder": "Search by name", + + "overviewTab": "Overview", + "membersTab": "Members", + "tasksTab": "Tasks", + + "completedTasksText": "Completed Tasks", + "incompleteTasksText": "Incomplete Tasks", + "overdueTasksText": "Overdue Tasks", + "allocatedHoursText": "Allocated Hours", + "loggedHoursText": "Logged Hours", + + "tasksText": "Tasks", + "allText": "All", + + "tasksByStatusText": "Tasks By Status", + "tasksByPriorityText": "Tasks By Priority", + "tasksByDueDateText": "Tasks By Due Date", + + "todoText": "To Do", + "doingText": "Doing", + "doneText": "Done", + "lowText": "Low", + "mediumText": "Medium", + "highText": "High", + "completedText": "Completed", + "upcomingText": "Upcoming", + "overdueText": "Overdue", + "noDueDateText": "No Due Date", + + "nameColumn": "Name", + "tasksCountColumn": "Tasks Count", + "completedTasksColumn": "Completed Tasks", + "incompleteTasksColumn": "Incomplete Tasks", + "overdueTasksColumn": "Overdue Tasks", + "contributionColumn": "Contribution", + "progressColumn": "Progress", + "loggedTimeColumn": "Logged Time", + "taskColumn": "Task", + "projectColumn": "Project", + "statusColumn": "Status", + "priorityColumn": "Priority", + "phaseColumn": "Phase", + "dueDateColumn": "Due Date", + "completedDateColumn": "Completed Date", + "estimatedTimeColumn": "Estimated Time", + "overloggedTimeColumn": "Overlogged Time", + "completedOnColumn": "Completed On", + "daysOverdueColumn": "Days overdue", + + "groupByText": "Group By:", + "statusText": "Status", + "priorityText": "Priority", + "phaseText": "Phase" +} diff --git a/worklenz-backend/src/public/locales/en/reporting-projects-filters.json b/worklenz-backend/src/public/locales/en/reporting-projects-filters.json new file mode 100644 index 00000000..7d9afccd --- /dev/null +++ b/worklenz-backend/src/public/locales/en/reporting-projects-filters.json @@ -0,0 +1,35 @@ +{ + "searchByNamePlaceholder": "Search by name", + "searchByCategoryPlaceholder": "Search by category", + + "statusText": "Status", + "healthText": "Health", + "categoryText": "Category", + "projectManagerText": "Project Manager", + "showFieldsText": "Show fields", + + "cancelledText": "Cancelled", + "blockedText": "Blocked", + "onHoldText": "On Hold", + "proposedText": "Proposed", + "inPlanningText": "In Planning", + "inProgressText": "In Progress", + "completedText": "Completed", + "continuousText": "Continuous", + + "notSetText": "NotSet", + "needsAttentionText": "Needs Attention", + "atRiskText": "At Risk", + "goodText": "Good", + + "nameText": "Project", + "estimatedVsActualText": "Estimated Vs Actual", + "tasksProgressText": "Tasks Progress", + "lastActivityText": "Last Activity", + "datesText": "Start/End Dates", + "daysLeftText": "Days Left/Overdue", + "projectHealthText": "Project Health", + "projectUpdateText": "Project Update", + "clientText": "Client", + "teamText": "Team" +} diff --git a/worklenz-backend/src/public/locales/en/reporting-projects.json b/worklenz-backend/src/public/locales/en/reporting-projects.json new file mode 100644 index 00000000..8dd472c4 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/reporting-projects.json @@ -0,0 +1,52 @@ +{ + "projectCount": "Project", + "projectCountPlural": "Projects", + "includeArchivedButton": "Include Archived Projects", + "exportButton": "Export", + "excelButton": "Excel", + + "projectColumn": "Project", + "estimatedVsActualColumn": "Estimated Vs Actual", + "tasksProgressColumn": "Tasks Progress", + "lastActivityColumn": "Last Activity", + "statusColumn": "Status", + "datesColumn": "Start/End Dates", + "daysLeftColumn": "Days Left/Overdue", + "projectHealthColumn": "Project Health", + "categoryColumn": "Category", + "projectUpdateColumn": "Project Update", + "clientColumn": "Client", + "teamColumn": "Team", + "projectManagerColumn": "Project Manager", + + "openButton": "Open", + + "estimatedText": "Estimated", + "actualText": "Actual", + + "todoText": "To Do", + "doingText": "Doing", + "doneText": "Done", + + "cancelledText": "Cancelled", + "blockedText": "Blocked", + "onHoldText": "On Hold", + "proposedText": "Proposed", + "inPlanningText": "In Planning", + "inProgressText": "In Progress", + "completedText": "Completed", + "continuousText": "Continuous", + + "daysLeftText": "days left", + "dayLeftText": "day left", + "daysOverdueText": "days overdue", + + "notSetText": "Not Set", + "needsAttentionText": "Needs Attention", + "atRiskText": "At Risk", + "goodText": "Good", + + "setCategoryText": "Set Category", + "searchByNameInputPlaceholder": "Search by name", + "todayText": "Today" +} diff --git a/worklenz-backend/src/public/locales/en/reporting-sidebar.json b/worklenz-backend/src/public/locales/en/reporting-sidebar.json new file mode 100644 index 00000000..8e82224d --- /dev/null +++ b/worklenz-backend/src/public/locales/en/reporting-sidebar.json @@ -0,0 +1,8 @@ +{ + "overview": "Overview", + "projects": "Projects", + "members": "Members", + "timeReports": "Time Reports", + "estimateVsActual": "Estimate Vs Actual", + "currentOrganizationTooltip": "Current organization" +} diff --git a/worklenz-backend/src/public/locales/en/schedule.json b/worklenz-backend/src/public/locales/en/schedule.json new file mode 100644 index 00000000..9e30c04b --- /dev/null +++ b/worklenz-backend/src/public/locales/en/schedule.json @@ -0,0 +1,39 @@ +{ + "today": "Today", + "week": "Week", + "month": "Month", + + "settings": "Settings", + "workingDays": "Working days", + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday", + "workingHours": "Working hours", + "hours": "hours", + "saveButton": "Save", + + "totalAllocation": "Total Allocation", + "timeLogged": "Time Logged", + "remainingTime": "Remaining Time", + "total": "Total", + "perDay": "Per Day", + "tasks": "tasks", + "startDate": "Start Date", + "endDate": "End Date", + + "hoursPerDay": "Hours Per Day", + "totalHours": "Total Hours", + "deleteButton": "Delete", + "cancelButton": "Cancel", + + "tabTitle": "Task without Start & End dates", + + "allocatedTime": "Allocated time", + "totalLogged": "Total Logged", + "loggedBillable": "Logged Billable", + "loggedNonBillable": "Logged Non Billable" +} diff --git a/worklenz-backend/src/public/locales/en/settings/appearance.json b/worklenz-backend/src/public/locales/en/settings/appearance.json new file mode 100644 index 00000000..9ce8de64 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/settings/appearance.json @@ -0,0 +1,5 @@ +{ + "title": "Appearance", + "darkMode": "Dark Mode", + "darkModeDescription": "Switch between light and dark mode to customize your viewing experience." +} diff --git a/worklenz-backend/src/public/locales/en/settings/categories.json b/worklenz-backend/src/public/locales/en/settings/categories.json new file mode 100644 index 00000000..716cb5c3 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/settings/categories.json @@ -0,0 +1,10 @@ +{ + "categoryColumn": "Category", + "deleteConfirmationTitle": "Are you sure?", + "deleteConfirmationOk": "Yes", + "deleteConfirmationCancel": "Cancel", + "associatedTaskColumn": "Associated Projects", + "searchPlaceholder": "Search by name", + "emptyText": "Categories can be created while updating or creating projects.", + "colorChangeTooltip": "Click to change color" +} diff --git a/worklenz-backend/src/public/locales/en/settings/change-password.json b/worklenz-backend/src/public/locales/en/settings/change-password.json new file mode 100644 index 00000000..ad39088b --- /dev/null +++ b/worklenz-backend/src/public/locales/en/settings/change-password.json @@ -0,0 +1,15 @@ +{ + "title": "Change Password", + "currentPassword": "Current Password", + "newPassword": "New Password", + "confirmPassword": "Confirm Password", + "currentPasswordPlaceholder": "Enter your current password", + "newPasswordPlaceholder": "New Password", + "confirmPasswordPlaceholder": "Confirm Password", + "currentPasswordRequired": "Please input your current password!", + "newPasswordRequired": "Please input your new password!", + "passwordValidationError": "Password must be at least 8 characters with an uppercase letter, a number, and a symbol.", + "passwordMismatch": "Passwords do not match!", + "passwordRequirements": "New password should be a minimum of 8 characters, with an uppercase letter, a number, and a symbol.", + "updateButton": "Update Password" +} diff --git a/worklenz-backend/src/public/locales/en/settings/clients.json b/worklenz-backend/src/public/locales/en/settings/clients.json new file mode 100644 index 00000000..b7fa4dac --- /dev/null +++ b/worklenz-backend/src/public/locales/en/settings/clients.json @@ -0,0 +1,22 @@ +{ + "nameColumn": "Name", + "projectColumn": "Project", + "noProjectsAvailable": "No projects available", + "deleteConfirmationTitle": "Are you sure?", + "deleteConfirmationOk": "Yes", + "deleteConfirmationCancel": "Cancel", + "searchPlaceholder": "Search by name", + "createClient": "Create Client", + "pinTooltip": "Click to pin this into the main menu", + "createClientDrawerTitle": "Create Client", + "updateClientDrawerTitle": "Update Client", + "nameLabel": "Name", + "namePlaceholder": "Name", + "nameRequiredError": "Please enter a Name", + "createButton": "Create", + "updateButton": "Update", + "createClientSuccessMessage": "Create client success!", + "createClientErrorMessage": "Create client failed!", + "updateClientSuccessMessage": "Update client success!", + "updateClientErrorMessage": "Update client failed!" +} diff --git a/worklenz-backend/src/public/locales/en/settings/job-titles.json b/worklenz-backend/src/public/locales/en/settings/job-titles.json new file mode 100644 index 00000000..9ec54f98 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/settings/job-titles.json @@ -0,0 +1,20 @@ +{ + "nameColumn": "Name", + "deleteConfirmationTitle": "Are you sure?", + "deleteConfirmationOk": "Yes", + "deleteConfirmationCancel": "Cancel", + "searchPlaceholder": "Search by name", + "createJobTitleButton": "Create Job Title", + "pinTooltip": "Click to pin this into the main menu", + "createJobTitleDrawerTitle": "Create Job Title", + "updateJobTitleDrawerTitle": "Update Job Title", + "nameLabel": "Name", + "namePlaceholder": "Name", + "nameRequiredError": "Please enter a Name", + "createButton": "Create", + "updateButton": "Update", + "createJobTitleSuccessMessage": "Create job title success!", + "createJobTitleErrorMessage": "Create job title failed!", + "updateJobTitleSuccessMessage": "Update job title success!", + "updateJobTitleErrorMessage": "Update job title failed!" +} diff --git a/worklenz-backend/src/public/locales/en/settings/labels.json b/worklenz-backend/src/public/locales/en/settings/labels.json new file mode 100644 index 00000000..5c3d2479 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/settings/labels.json @@ -0,0 +1,11 @@ +{ + "labelColumn": "Label", + "deleteConfirmationTitle": "Are you sure?", + "deleteConfirmationOk": "Yes", + "deleteConfirmationCancel": "Cancel", + "associatedTaskColumn": "Associated Task Count", + "searchPlaceholder": "Search by name", + "emptyText": "Labels can be created while updating or creating tasks.", + "pinTooltip": "Click to pin this into the main menu", + "colorChangeTooltip": "Click to change color" +} diff --git a/worklenz-backend/src/public/locales/en/settings/language.json b/worklenz-backend/src/public/locales/en/settings/language.json new file mode 100644 index 00000000..331cb7df --- /dev/null +++ b/worklenz-backend/src/public/locales/en/settings/language.json @@ -0,0 +1,7 @@ +{ + "language": "Language", + "language_required": "Language is required", + "time_zone": "Time zone", + "time_zone_required": "Time zone is required", + "save_changes": "Save Changes" +} diff --git a/worklenz-backend/src/public/locales/en/settings/notifications.json b/worklenz-backend/src/public/locales/en/settings/notifications.json new file mode 100644 index 00000000..7cc1eb47 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/settings/notifications.json @@ -0,0 +1,11 @@ +{ + "title": "Notifications Settings", + "emailTitle": "Send me email notifications", + "emailDescription": "This includes new task assignments", + "dailyDigestTitle": "Send me a daily digest", + "dailyDigestDescription": "Every evening, you will receive a summary of recent activity in tasks.", + "popupTitle": "Pop up notifications on my computer when Worklenz is open", + "popupDescription": "Pop up notifications can be disabled by your browser. Change your browser settings to allow them.", + "unreadItemsTitle": "Show the number of unread items", + "unreadItemsDescription": "You'll see counts for each notification." +} diff --git a/worklenz-backend/src/public/locales/en/settings/profile.json b/worklenz-backend/src/public/locales/en/settings/profile.json new file mode 100644 index 00000000..43ce2f41 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/settings/profile.json @@ -0,0 +1,14 @@ +{ + "uploadError": "You can only upload JPG/PNG file!", + "uploadSizeError": "Image must be smaller than 2MB!", + "upload": "Upload", + "nameLabel": "Name", + "nameRequiredError": "Name is required", + "emailLabel": "Email", + "emailRequiredError": "Email is required", + "saveChanges": "Save Changes", + "profileJoinedText": "Joined a month ago", + "profileLastUpdatedText": "Last updated a month ago", + "avatarTooltip": "Click to upload an avatar", + "title": "Profile Settings" +} diff --git a/worklenz-backend/src/public/locales/en/settings/project-templates.json b/worklenz-backend/src/public/locales/en/settings/project-templates.json new file mode 100644 index 00000000..802e017b --- /dev/null +++ b/worklenz-backend/src/public/locales/en/settings/project-templates.json @@ -0,0 +1,8 @@ +{ + "nameColumn": "Name", + "editToolTip": "Edit", + "deleteToolTip": "Delete", + "confirmText": "Are you sure?", + "okText": "Yes", + "cancelText": "Cancel" +} diff --git a/worklenz-backend/src/public/locales/en/settings/sidebar.json b/worklenz-backend/src/public/locales/en/settings/sidebar.json new file mode 100644 index 00000000..d0b64829 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/settings/sidebar.json @@ -0,0 +1,15 @@ +{ + "profile": "Profile", + "notifications": "Notifications", + "clients": "Clients", + "job-titles": "Job Titles", + "labels": "Labels", + "categories": "Categories", + "project-templates": "Project Templates", + "task-templates": "Task Templates", + "team-members": "Team Members", + "teams": "Teams", + "change-password": "Change Password", + "language-and-region": "Language and Region", + "appearance": "Appearance" +} diff --git a/worklenz-backend/src/public/locales/en/settings/task-templates.json b/worklenz-backend/src/public/locales/en/settings/task-templates.json new file mode 100644 index 00000000..b40bed2d --- /dev/null +++ b/worklenz-backend/src/public/locales/en/settings/task-templates.json @@ -0,0 +1,9 @@ +{ + "nameColumn": "Name", + "createdColumn": "Created", + "editToolTip": "Edit", + "deleteToolTip": "Delete", + "confirmText": "Are you sure?", + "okText": "Yes", + "cancelText": "Cancel" +} diff --git a/worklenz-backend/src/public/locales/en/settings/team-members.json b/worklenz-backend/src/public/locales/en/settings/team-members.json new file mode 100644 index 00000000..36918b90 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/settings/team-members.json @@ -0,0 +1,47 @@ +{ + "title": "Team Members", + "nameColumn": "Name", + "projectsColumn": "Projects", + "emailColumn": "Email", + "teamAccessColumn": "Team Access", + "memberCount": "Member", + "membersCountPlural": "Members", + "searchPlaceholder": "Search members by name", + "pinTooltip": "Refresh member list", + "addMemberButton": "Add New Member", + "editTooltip": "Edit member", + "deactivateTooltip": "Deactivate member", + "activateTooltip": "Activate member", + "deleteTooltip": "Delete member", + "confirmDeleteTitle": "Are you sure you want to delete this member?", + "confirmActivateTitle": "Are you sure you want to change this member's status?", + "okText": "Yes, proceed", + "cancelText": "No, cancel", + "deactivatedText": "(Currently deactivated)", + "pendingInvitationText": "(Invitation pending)", + "addMemberDrawerTitle": "Add New Team Member", + "updateMemberDrawerTitle": "Update Team Member", + "addMemberEmailHint": "Members will be added to the team regardless of invitation acceptance status", + "memberEmailLabel": "Email(s)", + "memberEmailPlaceholder": "Enter team member email address", + "memberEmailRequiredError": "Please enter a valid email address", + "jobTitleLabel": "Job Title", + "jobTitlePlaceholder": "Select or search job title (Optional)", + "memberAccessLabel": "Access Level", + "addToTeamButton": "Add Member to Team", + "updateButton": "Save Changes", + "resendInvitationButton": "Resend Invitation Email", + "invitationSentSuccessMessage": "Team invitation sent successfully!", + "createMemberSuccessMessage": "New team member added successfully!", + "createMemberErrorMessage": "Failed to add team member. Please try again.", + "updateMemberSuccessMessage": "Team member updated successfully!", + "updateMemberErrorMessage": "Failed to update team member. Please try again.", + "memberText": "Member", + "adminText": "Admin", + "ownerText": "Team Owner", + "addedText": "Added", + "updatedText": "Updated", + "noResultFound": "Type an email address and hit enter...", + "jobTitlesFetchError": "Failed to fetch job titles", + "invitationResent": "Invitation resent successfully!" +} diff --git a/worklenz-backend/src/public/locales/en/settings/teams.json b/worklenz-backend/src/public/locales/en/settings/teams.json new file mode 100644 index 00000000..57a1df51 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/settings/teams.json @@ -0,0 +1,16 @@ +{ + "title": "Teams", + "team": "Team", + "teams": "Teams", + "name": "Name", + "created": "Created", + "ownsBy": "Owns By", + "edit": "Edit", + "editTeam": "Edit Team", + "pinTooltip": "Click to pin this into the main menu", + "editTeamName": "Edit Team Name", + "updateName": "Update Name", + "namePlaceholder": "Name", + "nameRequired": "Please enter a Name", + "updateFailed": "Team name change failed!" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/en/task-drawer/task-drawer-info-tab.json b/worklenz-backend/src/public/locales/en/task-drawer/task-drawer-info-tab.json new file mode 100644 index 00000000..f88ecde9 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/task-drawer/task-drawer-info-tab.json @@ -0,0 +1,30 @@ +{ + "details": { + "task-key": "Task Key", + "phase": "Phase", + "assignees": "Assignees", + "due-date": "Due Date", + "time-estimation": "Time Estimation", + "priority": "Priority", + "labels": "Labels", + "billable": "Billable", + "notify": "Notify", + "when-done-notify": "When done, notify", + "start-date": "Start Date", + "end-date": "End Date", + "hide-start-date": "Hide Start Date", + "show-start-date": "Show Start Date", + "hours": "Hours", + "minutes": "Minutes", + "recurring": "Recurring" + }, + "description": { + "title": "Description", + "placeholder": "Add a more detailed description..." + }, + "subTasks": { + "title": "Sub Tasks", + "add-sub-task": "Add Sub Task", + "refresh-sub-tasks": "Refresh Sub Tasks" + } +} diff --git a/worklenz-backend/src/public/locales/en/task-drawer/task-drawer-recurring-config.json b/worklenz-backend/src/public/locales/en/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..1d22e41b --- /dev/null +++ b/worklenz-backend/src/public/locales/en/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,34 @@ +{ + "recurring": "Recurring", + "recurringTaskConfiguration": "Recurring task configuration", + "repeats": "Repeats", + "daily": "Daily", + "weekly": "Weekly", + "everyXDays": "Every X Days", + "everyXWeeks": "Every X Weeks", + "everyXMonths": "Every X Months", + "monthly": "Monthly", + "selectDaysOfWeek": "Select Days of the Week", + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat", + "sun": "Sun", + "monthlyRepeatType": "Monthly repeat type", + "onSpecificDate": "On a specific date", + "onSpecificDay": "On a specific day", + "dateOfMonth": "Date of the month", + "weekOfMonth": "Week of the month", + "dayOfWeek": "Day of the week", + "first": "First", + "second": "Second", + "third": "Third", + "fourth": "Fourth", + "last": "Last", + "intervalDays": "Interval (days)", + "intervalWeeks": "Interval (weeks)", + "intervalMonths": "Interval (months)", + "saveChanges": "Save Changes" +} diff --git a/worklenz-backend/src/public/locales/en/task-drawer/task-drawer.json b/worklenz-backend/src/public/locales/en/task-drawer/task-drawer.json new file mode 100644 index 00000000..b5147324 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/task-drawer/task-drawer.json @@ -0,0 +1,123 @@ +{ + "taskHeader": { + "taskNamePlaceholder": "Type your Task", + "deleteTask": "Delete Task" + }, + "taskInfoTab": { + "title": "Info", + "details": { + "title": "Details", + "task-key": "Task Key", + "phase": "Phase", + "assignees": "Assignees", + "due-date": "Due Date", + "time-estimation": "Time Estimation", + "priority": "Priority", + "labels": "Labels", + "billable": "Billable", + "notify": "Notify", + "when-done-notify": "When done, notify", + "start-date": "Start Date", + "end-date": "End Date", + "hide-start-date": "Hide Start Date", + "show-start-date": "Show Start Date", + "hours": "Hours", + "minutes": "Minutes", + "progressValue": "Progress Value", + "progressValueTooltip": "Set the progress percentage (0-100%)", + "progressValueRequired": "Please enter a progress value", + "progressValueRange": "Progress must be between 0 and 100", + "taskWeight": "Task Weight", + "taskWeightTooltip": "Set the weight of this subtask (percentage)", + "taskWeightRequired": "Please enter a task weight", + "taskWeightRange": "Weight must be between 0 and 100", + "recurring": "Recurring" + }, + "labels": { + "labelInputPlaceholder": "Search or create", + "labelsSelectorInputTip": "Hit Enter to create" + }, + "description": { + "title": "Description", + "placeholder": "Add a more detailed description..." + }, + "subTasks": { + "title": "Sub Tasks", + "addSubTask": "Add Sub Task", + "addSubTaskInputPlaceholder": "Type your task and hit enter", + "refreshSubTasks": "Refresh Sub Tasks", + "edit": "Edit", + "delete": "Delete", + "confirmDeleteSubTask": "Are you sure you want to delete this subtask?", + "deleteSubTask": "Delete Sub Task" + }, + "dependencies": { + "title": "Dependencies", + "addDependency": "+ Add new dependency", + "blockedBy": "Blocked By", + "searchTask": "Type to search task", + "noTasksFound": "No tasks found", + "confirmDeleteDependency": "Are you sure you want to delete?" + }, + "attachments": { + "title": "Attachments", + "chooseOrDropFileToUpload": "Choose or drop file to upload", + "uploading": "Uploading..." + }, + "comments": { + "title": "Comments", + "addComment": "+ Add new comment", + "noComments": "No comments yet. Be the first to comment!", + "delete": "Delete", + "confirmDeleteComment": "Are you sure you want to delete this comment?", + "addCommentPlaceholder": "Add a comment...", + "cancel": "Cancel", + "commentButton": "Comment", + "attachFiles": "Attach files", + "addMoreFiles": "Add more files", + "selectedFiles": "Selected Files (Up to 25MB, Maximum of {count})", + "maxFilesError": "You can only upload a maximum of {count} files", + "processFilesError": "Failed to process files", + "addCommentError": "Please add a comment or attach files", + "createdBy": "Created {{time}} by {{user}}", + "updatedTime": "Updated {{time}}" + }, + "searchInputPlaceholder": "Search by name", + "pendingInvitation": "Pending Invitation" + }, + "taskTimeLogTab": { + "title": "Time Log", + "addTimeLog": "Add new time log", + "totalLogged": "Total Logged", + "exportToExcel": "Export to Excel", + "noTimeLogsFound": "No time logs found", + "timeLogForm": { + "date": "Date", + "startTime": "Start Time", + "endTime": "End Time", + "workDescription": "Work Description", + "descriptionPlaceholder": "Add a description", + "logTime": "Log time", + "updateTime": "Update time", + "cancel": "Cancel", + "selectDateError": "Please select a date", + "selectStartTimeError": "Please select start time", + "selectEndTimeError": "Please select end time", + "endTimeAfterStartError": "End time must be after start time" + } + }, + "taskActivityLogTab": { + "title": "Activity Log", + "add": "ADD", + "remove": "REMOVE", + "none": "None", + "weight": "Weight", + "createdTask": "created the task." + }, + "taskProgress": { + "markAsDoneTitle": "Mark Task as Done?", + "confirmMarkAsDone": "Yes, mark as done", + "cancelMarkAsDone": "No, keep current status", + "markAsDoneDescription": "You've set the progress to 100%. Would you like to update the task status to \"Done\"?" + } +} diff --git a/worklenz-backend/src/public/locales/en/task-list-filters.json b/worklenz-backend/src/public/locales/en/task-list-filters.json new file mode 100644 index 00000000..a38356c6 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/task-list-filters.json @@ -0,0 +1,85 @@ +{ + "searchButton": "Search", + "resetButton": "Reset", + "searchInputPlaceholder": "Search by name", + + "sortText": "Sort", + "statusText": "Status", + "phaseText": "Phase", + "memberText": "Members", + "assigneesText": "Assignees", + "priorityText": "Priority", + "labelsText": "Labels", + "membersText": "Members", + "groupByText": "Group by", + "showArchivedText": "Show archived", + "showFieldsText": "Show fields", + "keyText": "Key", + "taskText": "Task", + "descriptionText": "Description", + "phasesText": "Phases", + "listText": "List", + "progressText": "Progress", + "timeTrackingText": "Time Tracking", + "timetrackingText": "Time Tracking", + "estimationText": "Estimation", + "startDateText": "Start Date", + "startdateText": "Start Date", + "endDateText": "End Date", + "dueDateText": "Due Date", + "duedateText": "Due Date", + "completedDateText": "Completed Date", + "completeddateText": "Completed Date", + "createdDateText": "Created Date", + "createddateText": "Created Date", + "lastUpdatedText": "Last Updated", + "lastupdatedText": "Last Updated", + "reporterText": "Reporter", + "dueTimeText": "Due Time", + "duetimeText": "Due Time", + + "lowText": "Low", + "mediumText": "Medium", + "highText": "High", + + "createStatusButtonTooltip": "Status settings", + "configPhaseButtonTooltip": "Phase settings", + "noLabelsFound": "No labels found", + + "addStatusButton": "Add Status", + "addPhaseButton": "Add Phase", + + "createStatus": "Create Status", + "name": "Name", + "category": "Category", + "selectCategory": "Select a category", + "pleaseEnterAName": "Please enter a name", + "pleaseSelectACategory": "Please select a category", + "create": "Create", + + "searchTasks": "Search tasks...", + "searchPlaceholder": "Search...", + "fieldsText": "Fields", + "loadingFilters": "Loading filters...", + "noOptionsFound": "No options found", + "filtersActive": "filters active", + "filterActive": "filter active", + "clearAll": "Clear all", + "clearing": "Clearing...", + "cancel": "Cancel", + "search": "Search", + "groupedBy": "Grouped by", + "manageStatuses": "Manage Statuses", + "managePhases": "Manage Phases", + "dragToReorderStatuses": "Drag statuses to reorder them. Each status can have a different category.", + "enterNewStatusName": "Enter new status name...", + "addStatus": "Add Status", + "noStatusesFound": "No statuses found. Create your first status above.", + "deleteStatus": "Delete Status", + "deleteStatusConfirm": "Are you sure you want to delete this status? This action cannot be undone.", + "rename": "Rename", + "delete": "Delete", + "enterStatusName": "Enter status name", + "selectCategory": "Select category", + "close": "Close" +} diff --git a/worklenz-backend/src/public/locales/en/task-list-table.json b/worklenz-backend/src/public/locales/en/task-list-table.json new file mode 100644 index 00000000..5c03f203 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/task-list-table.json @@ -0,0 +1,136 @@ +{ + "keyColumn": "Key", + "taskColumn": "Task", + "descriptionColumn": "Description", + "progressColumn": "Progress", + "membersColumn": "Members", + "assigneesColumn": "Assignees", + "labelsColumn": "Labels", + "phasesColumn": "Phases", + "phaseColumn": "Phase", + "statusColumn": "Status", + "priorityColumn": "Priority", + "timeTrackingColumn": "Time Tracking", + "timetrackingColumn": "Time Tracking", + "estimationColumn": "Estimation", + "startDateColumn": "Start Date", + "startdateColumn": "Start Date", + "dueDateColumn": "Due Date", + "duedateColumn": "Due Date", + "completedDateColumn": "Completed Date", + "completeddateColumn": "Completed Date", + "createdDateColumn": "Created Date", + "createddateColumn": "Created Date", + "lastUpdatedColumn": "Last Updated", + "lastupdatedColumn": "Last Updated", + "reporterColumn": "Reporter", + "dueTimeColumn": "Due Time", + "todoSelectorText": "To Do", + "doingSelectorText": "Doing", + "doneSelectorText": "Done", + + "lowSelectorText": "Low", + "mediumSelectorText": "Medium", + "highSelectorText": "High", + + "selectText": "Select", + "labelsSelectorInputTip": "Hit enter to create!", + + "addTaskText": "Add Task", + "addSubTaskText": "Add Sub Task", + "addTaskInputPlaceholder": "Type your task and hit enter", + "noTasksInGroup": "No tasks in this group", + + "openButton": "Open", + "okButton": "Ok", + + "noLabelsFound": "No labels found", + "searchInputPlaceholder": "Search or create", + "assigneeSelectorInviteButton": "Invite a new member by email", + "labelInputPlaceholder": "Search or create", + "searchLabelsPlaceholder": "Search labels...", + "createLabelButton": "Create \"{{name}}\"", + "manageLabelsPath": "Settings → Labels", + + "pendingInvitation": "Pending Invitation", + + "contextMenu": { + "assignToMe": "Assign to me", + "moveTo": "Move to", + "unarchive": "Unarchive", + "archive": "Archive", + "convertToSubTask": "Convert to Sub task", + "convertToTask": "Convert to Task", + "delete": "Delete", + "searchByNameInputPlaceholder": "Search by name" + }, + "setDueDate": "Set due date", + "setStartDate": "Set start date", + "clearDueDate": "Clear due date", + "clearStartDate": "Clear start date", + "dueDatePlaceholder": "Due Date", + "startDatePlaceholder": "Start Date", + + "emptyStates": { + "noTaskGroups": "No task groups found", + "noTaskGroupsDescription": "Tasks will appear here when they are created or when filters are applied.", + "errorPrefix": "Error:", + "dragTaskFallback": "Task" + }, + + "customColumns": { + "addCustomColumn": "Add a custom column", + "customColumnHeader": "Custom Column", + "customColumnSettings": "Custom column settings", + "noCustomValue": "No value", + "peopleField": "People field", + "noDate": "No date", + "unsupportedField": "Unsupported field type", + + "modal": { + "addFieldTitle": "Add field", + "editFieldTitle": "Edit field", + "fieldTitle": "Field title", + "fieldTitleRequired": "Field title is required", + "columnTitlePlaceholder": "Column title", + "type": "Type", + "deleteConfirmTitle": "Are you sure you want to delete this custom column?", + "deleteConfirmDescription": "This action cannot be undone. All data associated with this column will be permanently deleted.", + "deleteButton": "Delete", + "cancelButton": "Cancel", + "createButton": "Create", + "updateButton": "Update", + "createSuccessMessage": "Custom column created successfully", + "updateSuccessMessage": "Custom column updated successfully", + "deleteSuccessMessage": "Custom column deleted successfully", + "deleteErrorMessage": "Failed to delete custom column", + "createErrorMessage": "Failed to create custom column", + "updateErrorMessage": "Failed to update custom column" + }, + + "fieldTypes": { + "people": "People", + "number": "Number", + "date": "Date", + "selection": "Selection", + "checkbox": "Checkbox", + "labels": "Labels", + "key": "Key", + "formula": "Formula" + } + }, + + "indicators": { + "tooltips": { + "subtasks": "{{count}} subtask", + "subtasks_plural": "{{count}} subtasks", + "comments": "{{count}} comment", + "comments_plural": "{{count}} comments", + "attachments": "{{count}} attachment", + "attachments_plural": "{{count}} attachments", + "subscribers": "Task has subscribers", + "dependencies": "Task has dependencies", + "recurring": "Recurring task" + } + } +} diff --git a/worklenz-backend/src/public/locales/en/task-management.json b/worklenz-backend/src/public/locales/en/task-management.json new file mode 100644 index 00000000..2d21c746 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/task-management.json @@ -0,0 +1,35 @@ +{ + "noTasksInGroup": "No tasks in this group", + "noTasksInGroupDescription": "Add a task to get started", + "addFirstTask": "Add your first task", + "openTask": "Open", + "subtask": "subtask", + "subtasks": "subtasks", + "comment": "comment", + "comments": "comments", + "attachment": "attachment", + "attachments": "attachments", + "enterSubtaskName": "Enter subtask name...", + "add": "Add", + "cancel": "Cancel", + "renameGroup": "Rename Group", + "renameStatus": "Rename Status", + "renamePhase": "Rename Phase", + "changeCategory": "Change Category", + "clickToEditGroupName": "Click to edit group name", + "enterGroupName": "Enter group name", + + "indicators": { + "tooltips": { + "subtasks": "{{count}} subtask", + "subtasks_plural": "{{count}} subtasks", + "comments": "{{count}} comment", + "comments_plural": "{{count}} comments", + "attachments": "{{count}} attachment", + "attachments_plural": "{{count}} attachments", + "subscribers": "Task has subscribers", + "dependencies": "Task has dependencies", + "recurring": "Recurring task" + } + } +} diff --git a/worklenz-backend/src/public/locales/en/task-template-drawer.json b/worklenz-backend/src/public/locales/en/task-template-drawer.json new file mode 100644 index 00000000..9bc59126 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/task-template-drawer.json @@ -0,0 +1,12 @@ +{ + "createTaskTemplate": "Create Task Template", + "editTaskTemplate": "Edit Task Template", + "cancelText": "Cancel", + "saveText": "Save", + "templateNameText": "Template Name", + "templateNameRequired": "Template name is required", + "selectedTasks": "Selected Tasks", + "removeTask": "Remove", + "cancelButton": "Cancel", + "saveButton": "Save" +} diff --git a/worklenz-backend/src/public/locales/en/tasks/task-table-bulk-actions.json b/worklenz-backend/src/public/locales/en/tasks/task-table-bulk-actions.json new file mode 100644 index 00000000..42fcc024 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/tasks/task-table-bulk-actions.json @@ -0,0 +1,41 @@ +{ + "taskSelected": "task selected", + "tasksSelected": "tasks selected", + "changeStatus": "Change Status/ Prioriy/ Phases", + "changeLabel": "Change Label", + "assignToMe": "Assign to me", + "changeAssignees": "Change Assignees", + "archive": "Archive", + "unarchive": "Unarchive", + "delete": "Delete", + "moreOptions": "More options", + "deselectAll": "Deselect all", + "status": "Status", + "priority": "Priority", + "phase": "Phase", + "member": "Member", + "createTaskTemplate": "Create Task Template", + "apply": "Apply", + "createLabel": "+ Create Label", + "searchOrCreateLabel": "Search or create label...", + "hitEnterToCreate": "Press Enter to create", + "labelExists": "Label already exists", + "pendingInvitation": "Pending Invitation", + "noMatchingLabels": "No matching labels", + "noLabels": "No labels", + "CHANGE_STATUS": "Change Status", + "CHANGE_PRIORITY": "Change Priority", + "CHANGE_PHASE": "Change Phase", + "ADD_LABELS": "Add Labels", + "ASSIGN_TO_ME": "Assign to Me", + "ASSIGN_MEMBERS": "Assign Members", + "ARCHIVE": "Archive", + "DELETE": "Delete", + "CANCEL": "Cancel", + "CLEAR_SELECTION": "Clear Selection", + "TASKS_SELECTED": "{{count}} task selected", + "TASKS_SELECTED_plural": "{{count}} tasks selected", + "DELETE_TASKS_CONFIRM": "Delete {{count}} task?", + "DELETE_TASKS_CONFIRM_plural": "Delete {{count}} tasks?", + "DELETE_TASKS_WARNING": "This action cannot be undone." +} diff --git a/worklenz-backend/src/public/locales/en/template-drawer.json b/worklenz-backend/src/public/locales/en/template-drawer.json new file mode 100644 index 00000000..55364835 --- /dev/null +++ b/worklenz-backend/src/public/locales/en/template-drawer.json @@ -0,0 +1,19 @@ +{ + "title": "Edit Task Template", + "cancelText": "Cancel", + "saveText": "Save", + "templateNameText": "Template Name", + "selectedTasks": "Selected Tasks", + "removeTask": "Remove", + "description": "Description", + "phase": "Phase", + "statuses": "Statuses", + "priorities": "Priorities", + "labels": "Labels", + "tasks": "Tasks", + "noTemplateSelected": "No template selected", + "noDescription": "No description", + "worklenzTemplates": "Worklenz Templates", + "yourTemplatesLibrary": "Your Library", + "searchTemplates": "Search Templates" +} diff --git a/worklenz-backend/src/public/locales/en/templateDrawer.json b/worklenz-backend/src/public/locales/en/templateDrawer.json new file mode 100644 index 00000000..70bf2b0c --- /dev/null +++ b/worklenz-backend/src/public/locales/en/templateDrawer.json @@ -0,0 +1,23 @@ +{ + "bugTracking": "Bug Tracking", + "construction": "Construction", + "designCreative": "Design & Creative", + "education": "Education", + "finance": "Finance", + "hrRecruiting": "HR & Recruiting", + "informationTechnology": "Information Technology", + "legal": "Legal", + "manufacturing": "Manufacturing", + "marketing": "Marketing", + "nonprofit": "Nonprofit", + "personalUse": "Personal use", + "salesCRM": "Sales & CRM", + "serviceConsulting": "Service & Consulting", + "softwareDevelopment": "Software Development", + "description": "Description", + "phase": "Phase", + "statuses": "Statuses", + "priorities": "Priorities", + "labels": "Labels", + "tasks": "Tasks" +} diff --git a/worklenz-backend/src/public/locales/en/time-report.json b/worklenz-backend/src/public/locales/en/time-report.json new file mode 100644 index 00000000..00aa3c7f --- /dev/null +++ b/worklenz-backend/src/public/locales/en/time-report.json @@ -0,0 +1,57 @@ +{ + "includeArchivedProjects": "Include Archived Projects", + "export": "Export", + "timeSheet": "Time Sheet", + + "searchByName": "Search by name", + "selectAll": "Select All", + "teams": "Teams", + + "searchByProject": "Search by project name", + "projects": "Projects", + + "searchByCategory": "Search by category name", + "categories": "Categories", + + "billable": "Billable", + "nonBillable": "Non Billable", + + "total": "Total", + + "projectsTimeSheet": "Projects Time Sheet", + + "loggedTime": "Logged Time(hours)", + + "exportToExcel": "Export to Excel", + "logged": "logged", + "for": "for", + + "membersTimeSheet": "Members Time Sheet", + "member": "Member", + + "estimatedVsActual": "Estimated vs Actual", + "workingDays": "Working Days", + "manDays": "Man Days", + "days": "Days", + "estimatedDays": "Estimated Days", + "actualDays": "Actual Days", + + "noCategories": "No categories found", + "noCategory": "No Category", + "noProjects": "No projects found", + "noTeams": "No teams found", + "noData": "No data found", + + "groupBy": "Group by", + "groupByCategory": "Category", + "groupByTeam": "Team", + "groupByStatus": "Status", + "groupByNone": "None", + "clearSearch": "Clear search", + "selectedProjects": "Selected Projects", + "projectsSelected": "projects selected", + "showSelected": "Show Selected Only", + "expandAll": "Expand All", + "collapseAll": "Collapse All", + "ungrouped": "Ungrouped" +} diff --git a/worklenz-backend/src/public/locales/en/unauthorized.json b/worklenz-backend/src/public/locales/en/unauthorized.json new file mode 100644 index 00000000..5233250a --- /dev/null +++ b/worklenz-backend/src/public/locales/en/unauthorized.json @@ -0,0 +1,5 @@ +{ + "title": "Unauthorized!", + "subtitle": "You are not authorized to access this page", + "button": "Go to Home" +} diff --git a/worklenz-backend/src/public/locales/es/404-page.json b/worklenz-backend/src/public/locales/es/404-page.json new file mode 100644 index 00000000..9413ae9e --- /dev/null +++ b/worklenz-backend/src/public/locales/es/404-page.json @@ -0,0 +1,4 @@ +{ + "doesNotExistText": "Lo sentimos, la página que visitaste no existe.", + "backHomeButton": "Volver al inicio" +} diff --git a/worklenz-backend/src/public/locales/es/account-setup.json b/worklenz-backend/src/public/locales/es/account-setup.json new file mode 100644 index 00000000..3f7b013e --- /dev/null +++ b/worklenz-backend/src/public/locales/es/account-setup.json @@ -0,0 +1,32 @@ +{ + "continue": "Continuar", + + "setupYourAccount": "Configura tu cuenta.", + "organizationStepTitle": "Nombra tu organización", + "organizationStepLabel": "Elige un nombre para tu cuenta de Worklenz.", + + "projectStepTitle": "Crea tu primer proyecto", + "projectStepLabel": "¿En qué proyecto estás trabajando ahora?", + "projectStepPlaceholder": "e.g. Plan de Marketing", + + "step2Title": "Crea tus primeras tareas", + "step2InputLabel": "Escribe algunas tareas que vas a hacer en", + "step2AddAnother": "Agregar otro", + + "emailPlaceholder": "Dirección de correo electrónico", + "invalidEmail": "Por favor, introduce una dirección de correo electrónico válida", + "or": "o", + "templateButton": "Importar desde plantilla", + "goBack": "Volver", + "cancel": "Cancelar", + "create": "Crear", + "templateDrawerTitle": "Seleccionar de plantillas", + "step3InputLabel": "Invitar por correo electrónico", + "addAnother": "Agregar otro", + "skipForNow": "Omitir por ahora", + "formTitle": "Crea tu primera tarea.", + "step3Title": "Invita a tu equipo a trabajar", + + "maxMembers": " (Puedes invitar hasta 5 miembros)", + "maxTasks": " (Puedes crear hasta 5 tareas)" +} diff --git a/worklenz-backend/src/public/locales/es/admin-center/current-bill.json b/worklenz-backend/src/public/locales/es/admin-center/current-bill.json new file mode 100644 index 00000000..52a4bdbb --- /dev/null +++ b/worklenz-backend/src/public/locales/es/admin-center/current-bill.json @@ -0,0 +1,113 @@ +{ + "title": "Facturación", + "currentBill": "Factura Actual", + "configuration": "Configuración", + "currentPlanDetails": "Detalles del Plan Actual", + "upgradePlan": "Actualizar Plan", + "cardBodyText01": "Prueba gratuita", + "cardBodyText02": "(Tu plan de prueba expira en 1 mes 19 días)", + "redeemCode": "Canjear Código", + "accountStorage": "Almacenamiento de la Cuenta", + "used": "Usado:", + "remaining": "Restante:", + "charges": "Cargos", + "tooltip": "Cargos para el ciclo de facturación actual", + "description": "Descripción", + "billingPeriod": "Periodo de Facturación", + "billStatus": "Estado de la Factura", + "perUserValue": "Valor por Usuario", + "users": "Usuarios", + "amount": "Cantidad", + "invoices": "Facturas", + "transactionId": "ID de Transacción", + "transactionDate": "Fecha de Transacción", + "paymentMethod": "Método de Pago", + "status": "Estado", + "ltdUsers": "Puedes agregar hasta {{ltd_users}} usuarios.", + + "drawerTitle": "Canjear Código", + "label": "Canjear Código", + "drawerPlaceholder": "Ingrese su código de canje", + "redeemSubmit": "Enviar", + + "modalTitle": "Seleccione el mejor plan para su equipo", + "seatLabel": "Número de asientos", + "freePlan": "Plan Gratuito", + "startup": "Startup", + "business": "Negocio", + "tag": "Más Popular", + "enterprise": "Empresa", + + "freeSubtitle": "gratis para siempre", + "freeUsers": "Mejor para uso personal", + "freeText01": "100MB de almacenamiento", + "freeText02": "3 proyectos", + "freeText03": "5 miembros del equipo", + + "startupSubtitle": "TARIFA PLANa / mes", + "startupUsers": "Hasta 15 usuarios", + "startupText01": "25GB de almacenamiento", + "startupText02": "Proyectos activos ilimitados", + "startupText03": "Programación", + "startupText04": "Informes", + "startupText05": "Suscribirse a proyectos", + + "businessSubtitle": "usuario / mes", + "businessUsers": "16 - 200 usuarios", + + "enterpriseUsers": "200 - 500+ usuarios", + + "footerTitle": "Por favor, proporciónenos un número de teléfono que podamos usar para contactarte.", + "footerLabel": "Número de Teléfono", + "footerButton": "Contactarnos", + + "redeemCodePlaceHolder": "Ingrese su código de canje", + "submit": "Enviar", + + "trialPlan": "Plan de Prueba", + "trialExpireDate": "Válido hasta {{trial_expire_date}}", + "trialExpired": "Su prueba gratuita expiró {{trial_expire_string}}", + "trialInProgress": "Su prueba gratuita expira {{trial_expire_string}}", + + "required": "Este campo es requerido", + "invalidCode": "Código inválido", + + "selectPlan": "Seleccione el mejor plan para su equipo", + "changeSubscriptionPlan": "Cambie su plan de suscripción", + "noOfSeats": "Número de asientos", + "annualPlan": "Pro - Anual", + "monthlyPlan": "Pro - Mensual", + "freeForever": "Gratis para siempre", + "bestForPersonalUse": "Mejor para uso personal", + "storage": "Almacenamiento", + "projects": "Proyectos", + "teamMembers": "Miembros del equipo", + "unlimitedTeamMembers": "Miembros del equipo ilimitados", + "unlimitedActiveProjects": "Proyectos activos ilimitados", + "schedule": "Programación", + "reporting": "Informes", + "subscribeToProjects": "Suscribirse a proyectos", + "billedAnnually": "Facturado Anualmente", + "billedMonthly": "Facturado Mensualmente", + + "pausePlan": "Pausar Plan", + "resumePlan": "Reanudar Plan", + "changePlan": "Cambiar Plan", + "cancelPlan": "Cancelar Plan", + + "perMonthPerUser": "por usuario / mes", + "viewInvoice": "Ver Factura", + "switchToFreePlan": "Cambiar a Plan Gratuito", + + "expirestoday": "hoy", + "expirestomorrow": "mañana", + "expiredDaysAgo": "hace {{days}} días", + "creditPlan": "Plan de Crédito", + "customPlan": "Plan Personalizado", + "planValidTill": "Su plan es válido hasta {{date}}", + "purchaseSeatsText": "Para continuar, deberá comprar asientos adicionales.", + "currentSeatsText": "Actualmente tiene {{seats}} asientos disponibles.", + "selectSeatsText": "Seleccione el número de asientos adicionales a comprar.", + "purchase": "Comprar", + "contactSales": "Contactar ventas" +} diff --git a/worklenz-backend/src/public/locales/es/admin-center/overview.json b/worklenz-backend/src/public/locales/es/admin-center/overview.json new file mode 100644 index 00000000..f88dbdf6 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/admin-center/overview.json @@ -0,0 +1,8 @@ +{ + "overview": "Resumen", + "name": "Nombre de la Organización", + "owner": "Propietario de la Organización", + "admins": "Administradores de la Organización", + "contactNumber": "Agregar Número de Contacto", + "edit": "Editar" +} diff --git a/worklenz-backend/src/public/locales/es/admin-center/projects.json b/worklenz-backend/src/public/locales/es/admin-center/projects.json new file mode 100644 index 00000000..ab28374f --- /dev/null +++ b/worklenz-backend/src/public/locales/es/admin-center/projects.json @@ -0,0 +1,12 @@ +{ + "membersCount": "Cantidad de miembros", + "createdAt": "Creado en", + "projectName": "Nombre del proyecto", + "teamName": "Nombre del equipo", + "refreshProjects": "Refrescar proyectos", + "searchPlaceholder": "Buscar por nombre de proyecto", + "deleteProject": "¿Estás seguro de que deseas eliminar este proyecto?", + "confirm": "Confirmar", + "cancel": "Cancelar", + "delete": "Eliminar proyecto" +} diff --git a/worklenz-backend/src/public/locales/es/admin-center/sidebar.json b/worklenz-backend/src/public/locales/es/admin-center/sidebar.json new file mode 100644 index 00000000..7626302c --- /dev/null +++ b/worklenz-backend/src/public/locales/es/admin-center/sidebar.json @@ -0,0 +1,8 @@ +{ + "overview": "Resumen", + "users": "Usuarios", + "teams": "Equipos", + "billing": "Facturación", + "projects": "Proyectos", + "adminCenter": "Centro de Administración" +} diff --git a/worklenz-backend/src/public/locales/es/admin-center/teams.json b/worklenz-backend/src/public/locales/es/admin-center/teams.json new file mode 100644 index 00000000..13453656 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/admin-center/teams.json @@ -0,0 +1,35 @@ +{ + "title": "Equipos", + "subtitle": "equipos", + "tooltip": "Actualizar equipos", + "placeholder": "Buscar por nombre", + "addTeam": "Agregar Equipo", + "team": "Equipo", + "membersCount": "Cantidad de Miembros", + "members": "Miembros", + "drawerTitle": "Crear Nuevo Equipo", + "label": "Nombre del Equipo", + "drawerPlaceholder": "Nombre", + "create": "Crear", + "delete": "Eliminar", + "settings": "Configuración", + "popTitle": "¿Está seguro?", + "message": "Por favor ingrese un nombre", + "teamSettings": "Configuración del Equipo", + "teamName": "Nombre del Equipo", + "teamDescription": "Descripción del Equipo", + "teamMembers": "Miembros del Equipo", + "teamMembersCount": "Cantidad de Miembros del Equipo", + "teamMembersPlaceholder": "Buscar por nombre", + "addMember": "Agregar Miembro", + "add": "Agregar", + "update": "Actualizar", + "teamNamePlaceholder": "Nombre del Equipo", + "user": "Usuario", + "role": "Rol", + "owner": "Propietario", + "admin": "Administrador", + "member": "Miembro", + "cannotChangeOwnerRole": "El rol de Propietario no puede ser cambiado", + "pendingInvitation": "Invitación pendiente" +} diff --git a/worklenz-backend/src/public/locales/es/admin-center/users.json b/worklenz-backend/src/public/locales/es/admin-center/users.json new file mode 100644 index 00000000..05626c3b --- /dev/null +++ b/worklenz-backend/src/public/locales/es/admin-center/users.json @@ -0,0 +1,9 @@ +{ + "title": "Usuarios", + "subTitle": "usuarios", + "placeholder": "Buscar por nombre", + "user": "Usuario", + "email": "Correo electrónico", + "lastActivity": "Última actividad", + "refresh": "Actualizar usuarios" +} diff --git a/worklenz-backend/src/public/locales/es/all-project-list.json b/worklenz-backend/src/public/locales/es/all-project-list.json new file mode 100644 index 00000000..4a72d9c7 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/all-project-list.json @@ -0,0 +1,34 @@ +{ + "name": "Nombre", + "client": "Cliente", + "category": "Categoría", + "status": "Estado", + "tasksProgress": "Progreso de Tareas", + "updated_at": "Última Actualización", + "members": "Miembros", + "setting": "Configuración", + "projects": "Proyectos", + "refreshProjects": "Actualizar proyectos", + "all": "Todos", + "favorites": "Favoritos", + "archived": "Archivados", + "placeholder": "Buscar por nombre", + "archive": "Archivar", + "unarchive": "Desarchivar", + "archiveConfirm": "¿Está seguro de que desea archivar este proyecto?", + "unarchiveConfirm": "¿Está seguro de que desea desarchivar este proyecto?", + "yes": "Sí", + "no": "No", + "clickToFilter": "Haga clic para filtrar por", + "noProjects": "No se encontraron proyectos", + "addToFavourites": "Agregar a favoritos", + "list": "Lista", + "group": "Grupo", + "listView": "Vista de Lista", + "groupView": "Vista de Grupo", + "groupBy": { + "category": "Categoría", + "client": "Cliente" + }, + "noPermission": "No tienes permiso para realizar esta acción" +} diff --git a/worklenz-backend/src/public/locales/es/auth/auth-common.json b/worklenz-backend/src/public/locales/es/auth/auth-common.json new file mode 100644 index 00000000..6539ec51 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/auth/auth-common.json @@ -0,0 +1,5 @@ +{ + "loggingOut": "Cerrando sesión...", + "authenticating": "Autenticando...", + "gettingThingsReady": "Preparando todo para ti..." +} diff --git a/worklenz-backend/src/public/locales/es/auth/forgot-password.json b/worklenz-backend/src/public/locales/es/auth/forgot-password.json new file mode 100644 index 00000000..5ba75336 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/auth/forgot-password.json @@ -0,0 +1,12 @@ +{ + "headerDescription": "Restablecer tu contraseña", + "emailLabel": "Correo electrónico", + "emailPlaceholder": "Ingresa tu correo electrónico", + "emailRequired": "¡Por favor ingresa tu correo electrónico!", + "resetPasswordButton": "Restablecer Contraseña", + "returnToLoginButton": "Volver al Inicio de Sesión", + "passwordResetSuccessMessage": "Se ha enviado un enlace para restablecer la contraseña a tu correo electrónico.", + "orText": "O", + "successTitle": "¡Instrucciones de restablecimiento enviadas!", + "successMessage": "La información de restablecimiento se ha enviado a tu correo electrónico. Por favor, verifica tu correo." +} diff --git a/worklenz-backend/src/public/locales/es/auth/login.json b/worklenz-backend/src/public/locales/es/auth/login.json new file mode 100644 index 00000000..8c1697f8 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/auth/login.json @@ -0,0 +1,27 @@ +{ + "headerDescription": "Inicia sesión en tu cuenta", + "emailLabel": "Correo electrónico", + "emailPlaceholder": "Ingresa tu correo electrónico", + "emailRequired": "¡Por favor ingresa tu correo electrónico!", + "passwordLabel": "Contraseña", + "passwordPlaceholder": "Ingresa tu contraseña", + "passwordRequired": "¡Por favor ingresa tu contraseña!", + "rememberMe": "Recordarme", + "loginButton": "Iniciar sesión", + "signupButton": "Registrarse", + "forgotPasswordButton": "¿Olvidaste tu contraseña?", + "signInWithGoogleButton": "Iniciar sesión con Google", + "successMessage": "¡Has iniciado sesión exitosamente!", + "dontHaveAccountText": "¿No tienes una cuenta?", + "orText": "O", + "loginError": "Iniciar sesión falló", + "googleLoginError": "Iniciar sesión con Google falló", + "validationMessages": { + "password": "La contraseña debe tener al menos 8 caracteres", + "email": "Por favor ingresa una dirección de correo electrónico válida" + }, + "errorMessages": { + "loginErrorTitle": "Iniciar sesión falló", + "loginErrorMessage": "Por favor verifica tu correo electrónico y contraseña y vuelve a intentarlo" + } +} diff --git a/worklenz-backend/src/public/locales/es/auth/signup.json b/worklenz-backend/src/public/locales/es/auth/signup.json new file mode 100644 index 00000000..465ff287 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/auth/signup.json @@ -0,0 +1,29 @@ +{ + "headerDescription": "Regístrate para comenzar", + "nameLabel": "Nombre completo", + "namePlaceholder": "Ingresa tu nombre completo", + "nameRequired": "¡Por favor ingresa tu nombre completo!", + "nameMinCharacterRequired": "¡El nombre completo debe tener al menos 4 caracteres!", + "emailLabel": "Correo electrónico", + "emailPlaceholder": "Ingresa tu correo electrónico", + "emailRequired": "¡Por favor ingresa tu correo electrónico!", + "passwordLabel": "Contraseña", + "passwordPlaceholder": "Ingresa tu contraseña", + "passwordRequired": "¡Por favor ingresa tu contraseña!", + "passwordMinCharacterRequired": "¡La contraseña debe tener al menos 8 caracteres!", + "passwordPatternRequired": "¡La contraseña no cumple con los requisitos!", + "strongPasswordPlaceholder": "Ingresa una contraseña más segura", + "passwordValidationAltText": "La contraseña debe incluir al menos 8 caracteres con letras mayúsculas y minúsculas, un número y un símbolo.", + "signupSuccessMessage": "¡Te has registrado exitosamente!", + "privacyPolicyLink": "Política de Privacidad", + "termsOfUseLink": "Términos de Uso", + "bySigningUpText": "Al registrarte, aceptas nuestra", + "andText": "y", + "signupButton": "Registrarse", + "signInWithGoogleButton": "Iniciar sesión con Google", + "alreadyHaveAccountText": "¿Ya tienes una cuenta?", + "loginButton": "Iniciar sesión", + "orText": "O", + "reCAPTCHAVerificationError": "Error de verificación de reCAPTCHA", + "reCAPTCHAVerificationErrorMessage": "No pudimos verificar tu reCAPTCHA. Por favor, inténtalo de nuevo." +} diff --git a/worklenz-backend/src/public/locales/es/auth/verify-reset-email.json b/worklenz-backend/src/public/locales/es/auth/verify-reset-email.json new file mode 100644 index 00000000..1058bc1c --- /dev/null +++ b/worklenz-backend/src/public/locales/es/auth/verify-reset-email.json @@ -0,0 +1,14 @@ +{ + "title": "Verificar correo de restablecimiento", + "description": "Ingresa tu nueva contraseña", + "placeholder": "Ingresa tu nueva contraseña", + "confirmPasswordPlaceholder": "Confirma tu nueva contraseña", + "passwordHint": "Mínimo 8 caracteres, con mayúsculas y minúsculas, un número y un símbolo.", + "resetPasswordButton": "Restablecer contraseña", + "orText": "O", + "resendResetEmail": "Reenviar correo de restablecimiento", + "passwordRequired": "Por favor ingresa tu nueva contraseña", + "returnToLoginButton": "Volver al inicio de sesión", + "confirmPasswordRequired": "Por favor confirma tu nueva contraseña", + "passwordMismatch": "Las contraseñas no coinciden" +} diff --git a/worklenz-backend/src/public/locales/es/common.json b/worklenz-backend/src/public/locales/es/common.json new file mode 100644 index 00000000..583e8670 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/common.json @@ -0,0 +1,9 @@ +{ + "login-success": "¡Inicio de sesión exitoso!", + "login-failed": "Error al iniciar sesión. Por favor verifica tus credenciales e intenta nuevamente.", + "signup-success": "¡Registro exitoso! Bienvenido a bordo.", + "signup-failed": "Error al registrarse. Por favor asegúrate de llenar todos los campos requeridos e intenta nuevamente.", + "reconnecting": "Reconectando al servidor...", + "connection-lost": "Conexión perdida. Intentando reconectarse...", + "connection-restored": "Conexión restaurada. Reconectando al servidor..." +} diff --git a/worklenz-backend/src/public/locales/es/create-first-project-form.json b/worklenz-backend/src/public/locales/es/create-first-project-form.json new file mode 100644 index 00000000..4382cda5 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/create-first-project-form.json @@ -0,0 +1,13 @@ +{ + "formTitle": "Crea tu primer proyecto", + "inputLabel": "¿En qué proyecto estás trabajando ahora?", + "or": "o", + "templateButton": "Importar desde plantilla", + "createFromTemplate": "Crear desde plantilla", + "goBack": "Volver", + "continue": "Continuar", + "cancel": "Cancelar", + "create": "Crear", + "templateDrawerTitle": "Seleccionar de plantillas", + "createProject": "Crear proyecto" +} diff --git a/worklenz-backend/src/public/locales/es/create-first-tasks.json b/worklenz-backend/src/public/locales/es/create-first-tasks.json new file mode 100644 index 00000000..adca7366 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/create-first-tasks.json @@ -0,0 +1,7 @@ +{ + "formTitle": "Crea tu primera tarea.", + "inputLable": "Escribe algunas tareas que vas a hacer en", + "addAnother": "Agregar otra", + "goBack": "Volver", + "continue": "Continuar" +} diff --git a/worklenz-backend/src/public/locales/es/home.json b/worklenz-backend/src/public/locales/es/home.json new file mode 100644 index 00000000..cfd238f9 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/home.json @@ -0,0 +1,45 @@ +{ + "todoList": { + "title": "Lista de tareas", + "refreshTasks": "Actualizar tareas", + "addTask": "+ Agregar tarea", + "noTasks": "Sin tareas", + "pressEnter": "Presiona", + "toCreate": "para crear.", + "markAsDone": "Marcar como hecho" + }, + "projects": { + "title": "Proyectos", + "refreshProjects": "Actualizar proyectos", + "noRecentProjects": "Actualmente no estás asignado a ningún proyecto.", + "noFavouriteProjects": "No hay proyectos marcados como favoritos.", + "recent": "Recientes", + "favourites": "Favoritos" + }, + "tasks": { + "assignedToMe": "Asignadas a mí", + "assignedByMe": "Asignadas por mí", + "all": "Todas", + "today": "Hoy", + "upcoming": "Próximas", + "overdue": "Vencidas", + "noDueDate": "Sin fecha de vencimiento", + "noTasks": "No hay tareas para mostrar.", + "addTask": "+ Agregar tarea", + "name": "Nombre", + "project": "Proyecto", + "status": "Estado", + "dueDate": "Fecha de vencimiento", + "dueDatePlaceholder": "Establecer fecha de vencimiento", + "tomorrow": "Mañana", + "nextWeek": "La semana que viene", + "nextMonth": "El próximo mes", + "projectRequired": "Por favor selecciona un proyecto", + "dueOn": "Tareas vencidas el", + "taskRequired": "Por favor agrega una tarea", + "list": "Lista", + "calendar": "Calendario", + "tasks": "Tareas", + "refresh": "Actualizar" + } +} diff --git a/worklenz-backend/src/public/locales/es/invite-initial-team-members.json b/worklenz-backend/src/public/locales/es/invite-initial-team-members.json new file mode 100644 index 00000000..87fb006c --- /dev/null +++ b/worklenz-backend/src/public/locales/es/invite-initial-team-members.json @@ -0,0 +1,8 @@ +{ + "formTitle": "Invita a tu equipo a trabajar", + "inputLable": "Invitar por correo electrónico", + "addAnother": "Agregar otro", + "goBack": "Volver", + "continue": "Continuar", + "skipForNow": "Omitir por ahora" +} diff --git a/worklenz-backend/src/public/locales/es/kanban-board.json b/worklenz-backend/src/public/locales/es/kanban-board.json new file mode 100644 index 00000000..6e8d5975 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/kanban-board.json @@ -0,0 +1,30 @@ +{ + "rename": "Renombrar", + "delete": "Eliminar", + "addTask": "Agregar tarea", + "addSectionButton": "Agregar sección", + "changeCategory": "Cambiar categoría", + + "deleteTooltip": "Eliminar", + "deleteConfirmationTitle": "¿Estás seguro?", + "deleteConfirmationOk": "Sí", + "deleteConfirmationCancel": "Cancelar", + + "dueDate": "Fecha de vencimiento", + "cancel": "Cancelar", + + "today": "Hoy", + "tomorrow": "Mañana", + "assignToMe": "Asignarme", + "archive": "Archivar", + + "newTaskNamePlaceholder": "Escribe un nombre de tarea", + "newSubtaskNamePlaceholder": "Escribe un nombre de subtarea", + "untitledSection": "Sección sin título", + "unmapped": "Sin asignar", + "clickToChangeDate": "Haz clic para cambiar la fecha", + "noDueDate": "Sin fecha de vencimiento", + "save": "Guardar", + "clear": "Limpiar", + "nextWeek": "Próxima semana" +} diff --git a/worklenz-backend/src/public/locales/es/license-expired.json b/worklenz-backend/src/public/locales/es/license-expired.json new file mode 100644 index 00000000..3cd0de2d --- /dev/null +++ b/worklenz-backend/src/public/locales/es/license-expired.json @@ -0,0 +1,6 @@ +{ + "title": "¡Tu prueba de Worklenz ha expirado!", + "subtitle": "Por favor actualiza ahora.", + "button": "Actualizar ahora", + "checking": "Verificando estado de la suscripción..." +} diff --git a/worklenz-backend/src/public/locales/es/navbar.json b/worklenz-backend/src/public/locales/es/navbar.json new file mode 100644 index 00000000..97c79d50 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/navbar.json @@ -0,0 +1,31 @@ +{ + "logoAlt": "Logo de Worklenz", + "home": "Inicio", + "projects": "Proyectos", + "schedule": "Calendario", + "reporting": "Informes", + "clients": "Clientes", + "teams": "Equipos", + "labels": "Etiquetas", + "jobTitles": "Cargos", + "upgradePlan": "Actualizar Plan", + "upgradePlanTooltip": "Actualizar Plan", + "invite": "Invitar", + "inviteTooltip": "Invitar miembros al equipo", + "switchTeamTooltip": "Cambiar equipo", + "help": "Ayuda", + "notificationTooltip": "Ver notificaciones", + "profileTooltip": "Ver perfil", + "adminCenter": "Centro de administración", + "settings": "Configuración", + "logOut": "Cerrar sesión", + "notificationsDrawer": { + "read": "Notificaciones leídas", + "unread": "Notificaciones no leídas", + "markAsRead": "Marcar como leído", + "readAndJoin": "Leer y unirse", + "accept": "Aceptar", + "acceptAndJoin": "Aceptar y unirse", + "noNotifications": "Sin notificaciones" + } +} diff --git a/worklenz-backend/src/public/locales/es/organization-name-form.json b/worklenz-backend/src/public/locales/es/organization-name-form.json new file mode 100644 index 00000000..efd60ed7 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/organization-name-form.json @@ -0,0 +1,5 @@ +{ + "nameYourOrganization": "Nombra tu organización.", + "worklenzAccountTitle": "Elige un nombre para tu cuenta de Worklenz.", + "continue": "Continuar" +} diff --git a/worklenz-backend/src/public/locales/es/phases-drawer.json b/worklenz-backend/src/public/locales/es/phases-drawer.json new file mode 100644 index 00000000..e961b068 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/phases-drawer.json @@ -0,0 +1,19 @@ +{ + "configurePhases": "Configurar fases", + "phaseLabel": "Etiqueta de fase", + "enterPhaseName": "Ingrese un nombre para la etiqueta de fase", + "addOption": "Agregar opción", + "phaseOptions": "Opciones de fase:", + "dragToReorderPhases": "Arrastra las fases para reordenarlas. Cada fase puede tener un color diferente.", + "enterNewPhaseName": "Introducir nuevo nombre de fase...", + "addPhase": "Añadir Fase", + "noPhasesFound": "No se encontraron fases. Crea tu primera fase arriba.", + "deletePhase": "Eliminar Fase", + "deletePhaseConfirm": "¿Estás seguro de que quieres eliminar esta fase? Esta acción no se puede deshacer.", + "rename": "Renombrar", + "delete": "Eliminar", + "enterPhaseName": "Introducir nombre de la fase", + "selectColor": "Seleccionar color", + "managePhases": "Gestionar Fases", + "close": "Cerrar" +} diff --git a/worklenz-backend/src/public/locales/es/project-drawer.json b/worklenz-backend/src/public/locales/es/project-drawer.json new file mode 100644 index 00000000..447ad4f1 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/project-drawer.json @@ -0,0 +1,52 @@ +{ + "createProject": "Crear Proyecto", + "editProject": "Editar Proyecto", + "enterCategoryName": "Ingrese un nombre para la categoría", + "hitEnterToCreate": "¡Presiona enter para crear!", + "enterNotes": "Notas", + "youCanManageClientsUnderSettings": "Puedes gestionar clientes en Configuración", + "addCategory": "Agregar una categoría al proyecto", + "newCategory": "Nueva Categoría", + "notes": "Notas", + "startDate": "Fecha de Inicio", + "endDate": "Fecha de Finalización", + "estimateWorkingDays": "Estimar días de trabajo", + "estimateManDays": "Estimar días de trabajo", + "hoursPerDay": "Horas por día", + "create": "Crear", + "update": "Actualizar", + "delete": "Eliminar", + "typeToSearchClients": "Escribe para buscar clientes", + "projectColor": "Color del Proyecto", + "pleaseEnterAName": "Por favor ingresa un nombre", + "enterProjectName": "Ingresa el nombre del proyecto", + "name": "Nombre", + "status": "Estado", + "health": "Salud", + "category": "Categoría", + "projectManager": "Gerente de Proyecto", + "client": "Cliente", + "deleteConfirmation": "¿Estás seguro de que quieres eliminar?", + "deleteConfirmationDescription": "Esto eliminará todos los datos asociados y no se puede deshacer.", + "yes": "Sí", + "no": "No", + "createdAt": "Creado", + "updatedAt": "Actualizado", + "by": "por", + "add": "Agregar", + "asClient": "como cliente", + "createClient": "Crear cliente", + "searchInputPlaceholder": "Busca por nombre o email", + "hoursPerDayValidationMessage": "Las horas por día deben ser un número entre 1 y 24", + "workingDaysValidationMessage": "Los días de trabajo deben ser un número positivo", + "manDaysValidationMessage": "Los días hombre deben ser un número positivo", + "noPermission": "Sin permiso", + "progressSettings": "Configuración de Progreso", + "manualProgress": "Progreso Manual", + "manualProgressTooltip": "Permitir actualizaciones manuales de progreso para tareas sin subtareas", + "weightedProgress": "Progreso Ponderado", + "weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas", + "timeProgress": "Progreso Basado en Tiempo", + "timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado", + "enterProjectKey": "Ingresa la clave del proyecto" +} diff --git a/worklenz-backend/src/public/locales/es/project-view-files.json b/worklenz-backend/src/public/locales/es/project-view-files.json new file mode 100644 index 00000000..13071a2a --- /dev/null +++ b/worklenz-backend/src/public/locales/es/project-view-files.json @@ -0,0 +1,14 @@ +{ + "nameColumn": "Nombre", + "attachedTaskColumn": "Tarea Adjunta", + "sizeColumn": "Tamaño", + "uploadedByColumn": "Subido Por", + "uploadedAtColumn": "Subido El", + "fileIconAlt": "Icono de archivo", + "titleDescriptionText": "Todos los archivos adjuntos a las tareas en este proyecto aparecerán aquí.", + "deleteConfirmationTitle": "¿Estás seguro?", + "deleteConfirmationOk": "Sí", + "deleteConfirmationCancel": "Cancelar", + "segmentedTooltip": "¡Próximamente! Cambiar entre vista de lista y vista de miniaturas.", + "emptyText": "No hay archivos adjuntos en el proyecto." +} diff --git a/worklenz-backend/src/public/locales/es/project-view-insights.json b/worklenz-backend/src/public/locales/es/project-view-insights.json new file mode 100644 index 00000000..bd60b58e --- /dev/null +++ b/worklenz-backend/src/public/locales/es/project-view-insights.json @@ -0,0 +1,41 @@ +{ + "overview": { + "title": "Resumen", + "statusOverview": "Resumen de Estado", + "priorityOverview": "Resumen de Prioridad", + "lastUpdatedTasks": "Últimas Tareas Actualizadas" + }, + "members": { + "title": "Miembros", + "tooltip": "Miembros", + "tasksByMembers": "Tareas por miembros", + "tasksByMembersTooltip": "Tareas por miembros", + "name": "Nombre", + "taskCount": "Cantidad de Tareas", + "contribution": "Contribución", + "completed": "Completadas", + "incomplete": "Incompletas", + "overdue": "Atrasadas", + "progress": "Progreso" + }, + "tasks": { + "overdueTasks": "Tareas Atrasadas", + "overLoggedTasks": "Tareas con Exceso de Tiempo", + "tasksCompletedEarly": "Tareas completadas antes de tiempo", + "tasksCompletedLate": "Tareas completadas tarde", + "overLoggedTasksTooltip": "Tareas que tienen tiempo registrado más allá de su tiempo estimado", + "overdueTasksTooltip": "Tareas que están más allá de su fecha límite" + }, + "common": { + "seeAll": "Ver todo", + "totalLoggedHours": "Total de horas registradas", + "totalEstimation": "Estimación total", + "completedTasks": "Tareas completadas", + "incompleteTasks": "Tareas incompletas", + "overdueTasks": "Tareas atrasadas", + "overdueTasksTooltip": "Tareas que están más allá de su fecha límite", + "totalLoggedHoursTooltip": "Estimación de tareas y tiempo registrado para las tareas.", + "includeArchivedTasks": "Incluir Tareas Archivadas", + "export": "Exportar" + } +} diff --git a/worklenz-backend/src/public/locales/es/project-view-members.json b/worklenz-backend/src/public/locales/es/project-view-members.json new file mode 100644 index 00000000..95a8d943 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/project-view-members.json @@ -0,0 +1,17 @@ +{ + "nameColumn": "Nombre", + "jobTitleColumn": "Cargo", + "emailColumn": "Correo", + "tasksColumn": "Tareas", + "taskProgressColumn": "Progreso de Tareas", + "accessColumn": "Acceso", + "fileIconAlt": "Icono de archivo", + "deleteConfirmationTitle": "¿Estás seguro?", + "deleteConfirmationOk": "Sí", + "deleteConfirmationCancel": "Cancelar", + "refreshButtonTooltip": "Actualizar miembros", + "deleteButtonTooltip": "Eliminar del proyecto", + "memberCount": "Miembro", + "membersCountPlural": "Miembros", + "emptyText": "No hay archivos adjuntos en el proyecto." +} diff --git a/worklenz-backend/src/public/locales/es/project-view-updates.json b/worklenz-backend/src/public/locales/es/project-view-updates.json new file mode 100644 index 00000000..d565fcfc --- /dev/null +++ b/worklenz-backend/src/public/locales/es/project-view-updates.json @@ -0,0 +1,6 @@ +{ + "inputPlaceholder": "Agregar un comentario..", + "addButton": "Agregar", + "cancelButton": "Cancelar", + "deleteButton": "Eliminar" +} diff --git a/worklenz-backend/src/public/locales/es/project-view.json b/worklenz-backend/src/public/locales/es/project-view.json new file mode 100644 index 00000000..a4c12d9f --- /dev/null +++ b/worklenz-backend/src/public/locales/es/project-view.json @@ -0,0 +1,14 @@ +{ + "taskList": "Lista de Tareas", + "board": "Tablero Kanban", + "insights": "Análisis", + "files": "Archivos", + "members": "Miembros", + "updates": "Actualizaciones", + "projectView": "Vista del Proyecto", + "loading": "Cargando proyecto...", + "error": "Error al cargar el proyecto", + "pinnedTab": "Fijado como pestaña predeterminada", + "pinTab": "Fijar como pestaña predeterminada", + "unpinTab": "Desfijar pestaña predeterminada" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/es/project-view/import-task-templates.json b/worklenz-backend/src/public/locales/es/project-view/import-task-templates.json new file mode 100644 index 00000000..7be1539b --- /dev/null +++ b/worklenz-backend/src/public/locales/es/project-view/import-task-templates.json @@ -0,0 +1,11 @@ +{ + "importTaskTemplate": "Importar plantilla de tarea", + "templateName": "Nombre de la plantilla", + "templateDescription": "Descripción de la plantilla", + "selectedTasks": "Tareas seleccionadas", + "tasks": "Tareas", + "templates": "Plantillas", + "remove": "Eliminar", + "cancel": "Cancelar", + "import": "Importar" +} diff --git a/worklenz-backend/src/public/locales/es/project-view/project-member-drawer.json b/worklenz-backend/src/public/locales/es/project-view/project-member-drawer.json new file mode 100644 index 00000000..ab7570fd --- /dev/null +++ b/worklenz-backend/src/public/locales/es/project-view/project-member-drawer.json @@ -0,0 +1,7 @@ +{ + "title": "Miembros del Proyecto", + "searchLabel": "Agregar miembros ingresando su nombre o correo electrónico", + "searchPlaceholder": "Escriba nombre o correo electrónico", + "inviteAsAMember": "Invitar como miembro", + "inviteNewMemberByEmail": "Invitar nuevo miembro por correo electrónico" +} diff --git a/worklenz-backend/src/public/locales/es/project-view/project-view-header.json b/worklenz-backend/src/public/locales/es/project-view/project-view-header.json new file mode 100644 index 00000000..0215b89c --- /dev/null +++ b/worklenz-backend/src/public/locales/es/project-view/project-view-header.json @@ -0,0 +1,30 @@ +{ + "importTasks": "Importar tareas", + "importTask": "Importar tarea", + "createTask": "Crear tarea", + "settings": "Configuración", + "subscribe": "Suscribirse", + "unsubscribe": "Cancelar suscripción", + "deleteProject": "Eliminar proyecto", + "startDate": "Fecha de inicio", + "endDate": "Fecha de finalización", + "projectSettings": "Configuración del proyecto", + "projectSummary": "Resumen del proyecto", + "receiveProjectSummary": "Recibe un resumen del proyecto cada noche.", + "refreshProject": "Actualizar proyecto", + "saveAsTemplate": "Guardar como plantilla", + "invite": "Invitar", + "share": "Compartir", + "subscribeTooltip": "Suscribirse a notificaciones del proyecto", + "unsubscribeTooltip": "Cancelar suscripción a notificaciones del proyecto", + "refreshTooltip": "Actualizar datos del proyecto", + "settingsTooltip": "Abrir configuración del proyecto", + "saveAsTemplateTooltip": "Guardar este proyecto como plantilla", + "inviteTooltip": "Invitar miembros del equipo a este proyecto", + "createTaskTooltip": "Crear una nueva tarea", + "importTaskTooltip": "Importar tarea desde plantilla", + "navigateBackTooltip": "Volver a la lista de proyectos", + "projectStatusTooltip": "Estado del proyecto", + "projectDatesInfo": "Información de cronograma del proyecto", + "projectCategoryTooltip": "Categoría del proyecto" +} diff --git a/worklenz-backend/src/public/locales/es/project-view/save-as-template.json b/worklenz-backend/src/public/locales/es/project-view/save-as-template.json new file mode 100644 index 00000000..4d7e9354 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/project-view/save-as-template.json @@ -0,0 +1,27 @@ +{ + "title": "Guardar como Plantilla", + "templateName": "Nombre de la Plantilla", + "includes": "¿Qué se debe incluir en la plantilla del proyecto?", + "includesOptions": { + "statuses": "Estados", + "phases": "Fases", + "labels": "Etiquetas" + }, + "taskIncludes": "¿Qué se debe incluir en la plantilla de las tareas?", + "taskIncludesOptions": { + "statuses": "Estados", + "phases": "Fases", + "labels": "Etiquetas", + "name": "Nombre", + "priority": "Prioridad", + "status": "Estado", + "phase": "Fase", + "label": "Etiqueta", + "timeEstimate": "Estimación de Tiempo", + "description": "Descripción", + "subTasks": "Sub Tasks" + }, + "cancel": "Cancel", + "save": "Save", + "templateNamePlaceholder": "Enter template name" +} diff --git a/worklenz-backend/src/public/locales/es/reporting-members-drawer.json b/worklenz-backend/src/public/locales/es/reporting-members-drawer.json new file mode 100644 index 00000000..fb8ecf20 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/reporting-members-drawer.json @@ -0,0 +1,90 @@ +{ + "exportButton": "Exportar", + "timeLogsButton": "Registros de Tiempo", + "activityLogsButton": "Registros de Actividad", + "tasksButton": "Tareas", + "searchByNameInputPlaceholder": "Buscar por nombre", + + "overviewTab": "Resumen", + "timeLogsTab": "Registros de Tiempo", + "activityLogsTab": "Registros de Actividad", + "tasksTab": "Tareas", + + "projectsText": "Proyectos", + "totalTasksText": "Total de Tareas", + "assignedTasksText": "Tareas Asignadas", + "completedTasksText": "Tareas Completadas", + "ongoingTasksText": "Tareas en Curso", + "overdueTasksText": "Tareas Atrasadas", + "loggedHoursText": "Horas Registradas", + + "tasksText": "Tareas", + "allText": "Todo", + + "tasksByProjectsText": "Tareas por Proyectos", + "tasksByStatusText": "Tareas por Estado", + "tasksByPriorityText": "Tareas por Prioridad", + + "todoText": "Por Hacer", + "doingText": "Haciendo", + "doneText": "Hecho", + "lowText": "Baja", + "mediumText": "Media", + "highText": "Alta", + + "billableButton": "Facturable", + "billableText": "Facturable", + "nonBillableText": "No Facturable", + + "timeLogsEmptyPlaceholder": "No hay registros de tiempo para mostrar", + "loggedText": "Registrado", + "forText": "para", + "inText": "en", + "updatedText": "Actualizado", + "fromText": "Desde", + "toText": "hasta", + "withinText": "dentro de", + + "activityLogsEmptyPlaceholder": "No hay registros de actividad para mostrar", + + "filterByText": "Filtrar por:", + "selectProjectPlaceholder": "Seleccionar Proyecto", + + "taskColumn": "Tarea", + "nameColumn": "Nombre", + "projectColumn": "Proyecto", + "statusColumn": "Estado", + "priorityColumn": "Prioridad", + "dueDateColumn": "Fecha de Vencimiento", + "completedDateColumn": "Fecha de Finalización", + "estimatedTimeColumn": "Tiempo Estimado", + "loggedTimeColumn": "Tiempo Registrado", + "overloggedTimeColumn": "Tiempo Excedido", + "daysLeftColumn": "Días Restantes/Atrasados", + "startDateColumn": "Fecha de Inicio", + "endDateColumn": "Fecha de Fin", + "actualTimeColumn": "Tiempo Real", + "projectHealthColumn": "Salud del Proyecto", + "categoryColumn": "Categoría", + "projectManagerColumn": "Gerente de Proyecto", + + "tasksStatsOverviewDrawerTitle": "Tareas de", + "projectsStatsOverviewDrawerTitle": "Proyectos de", + + "cancelledText": "Cancelado", + "blockedText": "Bloqueado", + "onHoldText": "En Espera", + "proposedText": "Propuesto", + "inPlanningText": "En Planificación", + "inProgressText": "En Progreso", + "completedText": "Completado", + "continuousText": "Continuo", + + "daysLeftText": "días restantes", + "daysOverdueText": "días de retraso", + + "notSetText": "No Establecido", + "needsAttentionText": "Necesita Atención", + "atRiskText": "En Riesgo", + "goodText": "Bien" +} diff --git a/worklenz-backend/src/public/locales/es/reporting-members.json b/worklenz-backend/src/public/locales/es/reporting-members.json new file mode 100644 index 00000000..d87cafb8 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/reporting-members.json @@ -0,0 +1,35 @@ +{ + "yesterdayText": "Ayer", + "lastSevenDaysText": "Últimos 7 Días", + "lastWeekText": "Última Semana", + "lastThirtyDaysText": "Últimos 30 Días", + "lastMonthText": "Último Mes", + "lastThreeMonthsText": "Últimos 3 Meses", + "allTimeText": "Todo el Tiempo", + "customRangeText": "Rango personalizado", + "startDateInputPlaceholder": "Fecha de inicio", + "EndDateInputPlaceholder": "Fecha final", + "filterButton": "Filtrar", + + "membersTitle": "Miembros", + "includeArchivedButton": "Incluir Proyectos Archivados", + "exportButton": "Exportar", + "excelButton": "Excel", + "searchByNameInputPlaceholder": "Buscar por nombre", + + "memberColumn": "Miembro", + "tasksProgressColumn": "Progreso de Tareas", + "tasksAssignedColumn": "Tareas Asignadas", + "completedTasksColumn": "Tareas Completadas", + "overdueTasksColumn": "Tareas Atrasadas", + "ongoingTasksColumn": "Tareas en Curso", + + "tasksAssignedColumnTooltip": "Tareas asignadas en el rango de fechas seleccionado", + "overdueTasksColumnTooltip": "Tareas atrasadas al final del rango de fechas seleccionado", + "completedTasksColumnTooltip": "Tareas completadas en el rango de fechas seleccionado", + "ongoingTasksColumnTooltip": "Tareas iniciadas aún no completadas", + + "todoText": "Por Hacer", + "doingText": "Haciendo", + "doneText": "Hecho" +} diff --git a/worklenz-backend/src/public/locales/es/reporting-overview-drawer.json b/worklenz-backend/src/public/locales/es/reporting-overview-drawer.json new file mode 100644 index 00000000..fce8e554 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/reporting-overview-drawer.json @@ -0,0 +1,39 @@ +{ + "exportButton": "Exportar", + "projectsButton": "Proyectos", + "membersButton": "Miembros", + "searchByNameInputPlaceholder": "Buscar por nombre", + + "overviewTab": "Resumen", + "projectsTab": "Proyectos", + "membersTab": "Miembros", + + "projectsByStatusText": "Proyectos por Estado", + "projectsByCategoryText": "Proyectos por Categoría", + "projectsByHealthText": "Proyectos por Salud", + + "projectsText": "Proyectos", + "allText": "Todo", + + "cancelledText": "Cancelado", + "blockedText": "Bloqueado", + "onHoldText": "En Espera", + "proposedText": "Propuesto", + "inPlanningText": "En Planificación", + "inProgressText": "En Progreso", + "completedText": "Completado", + "continuousText": "Continuo", + + "notSetText": "No Establecido", + "needsAttentionText": "Necesita Atención", + "atRiskText": "En Riesgo", + "goodText": "Bien", + + "nameColumn": "Nombre", + "emailColumn": "Correo", + "projectsColumn": "Proyectos", + "tasksColumn": "Tareas", + "overdueTasksColumn": "Tareas Atrasadas", + "completedTasksColumn": "Tareas Completadas", + "ongoingTasksColumn": "Tareas en Curso" +} diff --git a/worklenz-backend/src/public/locales/es/reporting-overview.json b/worklenz-backend/src/public/locales/es/reporting-overview.json new file mode 100644 index 00000000..3f18fbed --- /dev/null +++ b/worklenz-backend/src/public/locales/es/reporting-overview.json @@ -0,0 +1,25 @@ +{ + "overviewTitle": "Resumen", + "includeArchivedButton": "Incluir Proyectos Archivados", + + "teamCount": "Equipo", + "teamCountPlural": "Equipos", + "projectCount": "Proyecto", + "projectCountPlural": "Proyectos", + "memberCount": "Miembro", + "memberCountPlural": "Miembros", + "activeProjectCount": "Proyecto Activo", + "activeProjectCountPlural": "Proyectos Activos", + "overdueProjectCount": "Proyecto Atrasado", + "overdueProjectCountPlural": "Proyectos Atrasados", + "unassignedMemberCount": "Miembro Sin Asignar", + "unassignedMemberCountPlural": "Miembros Sin Asignar", + "memberWithOverdueTaskCount": "Miembro Con Tarea Atrasada", + "memberWithOverdueTaskCountPlural": "Miembros Con Tareas Atrasadas", + + "teamsText": "Equipos", + + "nameColumn": "Nombre", + "projectsColumn": "Proyectos", + "membersColumn": "Miembros" +} diff --git a/worklenz-backend/src/public/locales/es/reporting-projects-drawer.json b/worklenz-backend/src/public/locales/es/reporting-projects-drawer.json new file mode 100644 index 00000000..1e056a29 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/reporting-projects-drawer.json @@ -0,0 +1,59 @@ +{ + "exportButton": "Exportar", + "membersButton": "Miembros", + "tasksButton": "Tareas", + "searchByNameInputPlaceholder": "Buscar por nombre", + + "overviewTab": "Resumen", + "membersTab": "Miembros", + "tasksTab": "Tareas", + + "completedTasksText": "Tareas Completadas", + "incompleteTasksText": "Tareas Incompletas", + "overdueTasksText": "Tareas Atrasadas", + "allocatedHoursText": "Horas Asignadas", + "loggedHoursText": "Horas Registradas", + + "tasksText": "Tareas", + "allText": "Todos", + + "tasksByStatusText": "Tareas por Estado", + "tasksByPriorityText": "Tareas por Prioridad", + "tasksByDueDateText": "Tareas por Fecha de Vencimiento", + + "todoText": "Por Hacer", + "doingText": "En Proceso", + "doneText": "Hecho", + "lowText": "Baja", + "mediumText": "Media", + "highText": "Alta", + "completedText": "Completado", + "upcomingText": "Próximo", + "overdueText": "Atrasado", + "noDueDateText": "Sin Fecha de Vencimiento", + + "nameColumn": "Nombre", + "tasksCountColumn": "Cantidad de Tareas", + "completedTasksColumn": "Tareas Completadas", + "incompleteTasksColumn": "Tareas Incompletas", + "overdueTasksColumn": "Tareas Atrasadas", + "contributionColumn": "Contribución", + "progressColumn": "Progreso", + "loggedTimeColumn": "Tiempo Registrado", + "taskColumn": "Tarea", + "projectColumn": "Proyecto", + "statusColumn": "Estado", + "priorityColumn": "Prioridad", + "phaseColumn": "Fase", + "dueDateColumn": "Fecha de Vencimiento", + "completedDateColumn": "Fecha de Finalización", + "estimatedTimeColumn": "Tiempo Estimado", + "overloggedTimeColumn": "Tiempo Excedido", + "completedOnColumn": "Completado El", + "daysOverdueColumn": "Días de Retraso", + + "groupByText": "Agrupar Por:", + "statusText": "Estado", + "priorityText": "Prioridad", + "phaseText": "Fase" +} diff --git a/worklenz-backend/src/public/locales/es/reporting-projects-filters.json b/worklenz-backend/src/public/locales/es/reporting-projects-filters.json new file mode 100644 index 00000000..1a4df4af --- /dev/null +++ b/worklenz-backend/src/public/locales/es/reporting-projects-filters.json @@ -0,0 +1,35 @@ +{ + "searchByNamePlaceholder": "Buscar por nombre", + "searchByCategoryPlaceholder": "Buscar por categoría", + + "statusText": "Estado", + "healthText": "Salud", + "categoryText": "Categoría", + "projectManagerText": "Gerente de Proyecto", + "showFieldsText": "Mostrar campos", + + "cancelledText": "Cancelado", + "blockedText": "Bloqueado", + "onHoldText": "En Espera", + "proposedText": "Propuesto", + "inPlanningText": "En Planificación", + "inProgressText": "En Progreso", + "completedText": "Completado", + "continuousText": "Continuo", + + "notSetText": "No Establecido", + "needsAttentionText": "Necesita Atención", + "atRiskText": "En Riesgo", + "goodText": "Bien", + + "nameText": "Proyecto", + "estimatedVsActualText": "Estimado vs Real", + "tasksProgressText": "Progreso de Tareas", + "lastActivityText": "Última Actividad", + "datesText": "Fechas de Inicio/Fin", + "daysLeftText": "Días Restantes/Atrasados", + "projectHealthText": "Salud del Proyecto", + "projectUpdateText": "Actualización del Proyecto", + "clientText": "Cliente", + "teamText": "Equipo" +} diff --git a/worklenz-backend/src/public/locales/es/reporting-projects.json b/worklenz-backend/src/public/locales/es/reporting-projects.json new file mode 100644 index 00000000..fbd9283f --- /dev/null +++ b/worklenz-backend/src/public/locales/es/reporting-projects.json @@ -0,0 +1,52 @@ +{ + "projectCount": "Proyecto", + "projectCountPlural": "Proyectos", + "includeArchivedButton": "Incluir Proyectos Archivados", + "exportButton": "Exportar", + "excelButton": "Excel", + + "projectColumn": "Proyecto", + "estimatedVsActualColumn": "Estimado vs Real", + "tasksProgressColumn": "Progreso de Tareas", + "lastActivityColumn": "Última Actividad", + "statusColumn": "Estado", + "datesColumn": "Fechas Inicio/Fin", + "daysLeftColumn": "Días Restantes/Atrasados", + "projectHealthColumn": "Salud del Proyecto", + "categoryColumn": "Categoría", + "projectUpdateColumn": "Actualización del Proyecto", + "clientColumn": "Cliente", + "teamColumn": "Equipo", + "projectManagerColumn": "Gerente de Proyecto", + + "openButton": "Abrir", + + "estimatedText": "Estimado", + "actualText": "Real", + + "todoText": "Por Hacer", + "doingText": "En Proceso", + "doneText": "Terminado", + + "cancelledText": "Cancelado", + "blockedText": "Bloqueado", + "onHoldText": "En Espera", + "proposedText": "Propuesto", + "inPlanningText": "En Planificación", + "inProgressText": "En Progreso", + "completedText": "Completado", + "continuousText": "Continuo", + + "daysLeftText": "días restantes", + "dayLeftText": "día restante", + "daysOverdueText": "días de retraso", + + "notSetText": "No Establecido", + "needsAttentionText": "Necesita Atención", + "atRiskText": "En Riesgo", + "goodText": "Bien", + + "setCategoryText": "Establecer Categoría", + "searchByNameInputPlaceholder": "Buscar por nombre", + "todayText": "Hoy" +} diff --git a/worklenz-backend/src/public/locales/es/reporting-sidebar.json b/worklenz-backend/src/public/locales/es/reporting-sidebar.json new file mode 100644 index 00000000..d5e89788 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/reporting-sidebar.json @@ -0,0 +1,8 @@ +{ + "overviewText": "Resumen", + "projectsText": "Proyectos", + "membersText": "Miembros", + "timeReportsText": "Informes de Tiempo", + "estimateVsActualText": "Estimado vs Real", + "currentOrganizationTooltip": "Organización actual" +} diff --git a/worklenz-backend/src/public/locales/es/schedule.json b/worklenz-backend/src/public/locales/es/schedule.json new file mode 100644 index 00000000..5b24c1f4 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/schedule.json @@ -0,0 +1,39 @@ +{ + "today": "Hoy", + "week": "Semana", + "month": "Mes", + + "settings": "Configuración", + "workingDays": "Días laborables", + "monday": "Lunes", + "tuesday": "Martes", + "wednesday": "Miércoles", + "thursday": "Jueves", + "friday": "Viernes", + "saturday": "Sábado", + "sunday": "Domingo", + "workingHours": "Horas laborables", + "hours": "horas", + "saveButton": "Guardar", + + "totalAllocation": "Asignación Total", + "timeLogged": "Tiempo Registrado", + "remainingTime": "Tiempo Restante", + "total": "Total", + "perDay": "Por Día", + "tasks": "tareas", + "startDate": "Fecha de Inicio", + "endDate": "Fecha de Fin", + + "hoursPerDay": "Horas Por Día", + "totalHours": "Horas Totales", + "deleteButton": "Eliminar", + "cancelButton": "Cancelar", + + "tabTitle": "Tarea sin Fechas de Inicio y Fin", + + "allocatedTime": "Tiempo Asignado", + "totalLogged": "Total Registrado", + "loggedBillable": "Registrado Facturable", + "loggedNonBillable": "Registrado No Facturable" +} diff --git a/worklenz-backend/src/public/locales/es/settings/appearance.json b/worklenz-backend/src/public/locales/es/settings/appearance.json new file mode 100644 index 00000000..d6b196da --- /dev/null +++ b/worklenz-backend/src/public/locales/es/settings/appearance.json @@ -0,0 +1,5 @@ +{ + "title": "Apariencia", + "darkMode": "Modo Oscuro", + "darkModeDescription": "Cambia entre el modo claro y oscuro para personalizar tu experiencia visual." +} diff --git a/worklenz-backend/src/public/locales/es/settings/categories.json b/worklenz-backend/src/public/locales/es/settings/categories.json new file mode 100644 index 00000000..417e17dd --- /dev/null +++ b/worklenz-backend/src/public/locales/es/settings/categories.json @@ -0,0 +1,10 @@ +{ + "categoryColumn": "Category", + "deleteConfirmationTitle": "Are you sure?", + "deleteConfirmationOk": "Yes", + "deleteConfirmationCancel": "Cancel", + "associatedTaskColumn": "Associated Task", + "searchPlaceholder": "Search by name", + "emptyText": "Categories can be created while updating or creating projects.", + "colorChangeTooltip": "Click to change color" +} diff --git a/worklenz-backend/src/public/locales/es/settings/change-password.json b/worklenz-backend/src/public/locales/es/settings/change-password.json new file mode 100644 index 00000000..e52b9aef --- /dev/null +++ b/worklenz-backend/src/public/locales/es/settings/change-password.json @@ -0,0 +1,15 @@ +{ + "title": "Cambiar Contraseña", + "currentPassword": "Contraseña Actual", + "newPassword": "Nueva Contraseña", + "confirmPassword": "Confirmar Contraseña", + "currentPasswordPlaceholder": "Introduce tu contraseña actual", + "newPasswordPlaceholder": "Nueva Contraseña", + "confirmPasswordPlaceholder": "Confirmar Contraseña", + "currentPasswordRequired": "¡Por favor, introduce tu contraseña actual!", + "newPasswordRequired": "¡Por favor, introduce tu nueva contraseña!", + "passwordValidationError": "La contraseña debe tener al menos 8 caracteres con una letra mayúscula, un número y un símbolo.", + "passwordMismatch": "¡Las contraseñas no coinciden!", + "passwordRequirements": "La nueva contraseña debe tener un mínimo de 8 caracteres, con una letra mayúscula, un número y un símbolo.", + "updateButton": "Actualizar Contraseña" +} diff --git a/worklenz-backend/src/public/locales/es/settings/clients.json b/worklenz-backend/src/public/locales/es/settings/clients.json new file mode 100644 index 00000000..ca206be1 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/settings/clients.json @@ -0,0 +1,22 @@ +{ + "nameColumn": "Nombre", + "projectColumn": "Proyecto", + "noProjectsAvailable": "No hay proyectos disponibles", + "deleteConfirmationTitle": "¿Estás seguro?", + "deleteConfirmationOk": "Sí", + "deleteConfirmationCancel": "Cancelar", + "searchPlaceholder": "Buscar por nombre", + "createClient": "Crear Cliente", + "pinTooltip": "Haz clic para fijar esto en el menú principal", + "createClientDrawerTitle": "Crear Cliente", + "updateClientDrawerTitle": "Actualizar Cliente", + "nameLabel": "Nombre", + "namePlaceholder": "Nombre", + "nameRequiredError": "Por favor ingresa un nombre", + "createButton": "Crear", + "updateButton": "Actualizar", + "createClientSuccessMessage": "¡Cliente creado exitosamente!", + "createClientErrorMessage": "¡Error al crear el cliente!", + "updateClientSuccessMessage": "¡Cliente actualizado exitosamente!", + "updateClientErrorMessage": "¡Error al actualizar el cliente!" +} diff --git a/worklenz-backend/src/public/locales/es/settings/job-titles.json b/worklenz-backend/src/public/locales/es/settings/job-titles.json new file mode 100644 index 00000000..1b892d72 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/settings/job-titles.json @@ -0,0 +1,20 @@ +{ + "nameColumn": "Nombre", + "deleteConfirmationTitle": "¿Estás seguro?", + "deleteConfirmationOk": "Sí", + "deleteConfirmationCancel": "Cancelar", + "searchPlaceholder": "Buscar por nombre", + "createJobTitleButton": "Crear Cargo", + "pinTooltip": "Haz clic para fijar esto en el menú principal", + "createJobTitleDrawerTitle": "Crear Cargo", + "updateJobTitleDrawerTitle": "Actualizar Cargo", + "nameLabel": "Nombre", + "namePlaceholder": "Nombre", + "nameRequiredError": "Por favor ingresa un nombre", + "createButton": "Crear", + "updateButton": "Actualizar", + "createJobTitleSuccessMessage": "¡Cargo creado exitosamente!", + "createJobTitleErrorMessage": "¡Error al crear el cargo!", + "updateJobTitleSuccessMessage": "¡Cargo actualizado exitosamente!", + "updateJobTitleErrorMessage": "¡Error al actualizar el cargo!" +} diff --git a/worklenz-backend/src/public/locales/es/settings/labels.json b/worklenz-backend/src/public/locales/es/settings/labels.json new file mode 100644 index 00000000..22cd9532 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/settings/labels.json @@ -0,0 +1,11 @@ +{ + "labelColumn": "Etiqueta", + "deleteConfirmationTitle": "¿Estás seguro?", + "deleteConfirmationOk": "Sí", + "deleteConfirmationCancel": "Cancelar", + "associatedTaskColumn": "Cantidad de Tareas Asociadas", + "searchPlaceholder": "Buscar por nombre", + "emptyText": "Las etiquetas se pueden crear al actualizar o crear tareas.", + "pinTooltip": "Haz clic para fijar esto en el menú principal", + "colorChangeTooltip": "Haz clic para cambiar el color" +} diff --git a/worklenz-backend/src/public/locales/es/settings/language.json b/worklenz-backend/src/public/locales/es/settings/language.json new file mode 100644 index 00000000..e07fd933 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/settings/language.json @@ -0,0 +1,7 @@ +{ + "language": "Idioma", + "language_required": "El idioma es requerido", + "time_zone": "Zona horaria", + "time_zone_required": "La zona horaria es requerida", + "save_changes": "Guardar cambios" +} diff --git a/worklenz-backend/src/public/locales/es/settings/notifications.json b/worklenz-backend/src/public/locales/es/settings/notifications.json new file mode 100644 index 00000000..c7a5af22 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/settings/notifications.json @@ -0,0 +1,10 @@ +{ + "emailTitle": "Enviarme notificaciones por correo electrónico", + "emailDescription": "Esto incluye nuevas asignaciones de tareas", + "dailyDigestTitle": "Enviarme un resumen diario", + "dailyDigestDescription": "Cada tarde, recibirás un resumen de la actividad reciente en las tareas.", + "popupTitle": "Mostrar notificaciones emergentes en mi computadora cuando Worklenz esté abierto", + "popupDescription": "Las notificaciones emergentes pueden ser desactivadas por tu navegador. Cambia la configuración de tu navegador para permitirlas.", + "unreadItemsTitle": "Mostrar el número de elementos no leídos", + "unreadItemsDescription": "Verás contadores para cada notificación." +} diff --git a/worklenz-backend/src/public/locales/es/settings/profile.json b/worklenz-backend/src/public/locales/es/settings/profile.json new file mode 100644 index 00000000..1a1698c8 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/settings/profile.json @@ -0,0 +1,14 @@ +{ + "uploadError": "¡Solo puedes subir archivos JPG/PNG!", + "uploadSizeError": "¡La imagen debe ser menor de 2MB!", + "upload": "Subir", + "nameLabel": "Nombre", + "nameRequiredError": "El nombre es obligatorio", + "emailLabel": "Correo electrónico", + "emailRequiredError": "El correo electrónico es obligatorio", + "saveChanges": "Guardar cambios", + "profileJoinedText": "Se unió hace un mes", + "profileLastUpdatedText": "Última actualización hace un mes", + "avatarTooltip": "Haz clic para subir un avatar", + "title": "Configuración del Perfil" +} diff --git a/worklenz-backend/src/public/locales/es/settings/project-templates.json b/worklenz-backend/src/public/locales/es/settings/project-templates.json new file mode 100644 index 00000000..045f2240 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/settings/project-templates.json @@ -0,0 +1,8 @@ +{ + "nameColumn": "Nombre", + "editToolTip": "Editar", + "deleteToolTip": "Eliminar", + "confirmText": "¿Estás seguro?", + "okText": "Sí", + "cancelText": "Cancelar" +} diff --git a/worklenz-backend/src/public/locales/es/settings/sidebar.json b/worklenz-backend/src/public/locales/es/settings/sidebar.json new file mode 100644 index 00000000..3793e77f --- /dev/null +++ b/worklenz-backend/src/public/locales/es/settings/sidebar.json @@ -0,0 +1,15 @@ +{ + "profile": "Perfil", + "notifications": "Notificaciones", + "clients": "Clientes", + "job-titles": "Títulos de trabajo", + "labels": "Etiquetas", + "categories": "Categorías", + "project-templates": "Plantillas de proyectos", + "task-templates": "Plantillas de tareas", + "team-members": "Miembros del equipo", + "teams": "Equipos", + "change-password": "Cambiar contraseña", + "language-and-region": "Idioma y región", + "appearance": "Apariencia" +} diff --git a/worklenz-backend/src/public/locales/es/settings/task-templates.json b/worklenz-backend/src/public/locales/es/settings/task-templates.json new file mode 100644 index 00000000..fbdc3c81 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/settings/task-templates.json @@ -0,0 +1,9 @@ +{ + "nameColumn": "Nombre", + "createdColumn": "Creado", + "editToolTip": "Editar", + "deleteToolTip": "Eliminar", + "confirmText": "¿Estás seguro?", + "okText": "Sí", + "cancelText": "Cancelar" +} diff --git a/worklenz-backend/src/public/locales/es/settings/team-members.json b/worklenz-backend/src/public/locales/es/settings/team-members.json new file mode 100644 index 00000000..1000bf98 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/settings/team-members.json @@ -0,0 +1,47 @@ +{ + "title": "Miembros del Equipo", + "nameColumn": "Nombre", + "projectsColumn": "Proyectos", + "emailColumn": "Correo electrónico", + "teamAccessColumn": "Acceso al equipo", + "memberCount": "Miembro", + "membersCountPlural": "Miembros", + "searchPlaceholder": "Buscar miembros por nombre", + "pinTooltip": "Actualizar lista de miembros", + "addMemberButton": "Agregar nuevo miembro", + "editTooltip": "Editar miembro", + "deactivateTooltip": "Desactivar miembro", + "activateTooltip": "Activar miembro", + "deleteTooltip": "Eliminar miembro", + "confirmDeleteTitle": "¿Está seguro de que desea eliminar este miembro?", + "confirmActivateTitle": "¿Está seguro de que desea cambiar el estado de este miembro?", + "okText": "Sí, continuar", + "cancelText": "No, cancelar", + "deactivatedText": "(Actualmente desactivado)", + "pendingInvitationText": "(Invitación pendiente)", + "addMemberDrawerTitle": "Agregar nuevo miembro del equipo", + "updateMemberDrawerTitle": "Actualizar miembro del equipo", + "addMemberEmailHint": "Los miembros se agregarán al equipo independientemente del estado de aceptación de la invitación", + "memberEmailLabel": "Dirección(es) de correo electrónico", + "memberEmailPlaceholder": "Ingrese la dirección de correo electrónico del miembro del equipo", + "memberEmailRequiredError": "Por favor, ingrese una dirección de correo electrónico válida", + "jobTitleLabel": "Cargo", + "jobTitlePlaceholder": "Seleccione o busque cargo (Opcional)", + "memberAccessLabel": "Nivel de acceso", + "addToTeamButton": "Agregar miembro al equipo", + "updateButton": "Guardar cambios", + "resendInvitationButton": "Reenviar correo de invitación", + "invitationSentSuccessMessage": "¡Invitación al equipo enviada exitosamente!", + "createMemberSuccessMessage": "¡Nuevo miembro del equipo agregado exitosamente!", + "createMemberErrorMessage": "Error al agregar miembro del equipo. Por favor, intente nuevamente.", + "updateMemberSuccessMessage": "¡Miembro del equipo actualizado exitosamente!", + "updateMemberErrorMessage": "Error al actualizar miembro del equipo. Por favor, intente nuevamente.", + "memberText": "Miembro del equipo", + "adminText": "Administrador", + "ownerText": "Propietario del equipo", + "addedText": "Agregado", + "updatedText": "Actualizado", + "noResultFound": "Escriba una dirección de correo electrónico y presione enter...", + "jobTitlesFetchError": "Error al obtener los cargos", + "invitationResent": "¡Invitación reenviada exitosamente!" +} diff --git a/worklenz-backend/src/public/locales/es/settings/teams.json b/worklenz-backend/src/public/locales/es/settings/teams.json new file mode 100644 index 00000000..808c1b78 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/settings/teams.json @@ -0,0 +1,16 @@ +{ + "title": "Equipos", + "team": "Equipo", + "teams": "Equipos", + "name": "Nombre", + "created": "Creado", + "ownsBy": "Pertenece a", + "edit": "Editar", + "editTeam": "Editar Equipo", + "pinTooltip": "Haz clic para fijar esto en el menú principal", + "editTeamName": "Editar Nombre del Equipo", + "updateName": "Actualizar Nombre", + "namePlaceholder": "Nombre", + "nameRequired": "Por favor ingresa un Nombre", + "updateFailed": "¡Falló el cambio de nombre del equipo!" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/es/task-drawer/task-drawer-info-tab.json b/worklenz-backend/src/public/locales/es/task-drawer/task-drawer-info-tab.json new file mode 100644 index 00000000..02b3038a --- /dev/null +++ b/worklenz-backend/src/public/locales/es/task-drawer/task-drawer-info-tab.json @@ -0,0 +1,30 @@ +{ + "details": { + "task-key": "Clave de tarea", + "phase": "Fase", + "assignees": "Asignados", + "due-date": "Fecha de vencimiento", + "time-estimation": "Estimación de tiempo", + "priority": "Prioridad", + "labels": "Etiquetas", + "billable": "Facturable", + "notify": "Notificar", + "when-done-notify": "Al terminar, notificar", + "start-date": "Fecha de inicio", + "end-date": "Fecha de finalización", + "hide-start-date": "Ocultar fecha de inicio", + "show-start-date": "Mostrar fecha de inicio", + "hours": "Horas", + "minutes": "Minutos", + "recurring": "Recurrente" + }, + "description": { + "title": "Descripción", + "placeholder": "Añadir una descripción más detallada..." + }, + "subTasks": { + "title": "Subtareas", + "add-sub-task": "+ Añadir subtarea", + "refresh-sub-tasks": "Actualizar subtareas" + } +} diff --git a/worklenz-backend/src/public/locales/es/task-drawer/task-drawer-recurring-config.json b/worklenz-backend/src/public/locales/es/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..c1ef9e83 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,34 @@ +{ + "recurring": "Recurrente", + "recurringTaskConfiguration": "Configuración de tarea recurrente", + "repeats": "Repeticiones", + "daily": "Diario", + "weekly": "Semanal", + "everyXDays": "Cada X días", + "everyXWeeks": "Cada X semanas", + "everyXMonths": "Cada X meses", + "monthly": "Mensual", + "selectDaysOfWeek": "Seleccionar días de la semana", + "mon": "Lun", + "tue": "Mar", + "wed": "Mié", + "thu": "Jue", + "fri": "Vie", + "sat": "Sáb", + "sun": "Dom", + "monthlyRepeatType": "Tipo de repetición mensual", + "onSpecificDate": "En una fecha específica", + "onSpecificDay": "En un día específico", + "dateOfMonth": "Fecha del mes", + "weekOfMonth": "Semana del mes", + "dayOfWeek": "Día de la semana", + "first": "Primero", + "second": "Segundo", + "third": "Tercero", + "fourth": "Cuarto", + "last": "Último", + "intervalDays": "Intervalo (días)", + "intervalWeeks": "Intervalo (semanas)", + "intervalMonths": "Intervalo (meses)", + "saveChanges": "Guardar cambios" +} diff --git a/worklenz-backend/src/public/locales/es/task-drawer/task-drawer.json b/worklenz-backend/src/public/locales/es/task-drawer/task-drawer.json new file mode 100644 index 00000000..8e438716 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/task-drawer/task-drawer.json @@ -0,0 +1,123 @@ +{ + "taskHeader": { + "taskNamePlaceholder": "Escriba su Tarea", + "deleteTask": "Eliminar Tarea" + }, + "taskInfoTab": { + "title": "Información", + "details": { + "title": "Detalles", + "task-key": "Clave de Tarea", + "phase": "Fase", + "assignees": "Asignados", + "due-date": "Fecha de Vencimiento", + "time-estimation": "Estimación de Tiempo", + "priority": "Prioridad", + "labels": "Etiquetas", + "billable": "Facturable", + "notify": "Notificar", + "when-done-notify": "Al terminar, notificar", + "start-date": "Fecha de Inicio", + "end-date": "Fecha de Fin", + "hide-start-date": "Ocultar Fecha de Inicio", + "show-start-date": "Mostrar Fecha de Inicio", + "hours": "Horas", + "minutes": "Minutos", + "progressValue": "Valor de Progreso", + "progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)", + "progressValueRequired": "Por favor, introduzca un valor de progreso", + "progressValueRange": "El progreso debe estar entre 0 y 100", + "taskWeight": "Peso de la Tarea", + "taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)", + "taskWeightRequired": "Por favor, introduzca un peso de tarea", + "taskWeightRange": "El peso debe estar entre 0 y 100", + "recurring": "Recurrente" + }, + "labels": { + "labelInputPlaceholder": "Buscar o crear", + "labelsSelectorInputTip": "Presiona Enter para crear" + }, + "description": { + "title": "Descripción", + "placeholder": "Añadir una descripción más detallada..." + }, + "subTasks": { + "title": "Sub Tareas", + "addSubTask": "Agregar Sub Tarea", + "addSubTaskInputPlaceholder": "Escriba su tarea y presione enter", + "refreshSubTasks": "Actualizar Sub Tareas", + "edit": "Editar", + "delete": "Eliminar", + "confirmDeleteSubTask": "¿Está seguro de que desea eliminar esta subtarea?", + "deleteSubTask": "Eliminar Sub Tarea" + }, + "dependencies": { + "title": "Dependencias", + "addDependency": "+ Agregar nueva dependencia", + "blockedBy": "Bloqueado por", + "searchTask": "Escribir para buscar tarea", + "noTasksFound": "No se encontraron tareas", + "confirmDeleteDependency": "¿Está seguro de que desea eliminar?" + }, + "attachments": { + "title": "Adjuntos", + "chooseOrDropFileToUpload": "Elija o arrastre un archivo para subir", + "uploading": "Subiendo..." + }, + "comments": { + "title": "Comentarios", + "addComment": "+ Agregar nuevo comentario", + "noComments": "Aún no hay comentarios. ¡Sé el primero en comentar!", + "delete": "Eliminar", + "confirmDeleteComment": "¿Está seguro de que desea eliminar este comentario?", + "addCommentPlaceholder": "Agregar un comentario...", + "cancel": "Cancelar", + "commentButton": "Comentar", + "attachFiles": "Adjuntar archivos", + "addMoreFiles": "Agregar más archivos", + "selectedFiles": "Archivos Seleccionados (Hasta 25MB, Máximo {count})", + "maxFilesError": "Solo puede subir un máximo de {count} archivos", + "processFilesError": "Error al procesar archivos", + "addCommentError": "Por favor agregue un comentario o adjunte archivos", + "createdBy": "Creado {{time}} por {{user}}", + "updatedTime": "Actualizado {{time}}" + }, + "searchInputPlaceholder": "Buscar por nombre", + "pendingInvitation": "Invitación Pendiente" + }, + "taskTimeLogTab": { + "title": "Registro de Tiempo", + "addTimeLog": "Añadir nuevo registro de tiempo", + "totalLogged": "Total Registrado", + "exportToExcel": "Exportar a Excel", + "noTimeLogsFound": "No se encontraron registros de tiempo", + "timeLogForm": { + "date": "Fecha", + "startTime": "Hora de Inicio", + "endTime": "Hora de Fin", + "workDescription": "Descripción del Trabajo", + "descriptionPlaceholder": "Agregar una descripción", + "logTime": "Registrar tiempo", + "updateTime": "Actualizar tiempo", + "cancel": "Cancelar", + "selectDateError": "Por favor seleccione una fecha", + "selectStartTimeError": "Por favor seleccione la hora de inicio", + "selectEndTimeError": "Por favor seleccione la hora de fin", + "endTimeAfterStartError": "La hora de fin debe ser posterior a la hora de inicio" + } + }, + "taskActivityLogTab": { + "title": "Registro de Actividad", + "add": "AGREGAR", + "remove": "QUITAR", + "none": "Ninguno", + "weight": "Peso", + "createdTask": "creó la tarea." + }, + "taskProgress": { + "markAsDoneTitle": "¿Marcar Tarea como Completada?", + "confirmMarkAsDone": "Sí, marcar como completada", + "cancelMarkAsDone": "No, mantener estado actual", + "markAsDoneDescription": "Ha establecido el progreso al 100%. ¿Le gustaría actualizar el estado de la tarea a \"Completada\"?" + } +} diff --git a/worklenz-backend/src/public/locales/es/task-list-filters.json b/worklenz-backend/src/public/locales/es/task-list-filters.json new file mode 100644 index 00000000..465368f0 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/task-list-filters.json @@ -0,0 +1,81 @@ +{ + "searchButton": "Buscar", + "resetButton": "Restablecer", + "searchInputPlaceholder": "Buscar por nombre", + + "sortText": "Ordenar", + "statusText": "Estado", + "phaseText": "Fase", + "priorityText": "Prioridad", + "labelsText": "Etiquetas", + "membersText": "Miembros", + "groupByText": "Agrupar por", + "showArchivedText": "Mostrar archivados", + "showFieldsText": "Mostrar campos", + "keyText": "Clave", + "taskText": "Tarea", + "descriptionText": "Descripción", + "phasesText": "Fases", + "progressText": "Progreso", + "timeTrackingText": "Seguimiento de tiempo", + "estimationText": "Estimación", + "startDateText": "Fecha de inicio", + "endDateText": "Fecha de fin", + "dueDateText": "Fecha de vencimiento", + "completedDateText": "Fecha de finalización", + "createdDateText": "Fecha de creación", + "lastUpdatedText": "Última actualización", + "reporterText": "Reportero", + "dueTimeText": "Hora de vencimiento", + "lowText": "Baja", + "mediumText": "Media", + "highText": "Alta", + "assigneesText": "Asignados", + "timetrackingText": "Seguimiento de tiempo", + "startdateText": "Fecha de inicio", + "duedateText": "Fecha de vencimiento", + "completeddateText": "Fecha de finalización", + "createddateText": "Fecha de creación", + "lastupdatedText": "Última actualización", + "duetimeText": "Hora de vencimiento", + "createStatusButtonTooltip": "Configuración de estados", + "configPhaseButtonTooltip": "Configuración de fases", + "noLabelsFound": "No se encontraron etiquetas", + + "addStatusButton": "Agregar estado", + "addPhaseButton": "Agregar fase", + + "createStatus": "Crear estado", + "name": "Nombre", + "category": "Categoría", + "selectCategory": "Seleccionar una categoría", + "pleaseEnterAName": "Por favor, ingrese un nombre", + "pleaseSelectACategory": "Por favor, seleccione una categoría", + "create": "Crear", + + "searchTasks": "Buscar tareas...", + "searchPlaceholder": "Buscar...", + "fieldsText": "Campos", + "loadingFilters": "Cargando filtros...", + "noOptionsFound": "No se encontraron opciones", + "filtersActive": "filtros activos", + "filterActive": "filtro activo", + "clearAll": "Limpiar todo", + "clearing": "Limpiando...", + "cancel": "Cancelar", + "search": "Buscar", + "groupedBy": "Agrupado por", + "manageStatuses": "Gestionar Estados", + "managePhases": "Gestionar Fases", + "dragToReorderStatuses": "Arrastra los estados para reordenarlos. Cada estado puede tener una categoría diferente.", + "enterNewStatusName": "Introducir nuevo nombre de estado...", + "addStatus": "Añadir Estado", + "noStatusesFound": "No se encontraron estados. Crea tu primer estado arriba.", + "deleteStatus": "Eliminar Estado", + "deleteStatusConfirm": "¿Estás seguro de que quieres eliminar este estado? Esta acción no se puede deshacer.", + "rename": "Renombrar", + "delete": "Eliminar", + "enterStatusName": "Introducir nombre del estado", + "selectCategory": "Seleccionar categoría", + "close": "Cerrar" +} diff --git a/worklenz-backend/src/public/locales/es/task-list-table.json b/worklenz-backend/src/public/locales/es/task-list-table.json new file mode 100644 index 00000000..0648c2ff --- /dev/null +++ b/worklenz-backend/src/public/locales/es/task-list-table.json @@ -0,0 +1,136 @@ +{ + "keyColumn": "Clave", + "taskColumn": "Tarea", + "descriptionColumn": "Descripción", + "progressColumn": "Progreso", + "membersColumn": "Miembros", + "assigneesColumn": "Asignados", + "labelsColumn": "Etiquetas", + "phasesColumn": "Fases", + "phaseColumn": "Fase", + "statusColumn": "Estado", + "priorityColumn": "Prioridad", + "timeTrackingColumn": "Seguimiento de tiempo", + "timetrackingColumn": "Seguimiento de tiempo", + "estimationColumn": "Estimación", + "startDateColumn": "Fecha de inicio", + "startdateColumn": "Fecha de inicio", + "dueDateColumn": "Fecha de vencimiento", + "duedateColumn": "Fecha de vencimiento", + "completedDateColumn": "Fecha de completado", + "completeddateColumn": "Fecha de completado", + "createdDateColumn": "Fecha de creación", + "createddateColumn": "Fecha de creación", + "lastUpdatedColumn": "Última actualización", + "lastupdatedColumn": "Última actualización", + "reporterColumn": "Reportador", + "dueTimeColumn": "Hora de vencimiento", + "todoSelectorText": "Por hacer", + "doingSelectorText": "En progreso", + "doneSelectorText": "Completado", + + "lowSelectorText": "Baja", + "mediumSelectorText": "Media", + "highSelectorText": "Alta", + + "selectText": "Seleccionar", + "labelsSelectorInputTip": "¡Presiona enter para crear!", + + "addTaskText": "Agregar tarea", + "addSubTaskText": "Agregar subtarea", + "noTasksInGroup": "No hay tareas en este grupo", + "addTaskInputPlaceholder": "Escribe tu tarea y presiona enter", + + "openButton": "Abrir", + "okButton": "Aceptar", + + "noLabelsFound": "No se encontraron etiquetas", + "searchInputPlaceholder": "Buscar o crear", + "assigneeSelectorInviteButton": "Invitar a un nuevo miembro por correo", + "labelInputPlaceholder": "Buscar o crear", + "searchLabelsPlaceholder": "Buscar etiquetas...", + "createLabelButton": "Crear \"{{name}}\"", + "manageLabelsPath": "Configuración → Etiquetas", + + "pendingInvitation": "Invitación pendiente", + + "contextMenu": { + "assignToMe": "Asignar a mí", + "moveTo": "Mover a", + "unarchive": "Desarchivar", + "archive": "Archivar", + "convertToSubTask": "Convertir en subtarea", + "convertToTask": "Convertir en tarea", + "delete": "Eliminar", + "searchByNameInputPlaceholder": "Buscar por nombre" + }, + "setDueDate": "Establecer fecha de vencimiento", + "setStartDate": "Establecer fecha de inicio", + "clearDueDate": "Limpiar fecha de vencimiento", + "clearStartDate": "Limpiar fecha de inicio", + "dueDatePlaceholder": "Fecha de vencimiento", + "startDatePlaceholder": "Fecha de inicio", + + "emptyStates": { + "noTaskGroups": "No se encontraron grupos de tareas", + "noTaskGroupsDescription": "Las tareas aparecerán aquí cuando se creen o cuando se apliquen filtros.", + "errorPrefix": "Error:", + "dragTaskFallback": "Tarea" + }, + + "customColumns": { + "addCustomColumn": "Agregar una columna personalizada", + "customColumnHeader": "Columna Personalizada", + "customColumnSettings": "Configuración de columna personalizada", + "noCustomValue": "Sin valor", + "peopleField": "Campo de personas", + "noDate": "Sin fecha", + "unsupportedField": "Tipo de campo no compatible", + + "modal": { + "addFieldTitle": "Agregar campo", + "editFieldTitle": "Editar campo", + "fieldTitle": "Título del campo", + "fieldTitleRequired": "El título del campo es obligatorio", + "columnTitlePlaceholder": "Título de la columna", + "type": "Tipo", + "deleteConfirmTitle": "¿Está seguro de que desea eliminar esta columna personalizada?", + "deleteConfirmDescription": "Esta acción no se puede deshacer. Todos los datos asociados con esta columna se eliminarán permanentemente.", + "deleteButton": "Eliminar", + "cancelButton": "Cancelar", + "createButton": "Crear", + "updateButton": "Actualizar", + "createSuccessMessage": "Columna personalizada creada exitosamente", + "updateSuccessMessage": "Columna personalizada actualizada exitosamente", + "deleteSuccessMessage": "Columna personalizada eliminada exitosamente", + "deleteErrorMessage": "Error al eliminar la columna personalizada", + "createErrorMessage": "Error al crear la columna personalizada", + "updateErrorMessage": "Error al actualizar la columna personalizada" + }, + + "fieldTypes": { + "people": "Personas", + "number": "Número", + "date": "Fecha", + "selection": "Selección", + "checkbox": "Casilla de verificación", + "labels": "Etiquetas", + "key": "Clave", + "formula": "Fórmula" + } + }, + + "indicators": { + "tooltips": { + "subtasks": "{{count}} subtarea", + "subtasks_plural": "{{count}} subtareas", + "comments": "{{count}} comentario", + "comments_plural": "{{count}} comentarios", + "attachments": "{{count}} archivo adjunto", + "attachments_plural": "{{count}} archivos adjuntos", + "subscribers": "La tarea tiene suscriptores", + "dependencies": "La tarea tiene dependencias", + "recurring": "Tarea recurrente" + } + } +} diff --git a/worklenz-backend/src/public/locales/es/task-management.json b/worklenz-backend/src/public/locales/es/task-management.json new file mode 100644 index 00000000..1c80304c --- /dev/null +++ b/worklenz-backend/src/public/locales/es/task-management.json @@ -0,0 +1,21 @@ +{ + "noTasksInGroup": "No hay tareas en este grupo", + "noTasksInGroupDescription": "Añade una tarea para comenzar", + "addFirstTask": "Añade tu primera tarea", + "openTask": "Abrir", + "subtask": "subtarea", + "subtasks": "subtareas", + "comment": "comentario", + "comments": "comentarios", + "attachment": "adjunto", + "attachments": "adjuntos", + "enterSubtaskName": "Ingresa el nombre de la subtarea...", + "add": "Añadir", + "cancel": "Cancelar", + "renameGroup": "Renombrar Grupo", + "renameStatus": "Renombrar Estado", + "renamePhase": "Renombrar Fase", + "changeCategory": "Cambiar Categoría", + "clickToEditGroupName": "Haz clic para editar el nombre del grupo", + "enterGroupName": "Ingresa el nombre del grupo" +} diff --git a/worklenz-backend/src/public/locales/es/task-template-drawer.json b/worklenz-backend/src/public/locales/es/task-template-drawer.json new file mode 100644 index 00000000..a3bfc45b --- /dev/null +++ b/worklenz-backend/src/public/locales/es/task-template-drawer.json @@ -0,0 +1,11 @@ +{ + "createTaskTemplate": "Crear Plantilla de Tarea", + "editTaskTemplate": "Editar Plantilla de Tarea", + "cancelText": "Cancelar", + "saveText": "Guardar", + "templateNameText": "Nombre de la Plantilla", + "selectedTasks": "Tareas Seleccionadas", + "removeTask": "Eliminar", + "cancelButton": "Cancelar", + "saveButton": "Guardar" +} diff --git a/worklenz-backend/src/public/locales/es/tasks/task-table-bulk-actions.json b/worklenz-backend/src/public/locales/es/tasks/task-table-bulk-actions.json new file mode 100644 index 00000000..0f98b1a5 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/tasks/task-table-bulk-actions.json @@ -0,0 +1,41 @@ +{ + "taskSelected": "Tarea seleccionada", + "tasksSelected": "Tareas seleccionadas", + "changeStatus": "Cambiar estado/ prioridad/ fases", + "changeLabel": "Cambiar etiqueta", + "assignToMe": "Asignar a mí", + "changeAssignees": "Cambiar asignados", + "archive": "Archivar", + "unarchive": "Desarchivar", + "delete": "Eliminar", + "moreOptions": "Más opciones", + "deselectAll": "Deseleccionar todo", + "status": "Estado", + "priority": "Prioridad", + "phase": "Fase", + "member": "Miembro", + "createTaskTemplate": "Crear plantilla de tarea", + "apply": "Aplicar", + "createLabel": "+ Crear etiqueta", + "searchOrCreateLabel": "Buscar o crear etiqueta...", + "hitEnterToCreate": "Presione Enter para crear", + "labelExists": "La etiqueta ya existe", + "pendingInvitation": "Invitación Pendiente", + "noMatchingLabels": "No hay etiquetas coincidentes", + "noLabels": "Sin etiquetas", + "CHANGE_STATUS": "Cambiar Estado", + "CHANGE_PRIORITY": "Cambiar Prioridad", + "CHANGE_PHASE": "Cambiar Fase", + "ADD_LABELS": "Agregar Etiquetas", + "ASSIGN_TO_ME": "Asignar a Mí", + "ASSIGN_MEMBERS": "Asignar Miembros", + "ARCHIVE": "Archivar", + "DELETE": "Eliminar", + "CANCEL": "Cancelar", + "CLEAR_SELECTION": "Limpiar Selección", + "TASKS_SELECTED": "{{count}} tarea seleccionada", + "TASKS_SELECTED_plural": "{{count}} tareas seleccionadas", + "DELETE_TASKS_CONFIRM": "¿Eliminar {{count}} tarea?", + "DELETE_TASKS_CONFIRM_plural": "¿Eliminar {{count}} tareas?", + "DELETE_TASKS_WARNING": "Esta acción no se puede deshacer." +} diff --git a/worklenz-backend/src/public/locales/es/template-drawer.json b/worklenz-backend/src/public/locales/es/template-drawer.json new file mode 100644 index 00000000..7c6c7f3d --- /dev/null +++ b/worklenz-backend/src/public/locales/es/template-drawer.json @@ -0,0 +1,19 @@ +{ + "title": "Editar Plantilla de Tarea", + "cancelText": "Cancelar", + "saveText": "Guardar", + "templateNameText": "Nombre de la Plantilla", + "selectedTasks": "Tareas Seleccionadas", + "removeTask": "Eliminar", + "description": "Descripción", + "phase": "Fase", + "statuses": "Estados", + "priorities": "Prioridades", + "labels": "Etiquetas", + "tasks": "Tareas", + "noTemplateSelected": "No hay plantilla seleccionada", + "noDescription": "Sin descripción", + "worklenzTemplates": "Plantillas de Worklenz", + "yourTemplatesLibrary": "Tu Biblioteca", + "searchTemplates": "Buscar Plantillas" +} diff --git a/worklenz-backend/src/public/locales/es/templateDrawer.json b/worklenz-backend/src/public/locales/es/templateDrawer.json new file mode 100644 index 00000000..8028f0cd --- /dev/null +++ b/worklenz-backend/src/public/locales/es/templateDrawer.json @@ -0,0 +1,23 @@ +{ + "bugTracking": "Seguimiento de Errores", + "construction": "Construcción", + "designCreative": "Diseño y Creatividad", + "education": "Educación", + "finance": "Finanzas", + "hrRecruiting": "RRHH y Reclutamiento", + "informationTechnology": "Tecnología de la Información", + "legal": "Legal", + "manufacturing": "Fabricación", + "marketing": "Marketing", + "nonprofit": "Sin fines de lucro", + "personalUse": "Uso personal", + "salesCRM": "Ventas y CRM", + "serviceConsulting": "Servicios y Consultoría", + "softwareDevelopment": "Desarrollo de Software", + "description": "Descripción", + "phase": "Fase", + "statuses": "Estados", + "priorities": "Prioridades", + "labels": "Etiquetas", + "tasks": "Tareas" +} diff --git a/worklenz-backend/src/public/locales/es/time-report.json b/worklenz-backend/src/public/locales/es/time-report.json new file mode 100644 index 00000000..2646520f --- /dev/null +++ b/worklenz-backend/src/public/locales/es/time-report.json @@ -0,0 +1,57 @@ +{ + "includeArchivedProjects": "Incluir Proyectos Archivados", + "export": "Exportar", + "timeSheet": "Hoja de Tiempo", + + "searchByName": "Buscar por nombre", + "selectAll": "Seleccionar Todo", + "teams": "Equipos", + + "searchByProject": "Buscar por nombre del proyecto", + "projects": "Proyectos", + + "searchByCategory": "Buscar por nombre de categoría", + "categories": "Categorías", + + "billable": "Facturable", + "nonBillable": "No Facturable", + + "total": "Total", + + "projectsTimeSheet": "Hoja de Tiempo de Proyectos", + + "loggedTime": "Tiempo Registrado(horas)", + + "exportToExcel": "Exportar a Excel", + "logged": "registrado", + "for": "para", + + "membersTimeSheet": "Hoja de Tiempo de Miembros", + "member": "Miembro", + + "estimatedVsActual": "Estimado vs Real", + "workingDays": "Días Laborables", + "manDays": "Días Hombre", + "days": "Días", + "estimatedDays": "Días Estimados", + "actualDays": "Días Reales", + + "noCategories": "No se encontraron categorías", + "noCategory": "Sin Categoría", + "noProjects": "No se encontraron proyectos", + "noTeams": "No se encontraron equipos", + "noData": "No se encontraron datos", + + "groupBy": "Agrupar por", + "groupByCategory": "Categoría", + "groupByTeam": "Equipo", + "groupByStatus": "Estado", + "groupByNone": "Ninguno", + "clearSearch": "Limpiar búsqueda", + "selectedProjects": "Proyectos Seleccionados", + "projectsSelected": "proyectos seleccionados", + "showSelected": "Mostrar Solo Seleccionados", + "expandAll": "Expandir Todo", + "collapseAll": "Contraer Todo", + "ungrouped": "Sin Agrupar" +} diff --git a/worklenz-backend/src/public/locales/es/unauthorized.json b/worklenz-backend/src/public/locales/es/unauthorized.json new file mode 100644 index 00000000..e28ce8f4 --- /dev/null +++ b/worklenz-backend/src/public/locales/es/unauthorized.json @@ -0,0 +1,5 @@ +{ + "title": "¡No autorizado!", + "subtitle": "No tienes permisos para acceder a esta página", + "button": "Ir a Inicio" +} diff --git a/worklenz-backend/src/public/locales/pt/404-page.json b/worklenz-backend/src/public/locales/pt/404-page.json new file mode 100644 index 00000000..638d30b3 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/404-page.json @@ -0,0 +1,4 @@ +{ + "doesNotExistText": "Desculpe, a página que você visitou não existe.", + "backHomeButton": "Voltar ao Início" +} diff --git a/worklenz-backend/src/public/locales/pt/account-setup.json b/worklenz-backend/src/public/locales/pt/account-setup.json new file mode 100644 index 00000000..1d8a8cba --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/account-setup.json @@ -0,0 +1,32 @@ +{ + "continue": "Continuar", + + "setupYourAccount": "Configure sua conta.", + "organizationStepTitle": "Nomeie sua organização", + "organizationStepLabel": "Escolha um nome para sua conta Worklenz.", + + "projectStepTitle": "Crie seu primeiro projeto", + "projectStepLabel": "Em qual projeto você está trabalhando agora?", + "projectStepPlaceholder": "ex. Plano de Marketing", + + "step2Title": "Crie suas primeiras tarefas", + "step2InputLabel": "Digite algumas tarefas que você vai fazer em", + "step2AddAnother": "Adicionar outro", + + "emailPlaceholder": "Endereço de e-mail", + "invalidEmail": "Por favor, insira um endereço de e-mail válido", + "or": "ou", + "templateButton": "Importar do modelo", + "goBack": "Voltar", + "cancel": "Cancelar", + "create": "Criar", + "templateDrawerTitle": "Selecionar dos modelos", + "step3InputLabel": "Convidar por email", + "addAnother": "Adicionar outro", + "skipForNow": "Pular por enquanto", + "formTitle": "Crie sua primeira tarefa.", + "step3Title": "Convide sua equipe para trabalhar", + + "maxMembers": " (Você pode convidar até 5 membros)", + "maxTasks": " (Você pode criar até 5 tarefas)" +} diff --git a/worklenz-backend/src/public/locales/pt/admin-center/current-bill.json b/worklenz-backend/src/public/locales/pt/admin-center/current-bill.json new file mode 100644 index 00000000..2e4b41d7 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/admin-center/current-bill.json @@ -0,0 +1,113 @@ +{ + "title": "Cobranças", + "currentBill": "Fatura Atual", + "configuration": "Configuração", + "currentPlanDetails": "Detalhes do Plano Atual", + "upgradePlan": "Atualizar Plano", + "cardBodyText01": "Teste gratuito", + "cardBodyText02": "(Seu plano de teste expira em 1 mês e 19 dias)", + "redeemCode": "Resgatar Código", + "accountStorage": "Armazenamento da Conta", + "used": "Usado:", + "remaining": "Restante:", + "charges": "Cobranças", + "tooltip": "Cobranças para o ciclo de faturamento atual", + "description": "Descrição", + "billingPeriod": "Período de Faturamento", + "billStatus": "Status da Fatura", + "perUserValue": "Valor por Usuário", + "users": "Usuários", + "amount": "Valor", + "invoices": "Faturas", + "transactionId": "ID da Transação", + "transactionDate": "Data da Transação", + "paymentMethod": "Método de Pagamento", + "status": "Status", + "ltdUsers": "Puedes agregar hasta {{ltd_users}} usuarios.", + + "drawerTitle": "Resgatar Código", + "label": "Resgatar Código", + "drawerPlaceholder": "Digite seu código de resgate", + "redeemSubmit": "Enviar", + + "modalTitle": "Selecione o melhor plano para sua equipe", + "seatLabel": "Número de assentos", + "freePlan": "Plano Gratuito", + "startup": "Startup", + "business": "Empresarial", + "tag": "Mais Popular", + "enterprise": "Enterprise", + + "freeSubtitle": "gratuito para sempre", + "freeUsers": "Melhor para uso pessoal", + "freeText01": "100MB de armazenamento", + "freeText02": "3 projetos", + "freeText03": "5 membros na equipe", + + "startupSubtitle": "TAXA FIXA / mês", + "startupUsers": "Até 15 usuários", + "startupText01": "25GB de armazenamento", + "startupText02": "Projetos ativos ilimitados", + "startupText03": "Agendamento", + "startupText04": "Relatórios", + "startupText05": "Inscrever-se em projetos", + + "businessSubtitle": "usuário / mês", + "businessUsers": "16 - 200 usuários", + + "enterpriseUsers": "200 - 500+ usuários", + + "footerTitle": "Por favor, forneça um número de contato para que possamos entrar em contato com você.", + "footerLabel": "Número de Contato", + "footerButton": "Contate-nos", + + "redeemCodePlaceHolder": "Digite seu código de resgate", + "submit": "Enviar", + + "trialPlan": "Plano de Teste", + "trialExpireDate": "Válido até {{trial_expire_date}}", + "trialExpired": "Sua prova gratuita expirou {{trial_expire_string}}", + "trialInProgress": "Sua prova gratuita expira {{trial_expire_string}}", + + "required": "Este campo é obrigatório", + "invalidCode": "Código inválido", + + "selectPlan": "Selecione o melhor plano para sua equipe", + "changeSubscriptionPlan": "Mude seu plano de assinatura", + "noOfSeats": "Número de assentos", + "annualPlan": "Pro - Anual", + "monthlyPlan": "Pro - Mensal", + "freeForever": "Gratis para sempre", + "bestForPersonalUse": "Melhor para uso pessoal", + "storage": "Armazenamento", + "projects": "Projetos", + "teamMembers": "Membros da equipe", + "unlimitedTeamMembers": "Membros da equipe ilimitados", + "unlimitedActiveProjects": "Projetos ativos ilimitados", + "schedule": "Agendamento", + "reporting": "Relatórios", + "subscribeToProjects": "Inscreva-se em projetos", + "billedAnnually": "Faturado Anualmente", + "billedMonthly": "Faturado Mensalmente", + + "pausePlan": "Pausar Plano", + "resumePlan": "Reanudar Plano", + "changePlan": "Mudar Plano", + "cancelPlan": "Cancelar Plano", + + "perMonthPerUser": "por usuário / mês", + "viewInvoice": "Ver Fatura", + "switchToFreePlan": "Mudar para Plano Gratuito", + + "expirestoday": "hoje", + "expirestomorrow": "amanhã", + "expiredDaysAgo": "há {{days}} dias", + "creditPlan": "Plano de Crédito", + "customPlan": "Plano Personalizado", + "planValidTill": "Seu plano é válido até {{date}}", + "purchaseSeatsText": "Para continuar, você precisará comprar assentos adicionais.", + "currentSeatsText": "Atualmente você tem {{seats}} assentos disponíveis.", + "selectSeatsText": "Selecione o número de assentos adicionais para comprar.", + "purchase": "Comprar", + "contactSales": "Fale com vendas" +} diff --git a/worklenz-backend/src/public/locales/pt/admin-center/overview.json b/worklenz-backend/src/public/locales/pt/admin-center/overview.json new file mode 100644 index 00000000..7cce8587 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/admin-center/overview.json @@ -0,0 +1,8 @@ +{ + "overview": "Visão Geral", + "name": "Nome da Organização", + "owner": "Proprietário da Organização", + "admins": "Administradores da Organização", + "contactNumber": "Adicione o Número de Contato", + "edit": "Editar" +} diff --git a/worklenz-backend/src/public/locales/pt/admin-center/projects.json b/worklenz-backend/src/public/locales/pt/admin-center/projects.json new file mode 100644 index 00000000..02fdc0bb --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/admin-center/projects.json @@ -0,0 +1,12 @@ +{ + "membersCount": "Contagem de Membros", + "createdAt": "Criado em", + "projectName": "Nome do Projeto", + "teamName": "Nome do Time", + "refreshProjects": "Atualizar Projetos", + "searchPlaceholder": "Pesquisar por nome do projeto", + "deleteProject": "Tem a certeza de que deseja deletar este projeto?", + "confirm": "Confirmar", + "cancel": "Cancelar", + "delete": "Deletar Projeto" +} diff --git a/worklenz-backend/src/public/locales/pt/admin-center/sidebar.json b/worklenz-backend/src/public/locales/pt/admin-center/sidebar.json new file mode 100644 index 00000000..253b77e4 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/admin-center/sidebar.json @@ -0,0 +1,8 @@ +{ + "overview": "Visão Geral", + "users": "Usuários", + "teams": "Equipes", + "billing": "Faturamento", + "projects": "Projetos", + "adminCenter": "Central Administrativa" +} diff --git a/worklenz-backend/src/public/locales/pt/admin-center/teams.json b/worklenz-backend/src/public/locales/pt/admin-center/teams.json new file mode 100644 index 00000000..6a71b491 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/admin-center/teams.json @@ -0,0 +1,35 @@ +{ + "title": "Equipes", + "subtitle": "equipes", + "tooltip": "Atualizar equipes", + "placeholder": "Pesquisar por nome", + "addTeam": "Adicionar Equipe", + "team": "Equipe", + "membersCount": "Contagem de Membros", + "members": "Membros", + "drawerTitle": "Criar Nova Equipe", + "label": "Nome da Equipe", + "drawerPlaceholder": "Nome", + "create": "Criar", + "delete": "Deletar", + "settings": "Configurações", + "popTitle": "Tem a certeza?", + "message": "Por favor, insira um Nome", + "teamSettings": "Configurações da Equipe", + "teamName": "Nome da Equipe", + "teamDescription": "Descrição da Equipe", + "teamMembers": "Membros da Equipe", + "teamMembersCount": "Quantidade de Membros da Equipe", + "teamMembersPlaceholder": "Buscar por nome", + "addMember": "Adicionar Membro", + "add": "Adicionar", + "update": "Atualizar", + "teamNamePlaceholder": "Nome da Equipe", + "user": "Usuário", + "role": "Rol", + "owner": "Propietario", + "admin": "Administrador", + "member": "Miembro", + "cannotChangeOwnerRole": "A função de Proprietário não pode ser alterada", + "pendingInvitation": "Convite pendente" +} diff --git a/worklenz-backend/src/public/locales/pt/admin-center/users.json b/worklenz-backend/src/public/locales/pt/admin-center/users.json new file mode 100644 index 00000000..9826c548 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/admin-center/users.json @@ -0,0 +1,9 @@ +{ + "title": "Usuários", + "subTitle": "usuários", + "placeholder": "Pesquisar por nome", + "user": "Usuário", + "email": "Email", + "lastActivity": "Última Atividade", + "refresh": "Atualizar usuários" +} diff --git a/worklenz-backend/src/public/locales/pt/all-project-list.json b/worklenz-backend/src/public/locales/pt/all-project-list.json new file mode 100644 index 00000000..482132eb --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/all-project-list.json @@ -0,0 +1,34 @@ +{ + "name": "Nome", + "client": "Cliente", + "category": "Categoria", + "status": "Status", + "tasksProgress": "Progresso das Tarefas", + "updated_at": "Última Atualização", + "members": "Membros", + "setting": "Configurações", + "projects": "Projetos", + "refreshProjects": "Atualizar projetos", + "all": "Todos", + "favorites": "Favoritos", + "archived": "Arquivados", + "placeholder": "Pesquisar por nome", + "archive": "Arquivar", + "unarchive": "Desarquivar", + "archiveConfirm": "Tem certeza de que deseja arquivar este projeto?", + "unarchiveConfirm": "Tem certeza de que deseja desarquivar este projeto?", + "yes": "Sim", + "no": "Não", + "clickToFilter": "Clique para filtrar por", + "noProjects": "Nenhum projeto encontrado", + "addToFavourites": "Adicionar aos favoritos", + "list": "Lista", + "group": "Grupo", + "listView": "Visualização em Lista", + "groupView": "Visualização em Grupo", + "groupBy": { + "category": "Categoria", + "client": "Cliente" + }, + "noPermission": "Você não tem permissão para realizar esta ação" +} diff --git a/worklenz-backend/src/public/locales/pt/auth/auth-common.json b/worklenz-backend/src/public/locales/pt/auth/auth-common.json new file mode 100644 index 00000000..e828bddf --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/auth/auth-common.json @@ -0,0 +1,5 @@ +{ + "loggingOut": "Deslogando...", + "authenticating": "Autenticando...", + "gettingThingsReady": "Preparando coisas para você..." +} diff --git a/worklenz-backend/src/public/locales/pt/auth/forgot-password.json b/worklenz-backend/src/public/locales/pt/auth/forgot-password.json new file mode 100644 index 00000000..5e9c89e0 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/auth/forgot-password.json @@ -0,0 +1,12 @@ +{ + "headerDescription": "Redefina sua senha", + "emailLabel": "Email", + "emailPlaceholder": "Digite seu email", + "emailRequired": "Por favor, digite seu email!", + "resetPasswordButton": "Redefinir Senha", + "returnToLoginButton": "Voltar para Login", + "passwordResetSuccessMessage": "Um link de redefinição de senha foi enviado para seu email.", + "orText": "OU", + "successTitle": "Instruções de redefinição enviadas!", + "successMessage": "A informação de redefinição foi enviada para seu email. Por favor, verifique seu email." +} diff --git a/worklenz-backend/src/public/locales/pt/auth/login.json b/worklenz-backend/src/public/locales/pt/auth/login.json new file mode 100644 index 00000000..2ce79115 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/auth/login.json @@ -0,0 +1,27 @@ +{ + "headerDescription": "Faça login na sua conta", + "emailLabel": "Email", + "emailPlaceholder": "Digite seu email", + "emailRequired": "Por favor, digite seu email!", + "passwordLabel": "Senha", + "passwordPlaceholder": "Digite sua senha", + "passwordRequired": "Por favor, digite sua Senha!", + "rememberMe": "Lembre de mim", + "loginButton": "Entrar", + "signupButton": "Inscrever-se", + "forgotPasswordButton": "Esqueceu sua senha?", + "signInWithGoogleButton": "Entrar com Google", + "successMessage": "Você entrou com sucesso!", + "dontHaveAccountText": "Não tem uma conta?", + "orText": "OU", + "loginError": "Login falhou", + "googleLoginError": "Login com Google falhou", + "validationMessages": { + "password": "A senha deve ter pelo menos 8 caracteres", + "email": "Por favor, insira um endereço de e-mail válido" + }, + "errorMessages": { + "loginErrorTitle": "Login falhou", + "loginErrorMessage": "Por favor, verifique seu e-mail e senha e tente novamente" + } +} diff --git a/worklenz-backend/src/public/locales/pt/auth/signup.json b/worklenz-backend/src/public/locales/pt/auth/signup.json new file mode 100644 index 00000000..cd994d4a --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/auth/signup.json @@ -0,0 +1,29 @@ +{ + "headerDescription": "Inscreva-se para começar", + "nameLabel": "Nome Completo", + "namePlaceholder": "Insira seu nome completo", + "nameRequired": "Por favor, insira seu nome completo!", + "nameMinCharacterRequired": "Nome completo deve ter pelo menos 4 caracteres!", + "emailLabel": "Email", + "emailPlaceholder": "Insira seu email", + "emailRequired": "Por favor, insira seu Email!", + "passwordLabel": "Senha", + "passwordPlaceholder": "Insira sua senha", + "passwordRequired": "Por favor, insira sua Senha!", + "passwordMinCharacterRequired": "Senha deve ter pelo menos 8 caracteres!", + "passwordPatternRequired": "Senha não atende aos requisitos!", + "strongPasswordPlaceholder": "Insira uma senha mais forte", + "passwordValidationAltText": "Senha deve incluir pelo menos 8 caracteres com letras maiúsculas e minúsculas, um número e um símbolo.", + "signupSuccessMessage": "Você se inscreveu com sucesso!", + "privacyPolicyLink": "Política de Privacidade", + "termsOfUseLink": "Termos de Uso", + "bySigningUpText": "Ao se inscrever, você concorda com nossos", + "andText": "e", + "signupButton": "Inscrever-se", + "signInWithGoogleButton": "Entrar com Google", + "alreadyHaveAccountText": "Já tem uma conta?", + "loginButton": "Entrar", + "orText": "OU", + "reCAPTCHAVerificationError": "Erro de verificação do reCAPTCHA", + "reCAPTCHAVerificationErrorMessage": "Não pudemos verificar seu reCAPTCHA. Por favor, tente novamente." +} diff --git a/worklenz-backend/src/public/locales/pt/auth/verify-reset-email.json b/worklenz-backend/src/public/locales/pt/auth/verify-reset-email.json new file mode 100644 index 00000000..189a6d51 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/auth/verify-reset-email.json @@ -0,0 +1,14 @@ +{ + "title": "Verificar E-mail de Redefinição", + "description": "Digite sua nova senha", + "placeholder": "Digite sua nova senha", + "confirmPasswordPlaceholder": "Confirme sua nova senha", + "passwordHint": "Mínimo de 8 caracteres, com maiúsculas e minúsculas, um número e um símbolo.", + "resetPasswordButton": "Redefinir senha", + "orText": "Ou", + "resendResetEmail": "Reenviar e-mail de redefinição", + "passwordRequired": "Por favor, digite sua nova senha", + "returnToLoginButton": "Voltar ao Login", + "confirmPasswordRequired": "Por favor, confirme sua nova senha", + "passwordMismatch": "As senhas não coincidem" +} diff --git a/worklenz-backend/src/public/locales/pt/common.json b/worklenz-backend/src/public/locales/pt/common.json new file mode 100644 index 00000000..ce540a28 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/common.json @@ -0,0 +1,9 @@ +{ + "login-success": "Login realizado com sucesso!", + "login-failed": "Falha no login. Por favor, verifique suas credenciais e tente novamente.", + "signup-success": "Cadastro realizado com sucesso! Bem-vindo a bordo.", + "signup-failed": "Falha no cadastro. Por favor, certifique-se de que todos os campos obrigatórios estão preenchidos e tente novamente.", + "reconnecting": "Reconectando ao servidor...", + "connection-lost": "Conexão perdida. Tentando reconectar...", + "connection-restored": "Conexão restaurada. Reconectando ao servidor..." +} diff --git a/worklenz-backend/src/public/locales/pt/create-first-project-form.json b/worklenz-backend/src/public/locales/pt/create-first-project-form.json new file mode 100644 index 00000000..ec3ec300 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/create-first-project-form.json @@ -0,0 +1,13 @@ +{ + "formTitle": "Crie seu primeiro projeto", + "inputLabel": "Em qual projeto você está trabalhando agora?", + "or": "ou", + "templateButton": "Importar do modelo", + "createFromTemplate": "Criar do modelo", + "goBack": "Voltar", + "continue": "Continuar", + "cancel": "Cancelar", + "create": "Criar", + "templateDrawerTitle": "Selecione um modelo", + "createProject": "Criar projeto" +} diff --git a/worklenz-backend/src/public/locales/pt/create-first-tasks.json b/worklenz-backend/src/public/locales/pt/create-first-tasks.json new file mode 100644 index 00000000..06c1ae87 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/create-first-tasks.json @@ -0,0 +1,7 @@ +{ + "formTitle": "Crie sua primeira tarefa.", + "inputLable": "Digite algumas tarefas que você vai fazer em", + "addAnother": "Adicionar outro", + "goBack": "Voltar", + "continue": "Continuar" +} diff --git a/worklenz-backend/src/public/locales/pt/home.json b/worklenz-backend/src/public/locales/pt/home.json new file mode 100644 index 00000000..b19ece5f --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/home.json @@ -0,0 +1,45 @@ +{ + "todoList": { + "title": "Lista de tarefas", + "refreshTasks": "Atualizar tarefas", + "addTask": "+ Adicionar tarefa", + "noTasks": "Nenhuma tarefa", + "pressEnter": "Pressione", + "toCreate": "para criar.", + "markAsDone": "Marcar como feito" + }, + "projects": { + "title": "Projetos", + "refreshProjects": "Atualizar projetos", + "noRecentProjects": "Você não está atribuído a nenhum projeto.", + "noFavouriteProjects": "Nenhum projeto foi marcado como favorito.", + "recent": "Recentes", + "favourites": "Favoritos" + }, + "tasks": { + "assignedToMe": "Atribuído a mim", + "assignedByMe": "Atribuído por mim", + "all": "Todas", + "today": "Hoje", + "upcoming": "Próximas", + "overdue": "Vencidas", + "noDueDate": "Sem data de vencimento", + "noTasks": "Nenhuma tarefa para mostrar.", + "addTask": "+ Adicionar tarefa", + "name": "Nome", + "project": "Projeto", + "status": "Status", + "dueDate": "Data de vencimento", + "dueDatePlaceholder": "Definir data de vencimento", + "tomorrow": "Amanhã", + "nextWeek": "Semana que vem", + "nextMonth": "Próximo mês", + "projectRequired": "Por favor selecione um projeto", + "dueOn": "Tarefas vencidas em", + "taskRequired": "Por favor adicione uma tarefa", + "list": "Lista", + "calendar": "Calendário", + "tasks": "Tarefas", + "refresh": "Atualizar" + } +} diff --git a/worklenz-backend/src/public/locales/pt/invite-initial-team-members.json b/worklenz-backend/src/public/locales/pt/invite-initial-team-members.json new file mode 100644 index 00000000..39808ab2 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/invite-initial-team-members.json @@ -0,0 +1,8 @@ +{ + "formTitle": "Convide sua equipe para trabalhar com", + "inputLable": "Convidar com email", + "addAnother": "Adicionar outro", + "goBack": "Voltar", + "continue": "Continuar", + "skipForNow": "Pular por enquanto" +} diff --git a/worklenz-backend/src/public/locales/pt/kanban-board.json b/worklenz-backend/src/public/locales/pt/kanban-board.json new file mode 100644 index 00000000..a2034daa --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/kanban-board.json @@ -0,0 +1,30 @@ +{ + "rename": "Renomear", + "delete": "Excluir", + "addTask": "Adicionar Tarefa", + "addSectionButton": "Adicionar Seção", + "changeCategory": "Alterar categoria", + + "deleteTooltip": "Excluir", + "deleteConfirmationTitle": "Tem certeza?", + "deleteConfirmationOk": "Sim", + "deleteConfirmationCancel": "Cancelar", + + "dueDate": "Data de vencimento", + "cancel": "Cancelar", + + "today": "Hoje", + "tomorrow": "Amanhã", + "assignToMe": "Atribuir a mim", + "archive": "Arquivar", + + "newTaskNamePlaceholder": "Escreva um nome de tarefa", + "newSubtaskNamePlaceholder": "Escreva um nome de subtarefa", + "untitledSection": "Seção sem título", + "unmapped": "Não mapeado", + "clickToChangeDate": "Clique para alterar a data", + "noDueDate": "Sem data de vencimento", + "save": "Salvar", + "clear": "Limpar", + "nextWeek": "Próxima semana" +} diff --git a/worklenz-backend/src/public/locales/pt/license-expired.json b/worklenz-backend/src/public/locales/pt/license-expired.json new file mode 100644 index 00000000..aa7ae88b --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/license-expired.json @@ -0,0 +1,6 @@ +{ + "title": "Seu teste do Worklenz expirou!", + "subtitle": "Por favor, atualize agora.", + "button": "Atualizar agora", + "checking": "Verificando status da assinatura..." +} diff --git a/worklenz-backend/src/public/locales/pt/navbar.json b/worklenz-backend/src/public/locales/pt/navbar.json new file mode 100644 index 00000000..be0f3a63 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/navbar.json @@ -0,0 +1,31 @@ +{ + "logoAlt": "Logotipo Worklenz", + "home": "Início", + "projects": "Projetos", + "schedule": "Agendamento", + "reporting": "Relatórios", + "clients": "Clientes", + "teams": "Equipes", + "labels": "Rótulos", + "jobTitles": "Títulos de Emprego", + "upgradePlan": "Plano de Upgrade", + "upgradePlanTooltip": "Plano de Upgrade", + "invite": "Convidar", + "inviteTooltip": "Convidar membros da equipe a se juntar", + "switchTeamTooltip": "Trocar equipe", + "help": "Ajuda", + "notificationTooltip": "Ver notificações", + "profileTooltip": "Ver perfil", + "adminCenter": "Centro de administração", + "settings": "Configurações", + "logOut": "Sair", + "notificationsDrawer": { + "read": "Notificações lidas", + "unread": "Notificações não lidas", + "markAsRead": "Marcar como lido", + "readAndJoin": "Ler e participar", + "accept": "Aceitar", + "acceptAndJoin": "Aceitar e participar", + "noNotifications": "Sem notificações" + } +} diff --git a/worklenz-backend/src/public/locales/pt/organization-name-form.json b/worklenz-backend/src/public/locales/pt/organization-name-form.json new file mode 100644 index 00000000..c165b8cb --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/organization-name-form.json @@ -0,0 +1,5 @@ +{ + "nameYourOrganization": "Nomeie sua organização.", + "worklenzAccountTitle": "Escolha um nome para sua conta Worklenz.", + "continue": "Continuar" +} diff --git a/worklenz-backend/src/public/locales/pt/phases-drawer.json b/worklenz-backend/src/public/locales/pt/phases-drawer.json new file mode 100644 index 00000000..080b13df --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/phases-drawer.json @@ -0,0 +1,19 @@ +{ + "configurePhases": "Configurar fases", + "phaseLabel": "Etiqueta de fase", + "enterPhaseName": "Digite um nome para o rótulo da fase", + "addOption": "Adicionar Opção", + "phaseOptions": "Opções de Fase:", + "dragToReorderPhases": "Arraste as fases para reordená-las. Cada fase pode ter uma cor diferente.", + "enterNewPhaseName": "Digite o novo nome da fase...", + "addPhase": "Adicionar Fase", + "noPhasesFound": "Nenhuma fase encontrada. Crie sua primeira fase acima.", + "deletePhase": "Excluir Fase", + "deletePhaseConfirm": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.", + "rename": "Renomear", + "delete": "Excluir", + "enterPhaseName": "Digite o nome da fase", + "selectColor": "Selecionar cor", + "managePhases": "Gerenciar Fases", + "close": "Fechar" +} diff --git a/worklenz-backend/src/public/locales/pt/project-drawer.json b/worklenz-backend/src/public/locales/pt/project-drawer.json new file mode 100644 index 00000000..92e11964 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/project-drawer.json @@ -0,0 +1,52 @@ +{ + "createProject": "Criar Projeto", + "editProject": "Editar Projeto", + "enterCategoryName": "Insira um nome para a categoria", + "hitEnterToCreate": "Pressione enter para criar!", + "enterNotes": "Notas", + "youCanManageClientsUnderSettings": "Você pode gerenciar clientes em Configurações", + "addCategory": "Adicione uma categoria ao projeto", + "newCategory": "Nova Categoria", + "notes": "Notas", + "startDate": "Data de Início", + "endDate": "Data de Fim", + "estimateWorkingDays": "Estime os dias de trabalho", + "estimateManDays": "Estime os dias de trabalho", + "hoursPerDay": "Horas por dia", + "create": "Criar", + "update": "Atualizar", + "delete": "Excluir", + "typeToSearchClients": "Digite para buscar clientes", + "projectColor": "Cor do Projeto", + "pleaseEnterAName": "Por favor, insira um nome", + "enterProjectName": "Insira o nome do projeto", + "name": "Nome", + "status": "Estado", + "health": "Saúde", + "category": "Categoria", + "projectManager": "Gerente de Projeto", + "client": "Cliente", + "deleteConfirmation": "Tem a certeza de que deseja excluir?", + "deleteConfirmationDescription": "Isso removerá todos os dados associados e não pode ser desfeito.", + "yes": "Sim", + "no": "Não", + "createdAt": "Criado", + "updatedAt": "Atualizado", + "by": "por", + "add": "Adicionar", + "asClient": "como cliente", + "createClient": "Criar cliente", + "searchInputPlaceholder": "Pesquise por nome ou email", + "hoursPerDayValidationMessage": "As horas por dia devem ser um número entre 1 e 24", + "workingDaysValidationMessage": "Os dias de trabalho devem ser um número positivo", + "manDaysValidationMessage": "Os dias de homem devem ser um número positivo", + "noPermission": "Sem permissão", + "progressSettings": "Configurações de Progresso", + "manualProgress": "Progresso Manual", + "manualProgressTooltip": "Permitir atualizações manuais de progresso para tarefas sem subtarefas", + "weightedProgress": "Progresso Ponderado", + "weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas", + "timeProgress": "Progresso Baseado em Tempo", + "timeProgressTooltip": "Calcular o progresso com base no tempo estimado", + "enterProjectKey": "Insira a chave do projeto" +} diff --git a/worklenz-backend/src/public/locales/pt/project-view-files.json b/worklenz-backend/src/public/locales/pt/project-view-files.json new file mode 100644 index 00000000..61f1cb59 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/project-view-files.json @@ -0,0 +1,14 @@ +{ + "nameColumn": "Nome", + "attachedTaskColumn": "Tarefa Anexada", + "sizeColumn": "Tamanho", + "uploadedByColumn": "Enviado Por", + "uploadedAtColumn": "Enviado Em", + "fileIconAlt": "Ícone do Arquivo", + "titleDescriptionText": "Todos os anexos das tarefas neste projeto aparecerão aqui.", + "deleteConfirmationTitle": "Tem certeza?", + "deleteConfirmationOk": "Sim", + "deleteConfirmationCancel": "Cancelar", + "segmentedTooltip": "Em breve! Alterne entre a visualização em lista e a visualização em miniatura.", + "emptyText": "Não há anexos no projeto." +} diff --git a/worklenz-backend/src/public/locales/pt/project-view-insights.json b/worklenz-backend/src/public/locales/pt/project-view-insights.json new file mode 100644 index 00000000..2ad6ee92 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/project-view-insights.json @@ -0,0 +1,41 @@ +{ + "overview": { + "title": "Visão Geral", + "statusOverview": "Visão Geral do Status", + "priorityOverview": "Visão Geral da Prioridade", + "lastUpdatedTasks": "Últimas Tarefas Atualizadas" + }, + "members": { + "title": "Membros", + "tooltip": "Membros", + "tasksByMembers": "Tarefas por membros", + "tasksByMembersTooltip": "Tarefas por membros", + "name": "Nome", + "taskCount": "Contagem de Tarefas", + "contribution": "Contribuição", + "completed": "Concluído", + "incomplete": "Incompleto", + "overdue": "Atrasado", + "progress": "Progresso" + }, + "tasks": { + "overdueTasks": "Tarefas Atrasadas", + "overLoggedTasks": "Tarefas com excesso de tempo registrado", + "tasksCompletedEarly": "Tarefas concluídas cedo", + "tasksCompletedLate": "Tarefas concluídas tarde", + "overLoggedTasksTooltip": "Tarefas que têm tempo registrado além do tempo estimado", + "overdueTasksTooltip": "Tarefas que estão atrasadas" + }, + "common": { + "seeAll": "Ver tudo", + "totalLoggedHours": "Total de horas registradas", + "totalEstimation": "Total de estimativa", + "completedTasks": "Tarefas concluídas", + "incompleteTasks": "Tarefas incompletas", + "overdueTasks": "Tarefas atrasadas", + "overdueTasksTooltip": "Tarefas que estão atrasadas", + "totalLoggedHoursTooltip": "Estimativa de tarefas e tempo registrado.", + "includeArchivedTasks": "Incluir Tarefas Arquivadas", + "export": "Exportar" + } +} diff --git a/worklenz-backend/src/public/locales/pt/project-view-members.json b/worklenz-backend/src/public/locales/pt/project-view-members.json new file mode 100644 index 00000000..72524807 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/project-view-members.json @@ -0,0 +1,17 @@ +{ + "nameColumn": "Nome", + "jobTitleColumn": "Título do Cargo", + "emailColumn": "Email", + "tasksColumn": "Tarefas", + "taskProgressColumn": "Progresso da Tarefa", + "accessColumn": "Acesso", + "fileIconAlt": "Ícone do Arquivo", + "deleteConfirmationTitle": "Tem a certeza?", + "deleteConfirmationOk": "Sim", + "deleteConfirmationCancel": "Cancelar", + "refreshButtonTooltip": "Atualizar membros", + "deleteButtonTooltip": "Remover do projeto", + "memberCount": "Membro", + "membersCountPlural": "Membros", + "emptyText": "Não há anexos no projeto." +} diff --git a/worklenz-backend/src/public/locales/pt/project-view-updates.json b/worklenz-backend/src/public/locales/pt/project-view-updates.json new file mode 100644 index 00000000..93a48950 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/project-view-updates.json @@ -0,0 +1,6 @@ +{ + "inputPlaceholder": "Adicione um comentário..", + "addButton": "Adicionar", + "cancelButton": "Cancelar", + "deleteButton": "Deletar" +} diff --git a/worklenz-backend/src/public/locales/pt/project-view.json b/worklenz-backend/src/public/locales/pt/project-view.json new file mode 100644 index 00000000..c58337da --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/project-view.json @@ -0,0 +1,14 @@ +{ + "taskList": "Lista de Tarefas", + "board": "Quadro Kanban", + "insights": "Insights", + "files": "Arquivos", + "members": "Membros", + "updates": "Atualizações", + "projectView": "Visualização do Projeto", + "loading": "Carregando projeto...", + "error": "Erro ao carregar projeto", + "pinnedTab": "Fixada como aba padrão", + "pinTab": "Fixar como aba padrão", + "unpinTab": "Desfixar aba padrão" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/pt/project-view/import-task-templates.json b/worklenz-backend/src/public/locales/pt/project-view/import-task-templates.json new file mode 100644 index 00000000..81a64607 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/project-view/import-task-templates.json @@ -0,0 +1,11 @@ +{ + "importTaskTemplate": "Importar modelo de tarefa", + "templateName": "Nome do modelo", + "templateDescription": "Descrição do modelo", + "selectedTasks": "Tarefas selecionadas", + "tasks": "Tarefas", + "templates": "Modelos", + "remove": "Remover", + "cancel": "Cancelar", + "import": "Importar" +} diff --git a/worklenz-backend/src/public/locales/pt/project-view/project-member-drawer.json b/worklenz-backend/src/public/locales/pt/project-view/project-member-drawer.json new file mode 100644 index 00000000..0afe3d87 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/project-view/project-member-drawer.json @@ -0,0 +1,7 @@ +{ + "title": "Membros do Projeto", + "searchLabel": "Adicionar membros inserindo nome ou e-mail", + "searchPlaceholder": "Digite nome ou e-mail", + "inviteAsAMember": "Convidar como membro", + "inviteNewMemberByEmail": "Convidar novo membro por e-mail" +} diff --git a/worklenz-backend/src/public/locales/pt/project-view/project-view-header.json b/worklenz-backend/src/public/locales/pt/project-view/project-view-header.json new file mode 100644 index 00000000..4649b768 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/project-view/project-view-header.json @@ -0,0 +1,30 @@ +{ + "importTasks": "Importar tarefas", + "importTask": "Importar tarefa", + "createTask": "Criar tarefa", + "settings": "Configurações", + "subscribe": "Inscrever-se", + "unsubscribe": "Cancelar inscrição", + "deleteProject": "Excluir projeto", + "startDate": "Data de início", + "endDate": "Data de término", + "projectSettings": "Configurações do projeto", + "projectSummary": "Resumo do projeto", + "receiveProjectSummary": "Receba um resumo do projeto todas as noites.", + "refreshProject": "Atualizar projeto", + "saveAsTemplate": "Salvar como modelo", + "invite": "Convidar", + "share": "Compartilhar", + "subscribeTooltip": "Inscrever-se nas notificações do projeto", + "unsubscribeTooltip": "Cancelar inscrição nas notificações do projeto", + "refreshTooltip": "Atualizar dados do projeto", + "settingsTooltip": "Abrir configurações do projeto", + "saveAsTemplateTooltip": "Salvar este projeto como modelo", + "inviteTooltip": "Convidar membros da equipe para este projeto", + "createTaskTooltip": "Criar uma nova tarefa", + "importTaskTooltip": "Importar tarefa de modelo", + "navigateBackTooltip": "Voltar para lista de projetos", + "projectStatusTooltip": "Status do projeto", + "projectDatesInfo": "Informações do cronograma do projeto", + "projectCategoryTooltip": "Categoria do projeto" +} diff --git a/worklenz-backend/src/public/locales/pt/project-view/save-as-template.json b/worklenz-backend/src/public/locales/pt/project-view/save-as-template.json new file mode 100644 index 00000000..c67eb20e --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/project-view/save-as-template.json @@ -0,0 +1,27 @@ +{ + "title": "Salvar como Modelo", + "templateName": "Nome do Modelo", + "includes": "O que deve ser incluído no modelo do projeto?", + "includesOptions": { + "statuses": "Status", + "phases": "Fases", + "labels": "Etiquetas" + }, + "taskIncludes": "O que deve ser incluído no modelo das tarefas?", + "taskIncludesOptions": { + "statuses": "Status", + "phases": "Fases", + "labels": "Etiquetas", + "name": "Nome", + "priority": "Prioridade", + "status": "Status", + "phase": "Fase", + "label": "Etiqueta", + "timeEstimate": "Estimativa de Tempo", + "description": "Descrição", + "subTasks": "Subtarefas" + }, + "cancel": "Cancelar", + "save": "Salvar", + "templateNamePlaceholder": "Digite o nome do modelo" +} diff --git a/worklenz-backend/src/public/locales/pt/reporting-members-drawer.json b/worklenz-backend/src/public/locales/pt/reporting-members-drawer.json new file mode 100644 index 00000000..49d0008b --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/reporting-members-drawer.json @@ -0,0 +1,90 @@ +{ + "exportButton": "Exportar", + "timeLogsButton": "Registros de Tempo", + "activityLogsButton": "Registros de Atividade", + "tasksButton": "Tarefas", + "searchByNameInputPlaceholder": "Pesquisar por nome", + + "overviewTab": "Visão Geral", + "timeLogsTab": "Registros de Tempo", + "activityLogsTab": "Registros de Atividade", + "tasksTab": "Tarefas", + + "projectsText": "Projetos", + "totalTasksText": "Total de Tarefas", + "assignedTasksText": "Tarefas Atribuídas", + "completedTasksText": "Tarefas Concluídas", + "ongoingTasksText": "Tarefas em Andamento", + "overdueTasksText": "Tarefas Atrasadas", + "loggedHoursText": "Horas Registradas", + + "tasksText": "Tarefas", + "allText": "Todas", + + "tasksByProjectsText": "Tarefas Por Projetos", + "tasksByStatusText": "Tarefas Por Status", + "tasksByPriorityText": "Tarefas Por Prioridade", + + "todoText": "A Fazer", + "doingText": "Fazendo", + "doneText": "Feita", + "lowText": "Baixa", + "mediumText": "Média", + "highText": "Alta", + + "billableButton": "Cobrável", + "billableText": "Cobrável", + "nonBillableText": "Não Cobrável", + + "timeLogsEmptyPlaceholder": "Nenhum registro de tempo para mostrar", + "loggedText": "Registrado", + "forText": "para", + "inText": "em", + "updatedText": "Atualizado", + "fromText": "De", + "toText": "até", + "withinText": "dentro de", + + "activityLogsEmptyPlaceholder": "Nenhum registro de atividade para mostrar", + + "filterByText": "Filtrar por:", + "selectProjectPlaceholder": "Selecione o Projeto", + + "taskColumn": "Tarefa", + "nameColumn": "Nome", + "projectColumn": "Projeto", + "statusColumn": "Status", + "priorityColumn": "Prioridade", + "dueDateColumn": "Data de Vencimento", + "completedDateColumn": "Data de Conclusão", + "estimatedTimeColumn": "Tempo Estimado", + "loggedTimeColumn": "Tempo Registrado", + "overloggedTimeColumn": "Tempo Excedido", + "daysLeftColumn": "Dias Restantes/Atrasados", + "startDateColumn": "Data de Início", + "endDateColumn": "Data de Fim", + "actualTimeColumn": "Tempo Real", + "projectHealthColumn": "Saúde do Projeto", + "categoryColumn": "Categoria", + "projectManagerColumn": "Gerente do Projeto", + + "tasksStatsOverviewDrawerTitle": "Tarefas de", + "projectsStatsOverviewDrawerTitle": "Projetos de", + + "cancelledText": "Cancelada", + "blockedText": "Bloqueada", + "onHoldText": "Em Espera", + "proposedText": "Proposta", + "inPlanningText": "Em Planejamento", + "inProgressText": "Em Progresso", + "completedText": "Concluída", + "continuousText": "Contínua", + + "daysLeftText": "dias restantes", + "daysOverdueText": "dias atrasados", + + "notSetText": "Não Definido", + "needsAttentionText": "Precisa de Atenção", + "atRiskText": "Em Risco", + "goodText": "Bom" +} diff --git a/worklenz-backend/src/public/locales/pt/reporting-members.json b/worklenz-backend/src/public/locales/pt/reporting-members.json new file mode 100644 index 00000000..a8035dcd --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/reporting-members.json @@ -0,0 +1,35 @@ +{ + "yesterdayText": "Yesterday", + "lastSevenDaysText": "Last 7 Days", + "lastWeekText": "Last Week", + "lastThirtyDaysText": "Last 30 Days", + "lastMonthText": "Last Month", + "lastThreeMonthsText": "Last 3 Months", + "allTimeText": "All Time", + "customRangeText": "Custom range", + "startDateInputPlaceholder": "Start date", + "EndDateInputPlaceholder": "End date", + "filterButton": "Filter", + + "membersTitle": "Members", + "includeArchivedButton": "Include Archived Projects", + "exportButton": "Export", + "excelButton": "Excel", + "searchByNameInputPlaceholder": "Search by name", + + "memberColumn": "Member", + "tasksProgressColumn": "Tasks Progress", + "tasksAssignedColumn": "Tasks Assigned", + "completedTasksColumn": "Completed Tasks", + "overdueTasksColumn": "Overdue Tasks", + "ongoingTasksColumn": "Ongoing Tasks", + + "tasksAssignedColumnTooltip": "Tasks assigned on selected date range", + "overdueTasksColumnTooltip": "Tasks overdue for end of the selected date range", + "completedTasksColumnTooltip": "Tasks completed on selected date range", + "ongoingTasksColumnTooltip": "Started tasks not completed yet", + + "todoText": "To Do", + "doingText": "Doing", + "doneText": "Done" +} diff --git a/worklenz-backend/src/public/locales/pt/reporting-overview-drawer.json b/worklenz-backend/src/public/locales/pt/reporting-overview-drawer.json new file mode 100644 index 00000000..af8b06ee --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/reporting-overview-drawer.json @@ -0,0 +1,39 @@ +{ + "exportButton": "Exportar", + "projectsButton": "Projetos", + "membersButton": "Membros", + "searchByNameInputPlaceholder": "Pesquisar por nome", + + "overviewTab": "Visão Geral", + "projectsTab": "Projetos", + "membersTab": "Membros", + + "projectsByStatusText": "Projetos Por Status", + "projectsByCategoryText": "Projetos Por Categoria", + "projectsByHealthText": "Projetos Por Saúde", + + "projectsText": "Projetos", + "allText": "Todos", + + "cancelledText": "Cancelado", + "blockedText": "Bloqueado", + "onHoldText": "Em Espera", + "proposedText": "Proposto", + "inPlanningText": "Em Planejamento", + "inProgressText": "Em Andamento", + "completedText": "Concluído", + "continuousText": "Contínuo", + + "notSetText": "Não Definido", + "needsAttentionText": "Necessita de Atenção", + "atRiskText": "Em Risco", + "goodText": "Bom", + + "nameColumn": "Nome", + "emailColumn": "Email", + "projectsColumn": "Projetos", + "tasksColumn": "Tarefas", + "overdueTasksColumn": "Tarefas Atrasadas", + "completedTasksColumn": "Tarefas Concluídas", + "ongoingTasksColumn": "Tarefas em Andamento" +} diff --git a/worklenz-backend/src/public/locales/pt/reporting-overview.json b/worklenz-backend/src/public/locales/pt/reporting-overview.json new file mode 100644 index 00000000..01681d1a --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/reporting-overview.json @@ -0,0 +1,25 @@ +{ + "overviewTitle": "Visão Geral", + "includeArchivedButton": "Incluir Projetos Arquivados", + + "teamCount": "Equipe", + "teamCountPlural": "Equipes", + "projectCount": "Projeto", + "projectCountPlural": "Projetos", + "memberCount": "Membro", + "memberCountPlural": "Membros", + "activeProjectCount": "Projeto Ativo", + "activeProjectCountPlural": "Projetos Ativos", + "overdueProjectCount": "Projeto Atrasado", + "overdueProjectCountPlural": "Projetos Atrasados", + "unassignedMemberCount": "Membro Não Atribuído", + "unassignedMemberCountPlural": "Membros Não Atribuídos", + "memberWithOverdueTaskCount": "Membro Com Tarefa Atrasada", + "memberWithOverdueTaskCountPlural": "Membros Com Tarefas Atrasadas", + + "teamsText": "Equipes", + + "nameColumn": "Nome", + "projectsColumn": "Projetos", + "membersColumn": "Membros" +} diff --git a/worklenz-backend/src/public/locales/pt/reporting-projects-drawer.json b/worklenz-backend/src/public/locales/pt/reporting-projects-drawer.json new file mode 100644 index 00000000..14bcfaca --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/reporting-projects-drawer.json @@ -0,0 +1,59 @@ +{ + "exportButton": "Exportar", + "membersButton": "Membros", + "tasksButton": "Tarefas", + "searchByNameInputPlaceholder": "Pesquisar por nome", + + "overviewTab": "Visão Geral", + "membersTab": "Membros", + "tasksTab": "Tarefas", + + "completedTasksText": "Tarefas Concluídas", + "incompleteTasksText": "Tarefas Incompletas", + "overdueTasksText": "Tarefas Atrasadas", + "allocatedHoursText": "Horas Alocadas", + "loggedHoursText": "Horas Registradas", + + "tasksText": "Tarefas", + "allText": "Todas", + + "tasksByStatusText": "Tarefas Por Status", + "tasksByPriorityText": "Tarefas Por Prioridade", + "tasksByDueDateText": "Tarefas Por Data de Vencimento", + + "todoText": "A Fazer", + "doingText": "Fazendo", + "doneText": "Feita", + "lowText": "Baixa", + "mediumText": "Média", + "highText": "Alta", + "completedText": "Concluída", + "upcomingText": "Próxima", + "overdueText": "Atrasada", + "noDueDateText": "Sem Data de Vencimento", + + "nameColumn": "Nome", + "tasksCountColumn": "Contagem de Tarefas", + "completedTasksColumn": "Tarefas Concluídas", + "incompleteTasksColumn": "Tarefas Incompletas", + "overdueTasksColumn": "Tarefas Atrasadas", + "contributionColumn": "Contribuição", + "progressColumn": "Progresso", + "loggedTimeColumn": "Tempo Registrado", + "taskColumn": "Tarefa", + "projectColumn": "Projeto", + "statusColumn": "Status", + "priorityColumn": "Prioridade", + "phaseColumn": "Fase", + "dueDateColumn": "Data de Vencimento", + "completedDateColumn": "Data de Conclusão", + "estimatedTimeColumn": "Tempo Estimado", + "overloggedTimeColumn": "Tempo Excedido", + "completedOnColumn": "Concluído Em", + "daysOverdueColumn": "Dias Atrasados", + + "groupByText": "Agrupar Por:", + "statusText": "Status", + "priorityText": "Prioridade", + "phaseText": "Fase" +} diff --git a/worklenz-backend/src/public/locales/pt/reporting-projects-filters.json b/worklenz-backend/src/public/locales/pt/reporting-projects-filters.json new file mode 100644 index 00000000..5d47d282 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/reporting-projects-filters.json @@ -0,0 +1,35 @@ +{ + "searchByNamePlaceholder": "Pesquisar por nome", + "searchByCategoryPlaceholder": "Pesquisar por categoria", + + "statusText": "Status", + "healthText": "Saúde", + "categoryText": "Categoria", + "projectManagerText": "Gerente de Projeto", + "showFieldsText": "Mostrar campos", + + "cancelledText": "Cancelado", + "blockedText": "Bloqueado", + "onHoldText": "Em Espera", + "proposedText": "Proposto", + "inPlanningText": "Em Planejamento", + "inProgressText": "Em Andamento", + "completedText": "Concluído", + "continuousText": "Contínuo", + + "notSetText": "Não Definido", + "needsAttentionText": "Precisa de Atenção", + "atRiskText": "Em Risco", + "goodText": "Bom", + + "nameText": "Projeto", + "estimatedVsActualText": "Estimado Vs Real", + "tasksProgressText": "Progresso das Tarefas", + "lastActivityText": "Última Atividade", + "datesText": "Datas de Início/Fim", + "daysLeftText": "Dias Restantes/Atrasados", + "projectHealthText": "Saúde do Projeto", + "projectUpdateText": "Atualização do Projeto", + "clientText": "Cliente", + "teamText": "Equipe" +} diff --git a/worklenz-backend/src/public/locales/pt/reporting-projects.json b/worklenz-backend/src/public/locales/pt/reporting-projects.json new file mode 100644 index 00000000..c5035b54 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/reporting-projects.json @@ -0,0 +1,52 @@ +{ + "projectCount": "Projeto", + "projectCountPlural": "Projetos", + "includeArchivedButton": "Incluir Projetos Arquivados", + "exportButton": "Exportar", + "excelButton": "Excel", + + "projectColumn": "Projeto", + "estimatedVsActualColumn": "Estimado Vs Real", + "tasksProgressColumn": "Progresso das Tarefas", + "lastActivityColumn": "Última Atividade", + "statusColumn": "Status", + "datesColumn": "Datas de Início/Fim", + "daysLeftColumn": "Dias Restantes/Atrasados", + "projectHealthColumn": "Saúde do Projeto", + "categoryColumn": "Categoria", + "projectUpdateColumn": "Atualização do Projeto", + "clientColumn": "Cliente", + "teamColumn": "Equipe", + "projectManagerColumn": "Gerente de Projeto", + + "openButton": "Abrir", + + "estimatedText": "Estimado", + "actualText": "Real", + + "todoText": "A Fazer", + "doingText": "Fazendo", + "doneText": "Feito", + + "cancelledText": "Cancelado", + "blockedText": "Bloqueado", + "onHoldText": "Em Espera", + "proposedText": "Proposto", + "inPlanningText": "Em Planejamento", + "inProgressText": "Em Andamento", + "completedText": "Concluído", + "continuousText": "Contínuo", + + "daysLeftText": "dias restantes", + "dayLeftText": "dia restante", + "daysOverdueText": "dias atrasados", + + "notSetText": "Não Definido", + "needsAttentionText": "Precisa de Atenção", + "atRiskText": "Em Risco", + "goodText": "Bom", + + "setCategoryText": "Definir Categoria", + "searchByNameInputPlaceholder": "Pesquisar por nome", + "todayText": "Hoje" +} diff --git a/worklenz-backend/src/public/locales/pt/reporting-sidebar.json b/worklenz-backend/src/public/locales/pt/reporting-sidebar.json new file mode 100644 index 00000000..e09940f3 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/reporting-sidebar.json @@ -0,0 +1,8 @@ +{ + "overviewText": "Visão Geral", + "projectsText": "Projetos", + "membersText": "Membros", + "timeReportsText": "Relatórios de Tempo", + "estimateVsActualText": "Estimado Vs Real", + "currentOrganizationTooltip": "Organização Atual" +} diff --git a/worklenz-backend/src/public/locales/pt/schedule.json b/worklenz-backend/src/public/locales/pt/schedule.json new file mode 100644 index 00000000..c2d9fed6 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/schedule.json @@ -0,0 +1,39 @@ +{ + "today": "Hoje", + "week": "Semana", + "month": "Mês", + + "settings": "Configurações", + "workingDays": "Dias de Trabalho", + "monday": "Segunda-feira", + "tuesday": "Terça-feira", + "wednesday": "Quarta-feira", + "thursday": "Quinta-feira", + "friday": "Sexta-feira", + "saturday": "Sábado", + "sunday": "Domingo", + "workingHours": "Horas de Trabalho", + "hours": "horas", + "saveButton": "Salvar", + + "totalAllocation": "Alocação Total", + "timeLogged": "Tempo Registrado", + "remainingTime": "Tempo Restante", + "total": "Total", + "perDay": "Por Dia", + "tasks": "tarefas", + "startDate": "Data de Início", + "endDate": "Data de Fim", + + "hoursPerDay": "Horas Por Dia", + "totalHours": "Horas Totais", + "deleteButton": "Excluir", + "cancelButton": "Cancelar", + + "tabTitle": "Tarefa sem Data de Início & Fim", + + "allocatedTime": "Tempo Alocado", + "totalLogged": "Total Registrado", + "loggedBillable": "Registrado Faturável", + "loggedNonBillable": "Registrado Não Faturável" +} diff --git a/worklenz-backend/src/public/locales/pt/settings/appearance.json b/worklenz-backend/src/public/locales/pt/settings/appearance.json new file mode 100644 index 00000000..13e5a1e6 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/settings/appearance.json @@ -0,0 +1,5 @@ +{ + "title": "Aparência", + "darkMode": "Modo Escuro", + "darkModeDescription": "Alterne entre o modo claro e escuro para personalizar sua experiência de visualização." +} diff --git a/worklenz-backend/src/public/locales/pt/settings/categories.json b/worklenz-backend/src/public/locales/pt/settings/categories.json new file mode 100644 index 00000000..9972d2a9 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/settings/categories.json @@ -0,0 +1,10 @@ +{ + "categoryColumn": "Categoria", + "deleteConfirmationTitle": "Tem a certeza?", + "deleteConfirmationOk": "Sim", + "deleteConfirmationCancel": "Cancelar", + "associatedTaskColumn": "Tarefa Associada", + "searchPlaceholder": "Pesquisar por nome", + "emptyText": "As categorias podem ser criadas ao atualizar ou criar projetos.", + "colorChangeTooltip": "Clique para mudar a cor" +} diff --git a/worklenz-backend/src/public/locales/pt/settings/change-password.json b/worklenz-backend/src/public/locales/pt/settings/change-password.json new file mode 100644 index 00000000..07b993dd --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/settings/change-password.json @@ -0,0 +1,15 @@ +{ + "title": "Alterar Senha", + "currentPassword": "Senha Atual", + "newPassword": "Nova Senha", + "confirmPassword": "Confirmar Senha", + "currentPasswordPlaceholder": "Digite sua senha atual", + "newPasswordPlaceholder": "Nova Senha", + "confirmPasswordPlaceholder": "Confirmar Senha", + "currentPasswordRequired": "Por favor, digite sua senha atual!", + "newPasswordRequired": "Por favor, digite sua nova senha!", + "passwordValidationError": "A senha deve ter pelo menos 8 caracteres com uma letra maiúscula, um número e um símbolo.", + "passwordMismatch": "As senhas não coincidem!", + "passwordRequirements": "A nova senha deve ter no mínimo 8 caracteres, com uma letra maiúscula, um número e um símbolo.", + "updateButton": "Atualizar Senha" +} diff --git a/worklenz-backend/src/public/locales/pt/settings/clients.json b/worklenz-backend/src/public/locales/pt/settings/clients.json new file mode 100644 index 00000000..932a7f5e --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/settings/clients.json @@ -0,0 +1,22 @@ +{ + "nameColumn": "Nome", + "projectColumn": "Projeto", + "noProjectsAvailable": "Nenhum projeto disponível", + "deleteConfirmationTitle": "Tem a certeza?", + "deleteConfirmationOk": "Sim", + "deleteConfirmationCancel": "Cancelar", + "searchPlaceholder": "Pesquisar por nome", + "createClient": "Criar Cliente", + "pinTooltip": "Clique para fixar isso no menu principal", + "createClientDrawerTitle": "Criar Cliente", + "updateClientDrawerTitle": "Atualizar Cliente", + "nameLabel": "Nome", + "namePlaceholder": "Nome", + "nameRequiredError": "Por favor, insira um Nome", + "createButton": "Criar", + "updateButton": "Atualizar", + "createClientSuccessMessage": "Criar cliente sucesso!", + "createClientErrorMessage": "Criar cliente falhou!", + "updateClientSuccessMessage": "Atualizar cliente sucesso!", + "updateClientErrorMessage": "Atualizar cliente falhou!" +} diff --git a/worklenz-backend/src/public/locales/pt/settings/job-titles.json b/worklenz-backend/src/public/locales/pt/settings/job-titles.json new file mode 100644 index 00000000..379ddc03 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/settings/job-titles.json @@ -0,0 +1,20 @@ +{ + "nameColumn": "Nome", + "deleteConfirmationTitle": "Tem a certeza?", + "deleteConfirmationOk": "Sim", + "deleteConfirmationCancel": "Cancelar", + "searchPlaceholder": "Pesquisar por nome", + "createJobTitleButton": "Criar Título de Emprego", + "pinTooltip": "Clique para fixar isso no menu principal", + "createJobTitleDrawerTitle": "Criar Título de Emprego", + "updateJobTitleDrawerTitle": "Atualizar Título de Emprego", + "nameLabel": "Nome", + "namePlaceholder": "Nome", + "nameRequiredError": "Por favor, insira um Nome", + "createButton": "Criar", + "updateButton": "Atualizar", + "createJobTitleSuccessMessage": "Criar título de emprego com sucesso!", + "createJobTitleErrorMessage": "Falha ao criar título de emprego!", + "updateJobTitleSuccessMessage": "Atualizar título de emprego com sucesso!", + "updateJobTitleErrorMessage": "Falha ao atualizar título de emprego!" +} diff --git a/worklenz-backend/src/public/locales/pt/settings/labels.json b/worklenz-backend/src/public/locales/pt/settings/labels.json new file mode 100644 index 00000000..737dccef --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/settings/labels.json @@ -0,0 +1,11 @@ +{ + "labelColumn": "Rótulo", + "deleteConfirmationTitle": "Tem a certeza?", + "deleteConfirmationOk": "Sim", + "deleteConfirmationCancel": "Cancelar", + "associatedTaskColumn": "Contagem de Tarefas Associadas", + "searchPlaceholder": "Pesquisar por nome", + "emptyText": "Os rótulos podem ser criados ao atualizar ou criar tarefas.", + "pinTooltip": "Clique para fixar isso no menu principal", + "colorChangeTooltip": "Clique para mudar a cor" +} diff --git a/worklenz-backend/src/public/locales/pt/settings/language.json b/worklenz-backend/src/public/locales/pt/settings/language.json new file mode 100644 index 00000000..f4494ff3 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/settings/language.json @@ -0,0 +1,7 @@ +{ + "language": "Idioma", + "language_required": "O idioma é obrigatório", + "time_zone": "Fuso horário", + "time_zone_required": "O fuso horário é obrigatório", + "save_changes": "Salvar alterações" +} diff --git a/worklenz-backend/src/public/locales/pt/settings/notifications.json b/worklenz-backend/src/public/locales/pt/settings/notifications.json new file mode 100644 index 00000000..5a61cdf0 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/settings/notifications.json @@ -0,0 +1,10 @@ +{ + "emailTitle": "Envie-me notificações por email", + "emailDescription": "Isso inclui novas atribuições de tarefas", + "dailyDigestTitle": "Envie-me um resumo diário", + "dailyDigestDescription": "Toda noite, você receberá um resumo da atividade recente nas tarefas.", + "popupTitle": "Notificações pop-up no meu computador quando o Worklenz está aberto", + "popupDescription": "As notificações pop-up podem ser desativadas pelo seu navegador. Altere as configurações do seu navegador para permiti-las.", + "unreadItemsTitle": "Mostrar o número de itens não lidos", + "unreadItemsDescription": "Você verá contagens para cada notificação." +} diff --git a/worklenz-backend/src/public/locales/pt/settings/profile.json b/worklenz-backend/src/public/locales/pt/settings/profile.json new file mode 100644 index 00000000..3a4a8447 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/settings/profile.json @@ -0,0 +1,14 @@ +{ + "uploadError": "Você só pode fazer upload de arquivos JPG/PNG!", + "uploadSizeError": "A imagem deve ser menor que 2MB!", + "upload": "Carregar", + "nameLabel": "Nome", + "nameRequiredError": "Nome é obrigatório", + "emailLabel": "Email", + "emailRequiredError": "Email é obrigatório", + "saveChanges": "Salvar Alterações", + "profileJoinedText": "Entrou há um mês", + "profileLastUpdatedText": "Última atualização há um mês", + "avatarTooltip": "Clique para carregar um avatar", + "title": "Configurações do Perfil" +} diff --git a/worklenz-backend/src/public/locales/pt/settings/project-templates.json b/worklenz-backend/src/public/locales/pt/settings/project-templates.json new file mode 100644 index 00000000..55546630 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/settings/project-templates.json @@ -0,0 +1,8 @@ +{ + "nameColumn": "Nome", + "editToolTip": "Editar", + "deleteToolTip": "Excluir", + "confirmText": "Tem a certeza?", + "okText": "Sim", + "cancelText": "Cancelar" +} diff --git a/worklenz-backend/src/public/locales/pt/settings/sidebar.json b/worklenz-backend/src/public/locales/pt/settings/sidebar.json new file mode 100644 index 00000000..0cb663f1 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/settings/sidebar.json @@ -0,0 +1,15 @@ +{ + "profile": "Perfil", + "notifications": "Notificações", + "clients": "Clientes", + "job-titles": "Títulos de Emprego", + "labels": "Rótulos", + "categories": "Categorias", + "project-templates": "Modelos de Projeto", + "task-templates": "Modelos de Tarefa", + "team-members": "Membros da Equipe", + "teams": "Equipes", + "change-password": "Alterar Senha", + "language-and-region": "Idioma e Região", + "appearance": "Aparência" +} diff --git a/worklenz-backend/src/public/locales/pt/settings/task-templates.json b/worklenz-backend/src/public/locales/pt/settings/task-templates.json new file mode 100644 index 00000000..fb501000 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/settings/task-templates.json @@ -0,0 +1,9 @@ +{ + "nameColumn": "Nome", + "createdColumn": "Criado", + "editToolTip": "Editar", + "deleteToolTip": "Excluir", + "confirmText": "Tem a certeza?", + "okText": "Sim", + "cancelText": "Cancelar" +} diff --git a/worklenz-backend/src/public/locales/pt/settings/team-members.json b/worklenz-backend/src/public/locales/pt/settings/team-members.json new file mode 100644 index 00000000..9ace1764 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/settings/team-members.json @@ -0,0 +1,47 @@ +{ + "title": "Membros da Equipe", + "nameColumn": "Nome", + "projectsColumn": "Projetos", + "emailColumn": "Email", + "teamAccessColumn": "Acesso à Equipe", + "memberCount": "Membro", + "membersCountPlural": "Membros", + "searchPlaceholder": "Pesquisar membros pelo nome", + "pinTooltip": "Atualizar lista de membros", + "addMemberButton": "Adicionar Novo Membro", + "editTooltip": "Editar membro", + "deactivateTooltip": "Desativar membro", + "activateTooltip": "Ativar membro", + "deleteTooltip": "Deletar membro", + "confirmDeleteTitle": "Tem a certeza de que deseja deletar este membro?", + "confirmActivateTitle": "Tem a certeza de que deseja alterar o status deste membro?", + "okText": "Sim, proceder", + "cancelText": "Não, cancelar", + "deactivatedText": "(Atualmente desativado)", + "pendingInvitationText": "(Convite pendente)", + "addMemberDrawerTitle": "Adicionar Novo Membro da Equipe", + "updateMemberDrawerTitle": "Atualizar Membro da Equipe", + "addMemberEmailHint": "Os membros serão adicionados à equipe independentemente do status de aceitação do convite", + "memberEmailLabel": "Endereço(s) de Email", + "memberEmailPlaceholder": "Insira o endereço de email do membro da equipe", + "memberEmailRequiredError": "Por favor, insira um email válido", + "jobTitleLabel": "Título do Emprego", + "jobTitlePlaceholder": "Selecione ou pesquise o título do emprego (Opcional)", + "memberAccessLabel": "Nível de Acesso", + "addToTeamButton": "Adicionar Membro à Equipe", + "updateButton": "Salvar Alterações", + "resendInvitationButton": "Redirecionar Email de Convite", + "invitationSentSuccessMessage": "Convite para a equipe enviado com sucesso!", + "createMemberSuccessMessage": "Novo membro da equipe adicionado com sucesso!", + "createMemberErrorMessage": "Falha ao adicionar membro da equipe. Por favor, tente novamente.", + "updateMemberSuccessMessage": "Membro da equipe atualizado com sucesso!", + "updateMemberErrorMessage": "Falha ao atualizar membro da equipe. Por favor, tente novamente.", + "memberText": "Membro da Equipe", + "adminText": "Administrador", + "ownerText": "Dono da Equipe", + "addedText": "Adicionado", + "updatedText": "Atualizado", + "noResultFound": "Digite um endereço de email e pressione enter...", + "jobTitlesFetchError": "Falha ao buscar cargos", + "invitationResent": "Convite reenviado com sucesso!" +} diff --git a/worklenz-backend/src/public/locales/pt/settings/teams.json b/worklenz-backend/src/public/locales/pt/settings/teams.json new file mode 100644 index 00000000..e460318f --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/settings/teams.json @@ -0,0 +1,16 @@ +{ + "title": "Equipes", + "team": "Equipe", + "teams": "Equipes", + "name": "Nome", + "created": "Criado", + "ownsBy": "Pertence a", + "edit": "Editar", + "editTeam": "Editar Equipe", + "pinTooltip": "Clique para fixar isso no menu principal", + "editTeamName": "Editar Nome da Equipe", + "updateName": "Atualizar Nome", + "namePlaceholder": "Nome", + "nameRequired": "Por favor digite um Nome", + "updateFailed": "Falha na alteração do nome da equipe!" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/pt/task-drawer/task-drawer-info-tab.json b/worklenz-backend/src/public/locales/pt/task-drawer/task-drawer-info-tab.json new file mode 100644 index 00000000..cf26b1a3 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/task-drawer/task-drawer-info-tab.json @@ -0,0 +1,30 @@ +{ + "details": { + "task-key": "Chave da tarefa", + "phase": "Fase", + "assignees": "Responsáveis", + "due-date": "Data de vencimento", + "time-estimation": "Estimativa de tempo", + "priority": "Prioridade", + "labels": "Etiquetas", + "billable": "Faturável", + "notify": "Notificar", + "when-done-notify": "Quando concluída, notificar", + "start-date": "Data de início", + "end-date": "Data de término", + "hide-start-date": "Ocultar data de início", + "show-start-date": "Mostrar data de início", + "hours": "Horas", + "minutes": "Minutos", + "recurring": "Recorrente" + }, + "description": { + "title": "Descrição", + "placeholder": "Adicionar uma descrição mais detalhada..." + }, + "subTasks": { + "title": "Subtarefas", + "add-sub-task": "+ Adicionar subtarefa", + "refresh-sub-tasks": "Atualizar subtarefas" + } +} diff --git a/worklenz-backend/src/public/locales/pt/task-drawer/task-drawer-recurring-config.json b/worklenz-backend/src/public/locales/pt/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..5592d897 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,34 @@ +{ + "recurring": "Recorrente", + "recurringTaskConfiguration": "Configuração de tarefa recorrente", + "repeats": "Repete", + "daily": "Diário", + "weekly": "Semanal", + "everyXDays": "A cada X dias", + "everyXWeeks": "A cada X semanas", + "everyXMonths": "A cada X meses", + "monthly": "Mensal", + "selectDaysOfWeek": "Selecionar dias da semana", + "mon": "Seg", + "tue": "Ter", + "wed": "Qua", + "thu": "Qui", + "fri": "Sex", + "sat": "Sáb", + "sun": "Dom", + "monthlyRepeatType": "Tipo de repetição mensal", + "onSpecificDate": "Em uma data específica", + "onSpecificDay": "Em um dia específico", + "dateOfMonth": "Data do mês", + "weekOfMonth": "Semana do mês", + "dayOfWeek": "Dia da semana", + "first": "Primeira", + "second": "Segunda", + "third": "Terceira", + "fourth": "Quarta", + "last": "Última", + "intervalDays": "Intervalo (dias)", + "intervalWeeks": "Intervalo (semanas)", + "intervalMonths": "Intervalo (meses)", + "saveChanges": "Salvar alterações" +} diff --git a/worklenz-backend/src/public/locales/pt/task-drawer/task-drawer.json b/worklenz-backend/src/public/locales/pt/task-drawer/task-drawer.json new file mode 100644 index 00000000..c24e943e --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/task-drawer/task-drawer.json @@ -0,0 +1,123 @@ +{ + "taskHeader": { + "taskNamePlaceholder": "Digite sua Tarefa", + "deleteTask": "Deletar Tarefa" + }, + "taskInfoTab": { + "title": "Informações", + "details": { + "title": "Detalhes", + "task-key": "Chave da Tarefa", + "phase": "Fase", + "assignees": "Responsáveis", + "due-date": "Data de Vencimento", + "time-estimation": "Estimativa de Tempo", + "priority": "Prioridade", + "labels": "Etiquetas", + "billable": "Faturável", + "notify": "Notificar", + "when-done-notify": "Quando concluído, notificar", + "start-date": "Data de Início", + "end-date": "Data de Fim", + "hide-start-date": "Ocultar Data de Início", + "show-start-date": "Mostrar Data de Início", + "hours": "Horas", + "minutes": "Minutos", + "progressValue": "Valor do Progresso", + "progressValueTooltip": "Definir a porcentagem de progresso (0-100%)", + "progressValueRequired": "Por favor, insira um valor de progresso", + "progressValueRange": "O progresso deve estar entre 0 e 100", + "taskWeight": "Peso da Tarefa", + "taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)", + "taskWeightRequired": "Por favor, insira um peso da tarefa", + "taskWeightRange": "O peso deve estar entre 0 e 100", + "recurring": "Recorrente" + }, + "labels": { + "labelInputPlaceholder": "Pesquisar ou criar", + "labelsSelectorInputTip": "Pressione Enter para criar" + }, + "description": { + "title": "Descrição", + "placeholder": "Adicionar uma descrição mais detalhada..." + }, + "subTasks": { + "title": "Sub Tarefas", + "addSubTask": "Adicionar Sub Tarefa", + "addSubTaskInputPlaceholder": "Digite sua tarefa e pressione enter", + "refreshSubTasks": "Atualizar Sub Tarefas", + "edit": "Editar", + "delete": "Deletar", + "confirmDeleteSubTask": "Tem certeza de que deseja deletar esta subtarefa?", + "deleteSubTask": "Deletar Sub Tarefa" + }, + "dependencies": { + "title": "Dependências", + "addDependency": "+ Adicionar nova dependência", + "blockedBy": "Bloqueado por", + "searchTask": "Digite para pesquisar tarefa", + "noTasksFound": "Nenhuma tarefa encontrada", + "confirmDeleteDependency": "Tem certeza de que deseja deletar?" + }, + "attachments": { + "title": "Anexos", + "chooseOrDropFileToUpload": "Escolha ou arraste um arquivo para upload", + "uploading": "Enviando..." + }, + "comments": { + "title": "Comentários", + "addComment": "+ Adicionar novo comentário", + "noComments": "Ainda não há comentários. Seja o primeiro a comentar!", + "delete": "Deletar", + "confirmDeleteComment": "Tem certeza de que deseja deletar este comentário?", + "addCommentPlaceholder": "Adicionar um comentário...", + "cancel": "Cancelar", + "commentButton": "Comentar", + "attachFiles": "Anexar arquivos", + "addMoreFiles": "Adicionar mais arquivos", + "selectedFiles": "Arquivos Selecionados (Até 25MB, Máximo {count})", + "maxFilesError": "Você pode fazer upload de no máximo {count} arquivos", + "processFilesError": "Falha ao processar arquivos", + "addCommentError": "Por favor adicione um comentário ou anexe arquivos", + "createdBy": "Criado {{time}} por {{user}}", + "updatedTime": "Atualizado {{time}}" + }, + "searchInputPlaceholder": "Pesquisar por nome", + "pendingInvitation": "Convite Pendente" + }, + "taskTimeLogTab": { + "title": "Registro de Tempo", + "addTimeLog": "Adicionar novo registro de tempo", + "totalLogged": "Total Registrado", + "exportToExcel": "Exportar para Excel", + "noTimeLogsFound": "Nenhum registro de tempo encontrado", + "timeLogForm": { + "date": "Data", + "startTime": "Hora de Início", + "endTime": "Hora de Fim", + "workDescription": "Descrição do Trabalho", + "descriptionPlaceholder": "Adicionar uma descrição", + "logTime": "Registrar tempo", + "updateTime": "Atualizar tempo", + "cancel": "Cancelar", + "selectDateError": "Por favor selecione uma data", + "selectStartTimeError": "Por favor selecione a hora de início", + "selectEndTimeError": "Por favor selecione a hora de fim", + "endTimeAfterStartError": "A hora de fim deve ser posterior à hora de início" + } + }, + "taskActivityLogTab": { + "title": "Registro de Atividade", + "add": "ADICIONAR", + "remove": "REMOVER", + "none": "Nenhum", + "weight": "Peso", + "createdTask": "criou a tarefa." + }, + "taskProgress": { + "markAsDoneTitle": "Marcar Tarefa como Concluída?", + "confirmMarkAsDone": "Sim, marcar como concluída", + "cancelMarkAsDone": "Não, manter status atual", + "markAsDoneDescription": "Você definiu o progresso para 100%. Gostaria de atualizar o status da tarefa para \"Concluída\"?" + } +} diff --git a/worklenz-backend/src/public/locales/pt/task-list-filters.json b/worklenz-backend/src/public/locales/pt/task-list-filters.json new file mode 100644 index 00000000..21e8806b --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/task-list-filters.json @@ -0,0 +1,82 @@ +{ + "searchButton": "Pesquisar", + "resetButton": "Redefinir", + "searchInputPlaceholder": "Pesquisar por nome", + + "sortText": "Ordenar", + "statusText": "Status", + "phaseText": "Fase", + "priorityText": "Prioridade", + "labelsText": "Rótulos", + "membersText": "Membros", + "groupByText": "Agrupar por", + "showArchivedText": "Mostrar arquivados", + "showFieldsText": "Mostrar campos", + "keyText": "Chave", + "taskText": "Tarefa", + "descriptionText": "Descrição", + "phasesText": "Fases", + "progressText": "Progresso", + "timeTrackingText": "Rastreamento de Tempo", + "estimationText": "Estimativa", + "startDateText": "Data de Início", + "endDateText": "Data de Fim", + "dueDateText": "Data de Vencimento", + "completedDateText": "Data de Conclusão", + "createdDateText": "Data de Criação", + "lastUpdatedText": "Última Atualização", + "reporterText": "Relator", + "dueTimeText": "Hora de Vencimento", + "assigneesText": "Atribuições", + "timetrackingText": "Rastreamento de Tempo", + "startdateText": "Data de Início", + "duedateText": "Data de Vencimento", + "completeddateText": "Data de Conclusão", + "createddateText": "Data de Criação", + "lastupdatedText": "Última Atualização", + + "lowText": "Baixa", + "mediumText": "Média", + "highText": "Alta", + + "createStatusButtonTooltip": "Configurações de Status", + "configPhaseButtonTooltip": "Configurações de Fase", + "noLabelsFound": "Nenhum rótulo encontrado", + + "addStatusButton": "Adicionar Status", + "addPhaseButton": "Adicionar Fase", + + "createStatus": "Criar Status", + "name": "Nome", + "category": "Categoria", + "selectCategory": "Selecionar uma categoria", + "pleaseEnterAName": "Por favor, insira um nome", + "pleaseSelectACategory": "Por favor, selecione uma categoria", + "create": "Criar", + + "searchTasks": "Pesquisar tarefas...", + "searchPlaceholder": "Pesquisar...", + "fieldsText": "Campos", + "loadingFilters": "Carregando filtros...", + "noOptionsFound": "Nenhuma opção encontrada", + "filtersActive": "filtros ativos", + "filterActive": "filtro ativo", + "clearAll": "Limpar tudo", + "clearing": "Limpando...", + "cancel": "Cancelar", + "search": "Pesquisar", + "groupedBy": "Agrupado por", + "manageStatuses": "Gerenciar Status", + "managePhases": "Gerenciar Fases", + "dragToReorderStatuses": "Arraste os status para reordená-los. Cada status pode ter uma categoria diferente.", + "enterNewStatusName": "Digite o novo nome do status...", + "addStatus": "Adicionar Status", + "noStatusesFound": "Nenhum status encontrado. Crie seu primeiro status acima.", + "deleteStatus": "Excluir Status", + "deleteStatusConfirm": "Tem certeza de que deseja excluir este status? Esta ação não pode ser desfeita.", + "rename": "Renomear", + "delete": "Excluir", + "enterStatusName": "Digite o nome do status", + "selectCategory": "Selecionar categoria", + "close": "Fechar" +} diff --git a/worklenz-backend/src/public/locales/pt/task-list-table.json b/worklenz-backend/src/public/locales/pt/task-list-table.json new file mode 100644 index 00000000..f53d834f --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/task-list-table.json @@ -0,0 +1,136 @@ +{ + "keyColumn": "Chave", + "taskColumn": "Tarefa", + "descriptionColumn": "Descrição", + "progressColumn": "Progresso", + "membersColumn": "Membros", + "assigneesColumn": "Atribuídos", + "labelsColumn": "Etiquetas", + "phasesColumn": "Fases", + "phaseColumn": "Fase", + "statusColumn": "Status", + "priorityColumn": "Prioridade", + "timeTrackingColumn": "Acompanhamento de Tempo", + "timetrackingColumn": "Acompanhamento de Tempo", + "estimationColumn": "Estimativa", + "startDateColumn": "Data de Início", + "startdateColumn": "Data de Início", + "dueDateColumn": "Data de Vencimento", + "duedateColumn": "Data de Vencimento", + "completedDateColumn": "Data de Conclusão", + "completeddateColumn": "Data de Conclusão", + "createdDateColumn": "Data de Criação", + "createddateColumn": "Data de Criação", + "lastUpdatedColumn": "Última Atualização", + "lastupdatedColumn": "Última Atualização", + "reporterColumn": "Reportador", + "dueTimeColumn": "Hora de Vencimento", + "todoSelectorText": "A Fazer", + "doingSelectorText": "Fazendo", + "doneSelectorText": "Feito", + + "lowSelectorText": "Baixo", + "mediumSelectorText": "Médio", + "highSelectorText": "Alto", + + "selectText": "Selecionar", + "labelsSelectorInputTip": "Pressione enter para criar!", + + "addTaskText": "Adicionar Tarefa", + "addSubTaskText": "+ Adicionar Subtarefa", + "noTasksInGroup": "Nenhuma tarefa neste grupo", + "addTaskInputPlaceholder": "Digite sua tarefa e pressione enter", + + "openButton": "Abrir", + "okButton": "Ok", + + "noLabelsFound": "Nenhuma etiqueta encontrada", + "searchInputPlaceholder": "Buscar ou criar", + "assigneeSelectorInviteButton": "Convide um novo membro por e-mail", + "labelInputPlaceholder": "Buscar ou criar", + "searchLabelsPlaceholder": "Buscar etiquetas...", + "createLabelButton": "Criar \"{{name}}\"", + "manageLabelsPath": "Configurações → Etiquetas", + + "pendingInvitation": "Convite Pendente", + + "contextMenu": { + "assignToMe": "Atribuir a mim", + "moveTo": "Mover para", + "unarchive": "Desarquivar", + "archive": "Arquivar", + "convertToSubTask": "Converter em Subtarefa", + "convertToTask": "Converter em Tarefa", + "delete": "Excluir", + "searchByNameInputPlaceholder": "Buscar por nome" + }, + "setDueDate": "Definir data de vencimento", + "setStartDate": "Definir data de início", + "clearDueDate": "Limpar data de vencimento", + "clearStartDate": "Limpar data de início", + "dueDatePlaceholder": "Data de vencimento", + "startDatePlaceholder": "Data de início", + + "emptyStates": { + "noTaskGroups": "Nenhum grupo de tarefas encontrado", + "noTaskGroupsDescription": "As tarefas aparecerão aqui quando forem criadas ou quando filtros forem aplicados.", + "errorPrefix": "Erro:", + "dragTaskFallback": "Tarefa" + }, + + "customColumns": { + "addCustomColumn": "Adicionar uma coluna personalizada", + "customColumnHeader": "Coluna Personalizada", + "customColumnSettings": "Configurações da coluna personalizada", + "noCustomValue": "Sem valor", + "peopleField": "Campo de pessoas", + "noDate": "Sem data", + "unsupportedField": "Tipo de campo não suportado", + + "modal": { + "addFieldTitle": "Adicionar campo", + "editFieldTitle": "Editar campo", + "fieldTitle": "Título do campo", + "fieldTitleRequired": "O título do campo é obrigatório", + "columnTitlePlaceholder": "Título da coluna", + "type": "Tipo", + "deleteConfirmTitle": "Tem certeza de que deseja excluir esta coluna personalizada?", + "deleteConfirmDescription": "Esta ação não pode ser desfeita. Todos os dados associados a esta coluna serão excluídos permanentemente.", + "deleteButton": "Excluir", + "cancelButton": "Cancelar", + "createButton": "Criar", + "updateButton": "Atualizar", + "createSuccessMessage": "Coluna personalizada criada com sucesso", + "updateSuccessMessage": "Coluna personalizada atualizada com sucesso", + "deleteSuccessMessage": "Coluna personalizada excluída com sucesso", + "deleteErrorMessage": "Falha ao excluir a coluna personalizada", + "createErrorMessage": "Falha ao criar a coluna personalizada", + "updateErrorMessage": "Falha ao atualizar a coluna personalizada" + }, + + "fieldTypes": { + "people": "Pessoas", + "number": "Número", + "date": "Data", + "selection": "Seleção", + "checkbox": "Caixa de seleção", + "labels": "Etiquetas", + "key": "Chave", + "formula": "Fórmula" + } + }, + + "indicators": { + "tooltips": { + "subtasks": "{{count}} subtarefa", + "subtasks_plural": "{{count}} subtarefas", + "comments": "{{count}} comentário", + "comments_plural": "{{count}} comentários", + "attachments": "{{count}} anexo", + "attachments_plural": "{{count}} anexos", + "subscribers": "A tarefa tem assinantes", + "dependencies": "A tarefa tem dependências", + "recurring": "Tarefa recorrente" + } + } +} diff --git a/worklenz-backend/src/public/locales/pt/task-management.json b/worklenz-backend/src/public/locales/pt/task-management.json new file mode 100644 index 00000000..946b3162 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/task-management.json @@ -0,0 +1,21 @@ +{ + "noTasksInGroup": "Nenhuma tarefa neste grupo", + "noTasksInGroupDescription": "Adicione uma tarefa para começar", + "addFirstTask": "Adicione sua primeira tarefa", + "openTask": "Abrir", + "subtask": "subtarefa", + "subtasks": "subtarefas", + "comment": "comentário", + "comments": "comentários", + "attachment": "anexo", + "attachments": "anexos", + "enterSubtaskName": "Digite o nome da subtarefa...", + "add": "Adicionar", + "cancel": "Cancelar", + "renameGroup": "Renomear Grupo", + "renameStatus": "Renomear Status", + "renamePhase": "Renomear Fase", + "changeCategory": "Alterar Categoria", + "clickToEditGroupName": "Clique para editar o nome do grupo", + "enterGroupName": "Digite o nome do grupo" +} diff --git a/worklenz-backend/src/public/locales/pt/task-template-drawer.json b/worklenz-backend/src/public/locales/pt/task-template-drawer.json new file mode 100644 index 00000000..f1358349 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/task-template-drawer.json @@ -0,0 +1,11 @@ +{ + "createTaskTemplate": "Criar Template de Tarefa", + "editTaskTemplate": "Editar Template de Tarefa", + "cancelText": "Cancelar", + "saveText": "Salvar", + "templateNameText": "Nome do Template", + "selectedTasks": "Tarefas Selecionadas", + "removeTask": "Remover", + "cancelButton": "Cancelar", + "saveButton": "Salvar" +} diff --git a/worklenz-backend/src/public/locales/pt/tasks/task-table-bulk-actions.json b/worklenz-backend/src/public/locales/pt/tasks/task-table-bulk-actions.json new file mode 100644 index 00000000..f4a3a10e --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/tasks/task-table-bulk-actions.json @@ -0,0 +1,41 @@ +{ + "taskSelected": "Tarefa selecionada", + "tasksSelected": "Tarefas selecionadas", + "changeStatus": "Alterar Status/ Prioridade/ Fases", + "changeLabel": "Alterar Etiqueta", + "assignToMe": "Atribuir a mim", + "changeAssignees": "Alterar Assignados", + "archive": "Arquivar", + "unarchive": "Desarquivar", + "delete": "Deletar", + "moreOptions": "Mais opções", + "deselectAll": "Desmarcar todas", + "status": "Status", + "priority": "Prioridade", + "phase": "Fase", + "member": "Membro", + "createTaskTemplate": "Criar Modelo de Tarefa", + "apply": "Aplicar", + "createLabel": "+ Criar etiqueta", + "searchOrCreateLabel": "Pesquisar ou criar etiqueta...", + "hitEnterToCreate": "Pressione Enter para criar", + "labelExists": "A etiqueta já existe", + "pendingInvitation": "Convite Pendente", + "noMatchingLabels": "Nenhuma etiqueta correspondente", + "noLabels": "Sem etiquetas", + "CHANGE_STATUS": "Alterar Status", + "CHANGE_PRIORITY": "Alterar Prioridade", + "CHANGE_PHASE": "Alterar Fase", + "ADD_LABELS": "Adicionar Etiquetas", + "ASSIGN_TO_ME": "Atribuir a Mim", + "ASSIGN_MEMBERS": "Atribuir Membros", + "ARCHIVE": "Arquivar", + "DELETE": "Deletar", + "CANCEL": "Cancelar", + "CLEAR_SELECTION": "Limpar Seleção", + "TASKS_SELECTED": "{{count}} tarefa selecionada", + "TASKS_SELECTED_plural": "{{count}} tarefas selecionadas", + "DELETE_TASKS_CONFIRM": "Deletar {{count}} tarefa?", + "DELETE_TASKS_CONFIRM_plural": "Deletar {{count}} tarefas?", + "DELETE_TASKS_WARNING": "Esta ação não pode ser desfeita." +} diff --git a/worklenz-backend/src/public/locales/pt/template-drawer.json b/worklenz-backend/src/public/locales/pt/template-drawer.json new file mode 100644 index 00000000..cb79d2bf --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/template-drawer.json @@ -0,0 +1,19 @@ +{ + "title": "Editar Template de Tarefa", + "cancelText": "Cancelar", + "saveText": "Salvar", + "templateNameText": "Nome do Template", + "selectedTasks": "Tarefas Selecionadas", + "removeTask": "Remover", + "description": "Descrição", + "phase": "Fase", + "statuses": "Status", + "priorities": "Prioridades", + "labels": "Rótulos", + "tasks": "Tarefas", + "noTemplateSelected": "Nenhum template selecionado", + "noDescription": "Sem descrição", + "worklenzTemplates": "Templates de Worklenz", + "yourTemplatesLibrary": "Sua Biblioteca", + "searchTemplates": "Pesquisar Templates" +} diff --git a/worklenz-backend/src/public/locales/pt/templateDrawer.json b/worklenz-backend/src/public/locales/pt/templateDrawer.json new file mode 100644 index 00000000..c4d970c6 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/templateDrawer.json @@ -0,0 +1,23 @@ +{ + "bugTracking": "Rastreamento de Bugs", + "construction": "Construção", + "designCreative": "Design e Criatividade", + "education": "Educação", + "finance": "Finanças", + "hrRecruiting": "RH e Recrutamento", + "informationTechnology": "Tecnologia da Informação", + "legal": "Jurídico", + "manufacturing": "Manufatura", + "marketing": "Marketing", + "nonprofit": "Sem Fins Lucrativos", + "personalUse": "Uso Pessoal", + "salesCRM": "Vendas e CRM", + "serviceConsulting": "Serviço e Consultoria", + "softwareDevelopment": "Desenvolvimento de Software", + "description": "Descrição", + "phase": "Fase", + "statuses": "Status", + "priorities": "Prioridades", + "labels": "Rótulos", + "tasks": "Tarefas" +} diff --git a/worklenz-backend/src/public/locales/pt/time-report.json b/worklenz-backend/src/public/locales/pt/time-report.json new file mode 100644 index 00000000..b40546e9 --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/time-report.json @@ -0,0 +1,57 @@ +{ + "includeArchivedProjects": "Incluir Projetos Arquivados", + "export": "Exportar", + "timeSheet": "Folha de Tempo", + + "searchByName": "Pesquisar por nome", + "selectAll": "Selecionar Tudo", + "teams": "Equipes", + + "searchByProject": "Pesquisar por nome do projeto", + "projects": "Projetos", + + "searchByCategory": "Pesquisar por nome da categoria", + "categories": "Categorias", + + "billable": "Faturável", + "nonBillable": "Não Faturável", + + "total": "Total", + + "projectsTimeSheet": "Folha de Tempo de Projetos", + + "loggedTime": "Tempo Registrado(horas)", + + "exportToExcel": "Exportar para Excel", + "logged": "registrado", + "for": "para", + + "membersTimeSheet": "Folha de Tempo de Membros", + "member": "Membro", + + "estimatedVsActual": "Estimado vs Real", + "workingDays": "Dias Úteis", + "manDays": "Dias Homem", + "days": "Dias", + "estimatedDays": "Dias Estimados", + "actualDays": "Dias Reais", + + "noCategories": "Nenhuma categoria encontrada", + "noCategory": "Sem Categoria", + "noProjects": "Nenhum projeto encontrado", + "noTeams": "Nenhuma equipe encontrada", + "noData": "Nenhum dado encontrado", + + "groupBy": "Agrupar por", + "groupByCategory": "Categoria", + "groupByTeam": "Equipe", + "groupByStatus": "Status", + "groupByNone": "Nenhum", + "clearSearch": "Limpar pesquisa", + "selectedProjects": "Projetos Selecionados", + "projectsSelected": "projetos selecionados", + "showSelected": "Mostrar Apenas Selecionados", + "expandAll": "Expandir Tudo", + "collapseAll": "Recolher Tudo", + "ungrouped": "Não Agrupado" +} diff --git a/worklenz-backend/src/public/locales/pt/unauthorized.json b/worklenz-backend/src/public/locales/pt/unauthorized.json new file mode 100644 index 00000000..e67e0ffd --- /dev/null +++ b/worklenz-backend/src/public/locales/pt/unauthorized.json @@ -0,0 +1,5 @@ +{ + "title": "¡Não autorizado!", + "subtitle": "Você não tem permissão para acessar esta página", + "button": "Ir para Início" +} diff --git a/worklenz-backend/src/public/locales/zh/404-page.json b/worklenz-backend/src/public/locales/zh/404-page.json new file mode 100644 index 00000000..24a74b3e --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/404-page.json @@ -0,0 +1,4 @@ +{ + "doesNotExistText": "抱歉,您访问的页面不存在。", + "backHomeButton": "返回首页" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/account-setup.json b/worklenz-backend/src/public/locales/zh/account-setup.json new file mode 100644 index 00000000..51cac1eb --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/account-setup.json @@ -0,0 +1,27 @@ +{ + "continue": "继续", + "setupYourAccount": "设置您的Worklenz账户。", + "organizationStepTitle": "命名您的组织", + "organizationStepLabel": "为您的Worklenz账户选择一个名称。", + "projectStepTitle": "创建您的第一个项目", + "projectStepLabel": "您现在正在做什么项目?", + "projectStepPlaceholder": "例如:营销计划", + "tasksStepTitle": "创建您的第一个任务", + "tasksStepLabel": "输入您将在其中完成的几个任务", + "tasksStepAddAnother": "添加另一个", + "emailPlaceholder": "电子邮件地址", + "invalidEmail": "请输入有效的电子邮件地址", + "or": "或", + "templateButton": "从模板导入", + "goBack": "返回", + "cancel": "取消", + "create": "创建", + "templateDrawerTitle": "从模板中选择", + "step3InputLabel": "通过电子邮件邀请", + "addAnother": "添加另一个", + "skipForNow": "暂时跳过", + "formTitle": "创建您的第一个任务。", + "step3Title": "邀请您的团队一起工作", + "maxMembers": "(您最多可以邀请5名成员)", + "maxTasks": "(您最多可以创建5个任务)" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/admin-center/current-bill.json b/worklenz-backend/src/public/locales/zh/admin-center/current-bill.json new file mode 100644 index 00000000..e18e8761 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/admin-center/current-bill.json @@ -0,0 +1,96 @@ +{ + "title": "账单", + "currentBill": "当前账单", + "configuration": "配置", + "currentPlanDetails": "当前计划详情", + "upgradePlan": "升级计划", + "cardBodyText01": "免费试用", + "cardBodyText02": "(您的试用计划将在1个月19天后到期)", + "redeemCode": "兑换码", + "accountStorage": "账户存储", + "used": "已用:", + "remaining": "剩余:", + "charges": "费用", + "tooltip": "当前账单周期的费用", + "description": "描述", + "billingPeriod": "账单周期", + "billStatus": "账单状态", + "perUserValue": "每用户费用", + "users": "用户", + "amount": "金额", + "invoices": "发票", + "transactionId": "交易ID", + "transactionDate": "交易日期", + "paymentMethod": "支付方式", + "status": "状态", + "ltdUsers": "您最多可以添加{{ltd_users}}名用户。", + "totalSeats": "总席位", + "availableSeats": "可用席位", + "addMoreSeats": "添加更多席位", + "drawerTitle": "兑换码", + "label": "兑换码", + "drawerPlaceholder": "输入您的兑换码", + "redeemSubmit": "提交", + "modalTitle": "为您的团队选择最佳计划", + "seatLabel": "席位数量", + "freePlan": "免费计划", + "startup": "初创", + "business": "商业", + "tag": "最受欢迎", + "enterprise": "企业", + "freeSubtitle": "永远免费", + "freeUsers": "最适合个人使用", + "freeText01": "100MB存储", + "freeText02": "3个项目", + "freeText03": "5名团队成员", + "startupSubtitle": "固定费率/月", + "startupUsers": "最多15名用户", + "startupText01": "25GB存储", + "startupText02": "无限活跃项目", + "startupText03": "日程", + "startupText04": "报告", + "startupText05": "订阅项目", + "businessSubtitle": "每用户/月", + "businessUsers": "16 - 200名用户", + "enterpriseUsers": "200 - 500+名用户", + "footerTitle": "请提供一个我们可以联系您的电话号码。", + "footerLabel": "联系电话", + "footerButton": "联系我们", + "redeemCodePlaceHolder": "输入您的兑换码", + "submit": "提交", + "trialPlan": "免费试用", + "trialExpireDate": "有效期至{{trial_expire_date}}", + "trialExpired": "您的免费试用已于{{trial_expire_string}}到期", + "trialInProgress": "您的免费试用将在{{trial_expire_string}}到期", + "required": "此字段为必填项", + "invalidCode": "无效的代码", + "selectPlan": "为您的团队选择最佳计划", + "changeSubscriptionPlan": "更改您的订阅计划", + "noOfSeats": "席位数量", + "annualPlan": "专业 - 年度", + "monthlyPlan": "专业 - 月度", + "freeForever": "永远免费", + "bestForPersonalUse": "最适合个人使用", + "storage": "存储", + "projects": "项目", + "teamMembers": "团队成员", + "unlimitedTeamMembers": "无限团队成员", + "unlimitedActiveProjects": "无限活跃项目", + "schedule": "日程", + "reporting": "报告", + "subscribeToProjects": "订阅项目", + "billedAnnually": "按年计费", + "billedMonthly": "按月计费", + "pausePlan": "暂停计划", + "resumePlan": "恢复计划", + "changePlan": "更改计划", + "cancelPlan": "取消计划", + "perMonthPerUser": "每用户/月", + "viewInvoice": "查看发票", + "switchToFreePlan": "切换到免费计划", + "expirestoday": "今天", + "expirestomorrow": "明天", + "expiredDaysAgo": "{{days}}天前", + "continueWith": "继续使用{{plan}}", + "changeToPlan": "更改为{{plan}}" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/admin-center/overview.json b/worklenz-backend/src/public/locales/zh/admin-center/overview.json new file mode 100644 index 00000000..9c70093f --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/admin-center/overview.json @@ -0,0 +1,8 @@ +{ + "overview": "概览", + "name": "组织名称", + "owner": "组织所有者", + "admins": "组织管理员", + "contactNumber": "添加联系电话", + "edit": "编辑" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/admin-center/projects.json b/worklenz-backend/src/public/locales/zh/admin-center/projects.json new file mode 100644 index 00000000..ca2eded2 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/admin-center/projects.json @@ -0,0 +1,12 @@ +{ + "membersCount": "成员数量", + "createdAt": "创建于", + "projectName": "项目名称", + "teamName": "团队名称", + "refreshProjects": "刷新项目", + "searchPlaceholder": "按项目名称搜索", + "deleteProject": "您确定要删除此项目吗?", + "confirm": "确认", + "cancel": "取消", + "delete": "删除项目" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/admin-center/sidebar.json b/worklenz-backend/src/public/locales/zh/admin-center/sidebar.json new file mode 100644 index 00000000..ab8808c3 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/admin-center/sidebar.json @@ -0,0 +1,8 @@ +{ + "overview": "概览", + "users": "用户", + "teams": "团队", + "billing": "账单", + "projects": "项目", + "adminCenter": "管理中心" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/admin-center/teams.json b/worklenz-backend/src/public/locales/zh/admin-center/teams.json new file mode 100644 index 00000000..4244d848 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/admin-center/teams.json @@ -0,0 +1,33 @@ +{ + "title": "团队", + "subtitle": "团队", + "tooltip": "刷新团队", + "placeholder": "按名称搜索", + "addTeam": "添加团队", + "team": "团队", + "membersCount": "成员数量", + "members": "成员", + "drawerTitle": "创建新团队", + "label": "团队名称", + "drawerPlaceholder": "名称", + "create": "创建", + "delete": "删除", + "settings": "设置", + "popTitle": "您确定吗?", + "message": "请输入名称", + "teamSettings": "团队设置", + "teamName": "团队名称", + "teamDescription": "团队描述", + "teamMembers": "团队成员", + "teamMembersCount": "团队成员数量", + "teamMembersPlaceholder": "按名称搜索", + "addMember": "添加成员", + "add": "添加", + "update": "更新", + "teamNamePlaceholder": "团队名称", + "user": "用户", + "role": "角色", + "owner": "所有者", + "admin": "管理员", + "member": "成员" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/admin-center/users.json b/worklenz-backend/src/public/locales/zh/admin-center/users.json new file mode 100644 index 00000000..83800c09 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/admin-center/users.json @@ -0,0 +1,9 @@ +{ + "title": "用户", + "subTitle": "用户", + "placeholder": "按名称搜索", + "user": "用户", + "email": "电子邮件", + "lastActivity": "最后活动", + "refresh": "刷新用户" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/all-project-list.json b/worklenz-backend/src/public/locales/zh/all-project-list.json new file mode 100644 index 00000000..a6c72c06 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/all-project-list.json @@ -0,0 +1,34 @@ +{ + "name": "名称", + "client": "客户", + "category": "类别", + "status": "状态", + "tasksProgress": "任务进度", + "updated_at": "最后更新", + "members": "成员", + "setting": "设置", + "projects": "项目", + "refreshProjects": "刷新项目", + "all": "全部", + "favorites": "收藏", + "archived": "已归档", + "placeholder": "按名称搜索", + "archive": "归档", + "unarchive": "取消归档", + "archiveConfirm": "您确定要归档此项目吗?", + "unarchiveConfirm": "您确定要取消归档此项目吗?", + "yes": "是", + "no": "否", + "clickToFilter": "点击筛选", + "noProjects": "未找到项目", + "addToFavourites": "添加到收藏", + "list": "列表", + "group": "分组", + "listView": "列表视图", + "groupView": "分组视图", + "groupBy": { + "category": "类别", + "client": "客户" + }, + "noPermission": "您没有权限执行此操作" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/auth/auth-common.json b/worklenz-backend/src/public/locales/zh/auth/auth-common.json new file mode 100644 index 00000000..df57a70d --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/auth/auth-common.json @@ -0,0 +1,5 @@ +{ + "loggingOut": "正在登出...", + "authenticating": "正在认证...", + "gettingThingsReady": "正在为您准备..." +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/auth/forgot-password.json b/worklenz-backend/src/public/locales/zh/auth/forgot-password.json new file mode 100644 index 00000000..de1529a4 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/auth/forgot-password.json @@ -0,0 +1,12 @@ +{ + "headerDescription": "重置您的密码", + "emailLabel": "电子邮件", + "emailPlaceholder": "输入您的电子邮件", + "emailRequired": "请输入您的电子邮件!", + "resetPasswordButton": "重置密码", + "returnToLoginButton": "返回登录", + "passwordResetSuccessMessage": "密码重置链接已发送到您的电子邮件。", + "orText": "或", + "successTitle": "重置指令已发送!", + "successMessage": "重置信息已发送到您的电子邮件。请检查您的电子邮件。" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/auth/login.json b/worklenz-backend/src/public/locales/zh/auth/login.json new file mode 100644 index 00000000..e53d5fc5 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/auth/login.json @@ -0,0 +1,27 @@ +{ + "headerDescription": "登录到您的账户", + "emailLabel": "电子邮件", + "emailPlaceholder": "输入您的电子邮件", + "emailRequired": "请输入您的电子邮件!", + "passwordLabel": "密码", + "passwordPlaceholder": "输入您的密码", + "passwordRequired": "请输入您的密码!", + "rememberMe": "记住我", + "loginButton": "登录", + "signupButton": "注册", + "forgotPasswordButton": "忘记密码?", + "signInWithGoogleButton": "使用Google登录", + "dontHaveAccountText": "没有账户?", + "orText": "或", + "successMessage": "您已成功登录!", + "loginError": "登录失败", + "googleLoginError": "Google登录失败", + "validationMessages": { + "email": "请输入有效的电子邮件地址", + "password": "密码必须至少包含8个字符" + }, + "errorMessages": { + "loginErrorTitle": "登录失败", + "loginErrorMessage": "请检查您的电子邮件和密码并重试" + } +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/auth/signup.json b/worklenz-backend/src/public/locales/zh/auth/signup.json new file mode 100644 index 00000000..a2b34e57 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/auth/signup.json @@ -0,0 +1,29 @@ +{ + "headerDescription": "注册以开始使用", + "nameLabel": "全名", + "namePlaceholder": "输入您的全名", + "nameRequired": "请输入您的全名!", + "nameMinCharacterRequired": "全名必须至少包含4个字符!", + "emailLabel": "电子邮件", + "emailPlaceholder": "输入您的电子邮件", + "emailRequired": "请输入您的电子邮件!", + "passwordLabel": "密码", + "passwordPlaceholder": "输入您的密码", + "passwordRequired": "请输入您的密码!", + "passwordMinCharacterRequired": "密码必须至少包含8个字符!", + "passwordPatternRequired": "密码不符合要求!", + "strongPasswordPlaceholder": "输入更强的密码", + "passwordValidationAltText": "密码必须至少包含8个字符,包括大小写字母、一个数字和一个符号。", + "signupSuccessMessage": "您已成功注册!", + "privacyPolicyLink": "隐私政策", + "termsOfUseLink": "使用条款", + "bySigningUpText": "通过注册,您同意我们的", + "andText": "和", + "signupButton": "注册", + "signInWithGoogleButton": "使用Google登录", + "alreadyHaveAccountText": "已经有账户了?", + "loginButton": "登录", + "orText": "或", + "reCAPTCHAVerificationError": "reCAPTCHA验证错误", + "reCAPTCHAVerificationErrorMessage": "我们无法验证您的reCAPTCHA。请重试。" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/auth/verify-reset-email.json b/worklenz-backend/src/public/locales/zh/auth/verify-reset-email.json new file mode 100644 index 00000000..11222523 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/auth/verify-reset-email.json @@ -0,0 +1,14 @@ +{ + "title": "验证重置电子邮件", + "description": "输入您的新密码", + "placeholder": "输入您的新密码", + "confirmPasswordPlaceholder": "确认您的新密码", + "passwordHint": "至少8个字符,包括大小写字母、一个数字和一个符号。", + "resetPasswordButton": "重置密码", + "orText": "或", + "resendResetEmail": "重新发送重置电子邮件", + "passwordRequired": "请输入您的新密码", + "returnToLoginButton": "返回登录", + "confirmPasswordRequired": "请确认您的新密码", + "passwordMismatch": "两次输入的密码不匹配" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/common.json b/worklenz-backend/src/public/locales/zh/common.json new file mode 100644 index 00000000..520ee5e2 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/common.json @@ -0,0 +1,9 @@ +{ + "login-success": "登录成功!", + "login-failed": "登录失败。请检查您的凭据并重试。", + "signup-success": "注册成功!欢迎加入。", + "signup-failed": "注册失败。请确保填写所有必填字段并重试。", + "reconnecting": "与服务器断开连接。", + "connection-lost": "无法连接到服务器。请检查您的互联网连接。", + "connection-restored": "成功连接到服务器" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/create-first-project-form.json b/worklenz-backend/src/public/locales/zh/create-first-project-form.json new file mode 100644 index 00000000..95ea4099 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/create-first-project-form.json @@ -0,0 +1,13 @@ +{ + "formTitle": "创建您的第一个项目", + "inputLabel": "您现在正在做什么项目?", + "or": "或", + "templateButton": "从模板导入", + "createFromTemplate": "从模板创建", + "goBack": "返回", + "continue": "继续", + "cancel": "取消", + "create": "创建", + "templateDrawerTitle": "从模板中选择", + "createProject": "创建项目" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/create-first-tasks.json b/worklenz-backend/src/public/locales/zh/create-first-tasks.json new file mode 100644 index 00000000..810d5aff --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/create-first-tasks.json @@ -0,0 +1,7 @@ +{ + "formTitle": "创建您的第一个任务。", + "inputLable": "输入您将在其中完成的几个任务", + "addAnother": "添加另一个", + "goBack": "返回", + "continue": "继续" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/home.json b/worklenz-backend/src/public/locales/zh/home.json new file mode 100644 index 00000000..184b4f1a --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/home.json @@ -0,0 +1,46 @@ +{ + "todoList": { + "title": "待办事项列表", + "refreshTasks": "刷新任务", + "addTask": "+ 添加任务", + "noTasks": "没有任务", + "pressEnter": "按", + "toCreate": "创建。", + "markAsDone": "标记为完成" + }, + "projects": { + "title": "项目", + "refreshProjects": "刷新项目", + "noRecentProjects": "您当前未被分配到任何项目。", + "noFavouriteProjects": "没有项目被标记为收藏。", + "recent": "最近", + "favourites": "收藏" + }, + "tasks": { + "assignedToMe": "分配给我", + "assignedByMe": "由我分配", + "all": "全部", + "today": "今天", + "upcoming": "即将到来", + "overdue": "逾期", + "noDueDate": "没有截止日期", + "noTasks": "没有任务可显示。", + "addTask": "+ 添加任务", + "name": "名称", + "project": "项目", + "status": "状态", + "dueDate": "截止日期", + "dueDatePlaceholder": "设置截止日期", + "tomorrow": "明天", + "nextWeek": "下周", + "nextMonth": "下个月", + "projectRequired": "请选择一个项目", + "pressTabToSelectDueDateAndProject": "按Tab键选择截止日期和项目", + "dueOn": "任务截止于", + "taskRequired": "请添加一个任务", + "list": "列表", + "calendar": "日历", + "tasks": "任务", + "refresh": "刷新" + } +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/invite-initial-team-members.json b/worklenz-backend/src/public/locales/zh/invite-initial-team-members.json new file mode 100644 index 00000000..6ebb9fbf --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/invite-initial-team-members.json @@ -0,0 +1,8 @@ +{ + "formTitle": "邀请您的团队一起工作", + "inputLable": "通过电子邮件邀请", + "addAnother": "添加另一个", + "goBack": "返回", + "continue": "继续", + "skipForNow": "暂时跳过" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/kanban-board.json b/worklenz-backend/src/public/locales/zh/kanban-board.json new file mode 100644 index 00000000..7b72c5d5 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/kanban-board.json @@ -0,0 +1,19 @@ +{ + "rename": "重命名", + "delete": "删除", + "addTask": "添加任务", + "addSectionButton": "添加部分", + "changeCategory": "更改类别", + "deleteTooltip": "删除", + "deleteConfirmationTitle": "您确定吗?", + "deleteConfirmationOk": "是", + "deleteConfirmationCancel": "取消", + "dueDate": "截止日期", + "cancel": "取消", + "today": "今天", + "tomorrow": "明天", + "assignToMe": "分配给我", + "archive": "归档", + "newTaskNamePlaceholder": "写一个任务名称", + "newSubtaskNamePlaceholder": "写一个子任务名称" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/license-expired.json b/worklenz-backend/src/public/locales/zh/license-expired.json new file mode 100644 index 00000000..838125c2 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/license-expired.json @@ -0,0 +1,6 @@ +{ + "title": "您的Worklenz试用已过期!", + "subtitle": "请立即升级。", + "button": "立即升级", + "checking": "正在检查订阅状态..." +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/navbar.json b/worklenz-backend/src/public/locales/zh/navbar.json new file mode 100644 index 00000000..c4ed67ab --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/navbar.json @@ -0,0 +1,31 @@ +{ + "logoAlt": "Worklenz Logo", + "home": "首页", + "projects": "项目", + "schedule": "日程", + "reporting": "报告", + "clients": "客户", + "teams": "团队", + "labels": "标签", + "jobTitles": "职位", + "upgradePlan": "升级计划", + "upgradePlanTooltip": "升级计划", + "invite": "邀请", + "inviteTooltip": "邀请团队成员加入", + "switchTeamTooltip": "切换团队", + "help": "帮助", + "notificationTooltip": "查看通知", + "profileTooltip": "查看个人资料", + "adminCenter": "管理中心", + "settings": "设置", + "logOut": "登出", + "notificationsDrawer": { + "read": "已读通知", + "unread": "未读通知", + "markAsRead": "标记为已读", + "readAndJoin": "阅读并加入", + "accept": "接受", + "acceptAndJoin": "接受并加入", + "noNotifications": "没有通知" + } +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/organization-name-form.json b/worklenz-backend/src/public/locales/zh/organization-name-form.json new file mode 100644 index 00000000..df8727d8 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/organization-name-form.json @@ -0,0 +1,5 @@ +{ + "nameYourOrganization": "命名您的组织。", + "worklenzAccountTitle": "为您的Worklenz账户选择一个名称。", + "continue": "继续" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/phases-drawer.json b/worklenz-backend/src/public/locales/zh/phases-drawer.json new file mode 100644 index 00000000..24d21b38 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/phases-drawer.json @@ -0,0 +1,19 @@ +{ + "configurePhases": "配置阶段", + "phaseLabel": "阶段标签", + "enterPhaseName": "输入阶段标签名称", + "addOption": "添加选项", + "phaseOptions": "阶段选项:", + "dragToReorderPhases": "拖拽阶段以重新排序。每个阶段可以有不同的颜色。", + "enterNewPhaseName": "输入新阶段名称...", + "addPhase": "添加阶段", + "noPhasesFound": "未找到阶段。请在上面创建您的第一个阶段。", + "deletePhase": "删除阶段", + "deletePhaseConfirm": "您确定要删除此阶段吗?此操作无法撤销。", + "rename": "重命名", + "delete": "删除", + "enterPhaseName": "输入阶段名称", + "selectColor": "选择颜色", + "managePhases": "管理阶段", + "close": "关闭" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/project-drawer.json b/worklenz-backend/src/public/locales/zh/project-drawer.json new file mode 100644 index 00000000..1649dfde --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/project-drawer.json @@ -0,0 +1,42 @@ +{ + "createProject": "创建项目", + "editProject": "编辑项目", + "enterCategoryName": "输入类别名称", + "hitEnterToCreate": "按回车键创建!", + "enterNotes": "备注", + "youCanManageClientsUnderSettings": "您可以在设置中管理客户", + "addCategory": "向项目添加类别", + "newCategory": "新类别", + "notes": "备注", + "startDate": "开始日期", + "endDate": "结束日期", + "estimateWorkingDays": "估算工作日", + "estimateManDays": "估算人天", + "hoursPerDay": "每天小时数", + "create": "创建", + "update": "更新", + "delete": "删除", + "typeToSearchClients": "输入以搜索客户", + "projectColor": "项目颜色", + "pleaseEnterAName": "请输入名称", + "enterProjectName": "输入项目名称", + "name": "名称", + "status": "状态", + "health": "健康状况", + "category": "类别", + "projectManager": "项目经理", + "client": "客户", + "deleteConfirmation": "您确定要删除吗?", + "deleteConfirmationDescription": "这将删除所有相关数据且无法撤销。", + "yes": "是", + "no": "否", + "createdAt": "创建于", + "updatedAt": "更新于", + "by": "由", + "add": "添加", + "asClient": "作为客户", + "createClient": "创建客户", + "searchInputPlaceholder": "按名称或电子邮件搜索", + "hoursPerDayValidationMessage": "每天小时数必须是1到24之间的数字", + "noPermission": "无权限" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/project-view-files.json b/worklenz-backend/src/public/locales/zh/project-view-files.json new file mode 100644 index 00000000..9cbf8ef6 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/project-view-files.json @@ -0,0 +1,14 @@ +{ + "nameColumn": "名称", + "attachedTaskColumn": "附加任务", + "sizeColumn": "大小", + "uploadedByColumn": "上传者", + "uploadedAtColumn": "上传时间", + "fileIconAlt": "文件图标", + "titleDescriptionText": "此项目中任务的所有附件将显示在这里。", + "deleteConfirmationTitle": "您确定吗?", + "deleteConfirmationOk": "是", + "deleteConfirmationCancel": "取消", + "segmentedTooltip": "即将推出!在列表视图和缩略图视图之间切换。", + "emptyText": "项目中没有附件。" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/project-view-insights.json b/worklenz-backend/src/public/locales/zh/project-view-insights.json new file mode 100644 index 00000000..903d73d2 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/project-view-insights.json @@ -0,0 +1,41 @@ +{ + "overview": { + "title": "概览", + "statusOverview": "状态概览", + "priorityOverview": "优先级概览", + "lastUpdatedTasks": "最近更新的任务" + }, + "members": { + "title": "成员", + "tooltip": "成员", + "tasksByMembers": "按成员分类任务", + "tasksByMembersTooltip": "按成员分类任务", + "name": "名称", + "taskCount": "任务计数", + "contribution": "贡献", + "completed": "已完成", + "incomplete": "未完成", + "overdue": "逾期", + "progress": "进度" + }, + "tasks": { + "overdueTasks": "逾期任务", + "overLoggedTasks": "超额记录任务", + "tasksCompletedEarly": "提前完成的任务", + "tasksCompletedLate": "延迟完成的任务", + "overLoggedTasksTooltip": "记录时间超过预计时间的任务", + "overdueTasksTooltip": "超过截止日期的任务" + }, + "common": { + "seeAll": "查看全部", + "totalLoggedHours": "总记录小时数", + "totalEstimation": "总估算", + "completedTasks": "已完成任务", + "incompleteTasks": "未完成任务", + "overdueTasks": "逾期任务", + "overdueTasksTooltip": "超过截止日期的任务", + "totalLoggedHoursTooltip": "任务估算和任务记录时间。", + "includeArchivedTasks": "包含已归档任务", + "export": "导出" + } +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/project-view-members.json b/worklenz-backend/src/public/locales/zh/project-view-members.json new file mode 100644 index 00000000..3d217694 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/project-view-members.json @@ -0,0 +1,17 @@ +{ + "nameColumn": "名称", + "jobTitleColumn": "职位", + "emailColumn": "电子邮件", + "tasksColumn": "任务", + "taskProgressColumn": "任务进度", + "accessColumn": "访问权限", + "fileIconAlt": "文件图标", + "deleteConfirmationTitle": "您确定吗?", + "deleteConfirmationOk": "是", + "deleteConfirmationCancel": "取消", + "refreshButtonTooltip": "刷新成员", + "deleteButtonTooltip": "从项目中移除", + "memberCount": "成员", + "membersCountPlural": "成员", + "emptyText": "项目中没有附件。" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/project-view-updates.json b/worklenz-backend/src/public/locales/zh/project-view-updates.json new file mode 100644 index 00000000..b34c71ea --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/project-view-updates.json @@ -0,0 +1,6 @@ +{ + "inputPlaceholder": "添加评论", + "addButton": "添加", + "cancelButton": "取消", + "deleteButton": "删除" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/project-view.json b/worklenz-backend/src/public/locales/zh/project-view.json new file mode 100644 index 00000000..ff756ea5 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/project-view.json @@ -0,0 +1,14 @@ +{ + "taskList": "任务列表", + "board": "看板", + "insights": "数据洞察", + "files": "文件", + "members": "成员", + "updates": "动态更新", + "projectView": "项目视图", + "loading": "正在加载项目...", + "error": "加载项目时出错", + "pinnedTab": "已固定为默认标签页", + "pinTab": "固定为默认标签页", + "unpinTab": "取消固定默认标签页" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/project-view/import-task-templates.json b/worklenz-backend/src/public/locales/zh/project-view/import-task-templates.json new file mode 100644 index 00000000..3dae9403 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/project-view/import-task-templates.json @@ -0,0 +1,11 @@ +{ + "importTaskTemplate": "导入任务模板", + "templateName": "模板名称", + "templateDescription": "模板描述", + "selectedTasks": "已选任务", + "tasks": "任务", + "templates": "模板", + "remove": "移除", + "cancel": "取消", + "import": "导入" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/project-view/project-member-drawer.json b/worklenz-backend/src/public/locales/zh/project-view/project-member-drawer.json new file mode 100644 index 00000000..f412f22b --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/project-view/project-member-drawer.json @@ -0,0 +1,7 @@ +{ + "title": "项目成员", + "searchLabel": "通过添加名称或电子邮件添加成员", + "searchPlaceholder": "输入名称或电子邮件", + "inviteAsAMember": "邀请为成员", + "inviteNewMemberByEmail": "通过电子邮件邀请新成员" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/project-view/project-view-header.json b/worklenz-backend/src/public/locales/zh/project-view/project-view-header.json new file mode 100644 index 00000000..9f8ca8ed --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/project-view/project-view-header.json @@ -0,0 +1,30 @@ +{ + "importTasks": "导入任务", + "importTask": "导入任务", + "createTask": "创建任务", + "settings": "设置", + "subscribe": "订阅", + "unsubscribe": "取消订阅", + "deleteProject": "删除项目", + "startDate": "开始日期", + "endDate": "结束日期", + "projectSettings": "项目设置", + "projectSummary": "项目摘要", + "receiveProjectSummary": "每晚接收项目摘要。", + "refreshProject": "刷新项目", + "saveAsTemplate": "保存为模板", + "invite": "邀请", + "share": "分享", + "subscribeTooltip": "订阅项目通知", + "unsubscribeTooltip": "取消订阅项目通知", + "refreshTooltip": "刷新项目数据", + "settingsTooltip": "打开项目设置", + "saveAsTemplateTooltip": "将此项目保存为模板", + "inviteTooltip": "邀请团队成员加入此项目", + "createTaskTooltip": "创建新任务", + "importTaskTooltip": "从模板导入任务", + "navigateBackTooltip": "返回项目列表", + "projectStatusTooltip": "项目状态", + "projectDatesInfo": "项目时间安排信息", + "projectCategoryTooltip": "项目类别" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/project-view/save-as-template.json b/worklenz-backend/src/public/locales/zh/project-view/save-as-template.json new file mode 100644 index 00000000..d1d3dfa8 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/project-view/save-as-template.json @@ -0,0 +1,27 @@ +{ + "title": "保存为模板", + "templateName": "模板名称", + "includes": "项目中应包含哪些内容到模板中?", + "includesOptions": { + "statuses": "状态", + "phases": "阶段", + "labels": "标签" + }, + "taskIncludes": "任务中应包含哪些内容到模板中?", + "taskIncludesOptions": { + "statuses": "状态", + "phases": "阶段", + "labels": "标签", + "name": "名称", + "priority": "优先级", + "status": "状态", + "phase": "阶段", + "label": "标签", + "timeEstimate": "预计用时", + "description": "描述", + "subTasks": "子任务" + }, + "cancel": "取消", + "save": "保存", + "templateNamePlaceholder": "输入模板名称" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/reporting-members-drawer.json b/worklenz-backend/src/public/locales/zh/reporting-members-drawer.json new file mode 100644 index 00000000..db42a74b --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/reporting-members-drawer.json @@ -0,0 +1,76 @@ +{ + "exportButton": "导出", + "timeLogsButton": "时间日志", + "activityLogsButton": "活动日志", + "tasksButton": "任务", + "searchByNameInputPlaceholder": "按名称搜索", + "overviewTab": "概览", + "timeLogsTab": "时间日志", + "activityLogsTab": "活动日志", + "tasksTab": "任务", + "projectsText": "项目", + "totalTasksText": "任务总数", + "assignedTasksText": "已分配任务", + "completedTasksText": "已完成任务", + "ongoingTasksText": "进行中任务", + "overdueTasksText": "逾期任务", + "loggedHoursText": "记录小时数", + "tasksText": "任务", + "allText": "全部", + "tasksByProjectsText": "按项目分类任务", + "tasksByStatusText": "按状态分类任务", + "tasksByPriorityText": "按优先级分类任务", + "todoText": "待办", + "doingText": "进行中", + "doneText": "已完成", + "lowText": "低", + "mediumText": "中", + "highText": "高", + "billableButton": "可计费", + "billableText": "可计费", + "nonBillableText": "不可计费", + "timeLogsEmptyPlaceholder": "没有时间日志可显示", + "loggedText": "记录", + "forText": "为", + "inText": "在", + "updatedText": "更新", + "fromText": "从", + "toText": "到", + "withinText": "在...之内", + "activityLogsEmptyPlaceholder": "没有活动日志可显示", + "filterByText": "筛选依据:", + "selectProjectPlaceholder": "选择项目", + "taskColumn": "任务", + "nameColumn": "名称", + "projectColumn": "项目", + "statusColumn": "状态", + "priorityColumn": "优先级", + "dueDateColumn": "截止日期", + "completedDateColumn": "完成日期", + "estimatedTimeColumn": "预计用时", + "loggedTimeColumn": "记录时间", + "overloggedTimeColumn": "超额记录时间", + "daysLeftColumn": "剩余天数/逾期", + "startDateColumn": "开始日期", + "endDateColumn": "结束日期", + "actualTimeColumn": "实际时间", + "projectHealthColumn": "项目健康状况", + "categoryColumn": "类别", + "projectManagerColumn": "项目经理", + "tasksStatsOverviewDrawerTitle": "的任务", + "projectsStatsOverviewDrawerTitle": "的项目", + "cancelledText": "已取消", + "blockedText": "已阻塞", + "onHoldText": "暂停", + "proposedText": "提议", + "inPlanningText": "规划中", + "inProgressText": "进行中", + "completedText": "已完成", + "continuousText": "持续", + "daysLeftText": "天剩余", + "daysOverdueText": "天逾期", + "notSetText": "未设置", + "needsAttentionText": "需要关注", + "atRiskText": "有风险", + "goodText": "良好" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/reporting-members.json b/worklenz-backend/src/public/locales/zh/reporting-members.json new file mode 100644 index 00000000..de4c23bb --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/reporting-members.json @@ -0,0 +1,31 @@ +{ + "yesterdayText": "昨天", + "lastSevenDaysText": "过去7天", + "lastWeekText": "上周", + "lastThirtyDaysText": "过去30天", + "lastMonthText": "上个月", + "lastThreeMonthsText": "过去3个月", + "allTimeText": "所有时间", + "customRangeText": "自定义范围", + "startDateInputPlaceholder": "开始日期", + "EndDateInputPlaceholder": "结束日期", + "filterButton": "筛选", + "membersTitle": "成员", + "includeArchivedButton": "包含已归档项目", + "exportButton": "导出", + "excelButton": "Excel", + "searchByNameInputPlaceholder": "按名称搜索", + "memberColumn": "成员", + "tasksProgressColumn": "任务进度", + "tasksAssignedColumn": "分配任务", + "completedTasksColumn": "已完成任务", + "overdueTasksColumn": "逾期任务", + "ongoingTasksColumn": "进行中任务", + "tasksAssignedColumnTooltip": "在选定日期范围内分配的任务", + "overdueTasksColumnTooltip": "在选定日期范围结束时逾期的任务", + "completedTasksColumnTooltip": "在选定日期范围内完成的任务", + "ongoingTasksColumnTooltip": "已开始但尚未完成的任务", + "todoText": "待办", + "doingText": "进行中", + "doneText": "已完成" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/reporting-overview-drawer.json b/worklenz-backend/src/public/locales/zh/reporting-overview-drawer.json new file mode 100644 index 00000000..a02b318f --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/reporting-overview-drawer.json @@ -0,0 +1,33 @@ +{ + "exportButton": "导出", + "projectsButton": "项目", + "membersButton": "成员", + "searchByNameInputPlaceholder": "按名称搜索", + "overviewTab": "概览", + "projectsTab": "项目", + "membersTab": "成员", + "projectsByStatusText": "按状态分类项目", + "projectsByCategoryText": "按类别分类项目", + "projectsByHealthText": "按健康状况分类项目", + "projectsText": "项目", + "allText": "全部", + "cancelledText": "已取消", + "blockedText": "已阻塞", + "onHoldText": "暂停", + "proposedText": "提议", + "inPlanningText": "规划中", + "inProgressText": "进行中", + "completedText": "已完成", + "continuousText": "持续", + "notSetText": "未设置", + "needsAttentionText": "需要关注", + "atRiskText": "有风险", + "goodText": "良好", + "nameColumn": "名称", + "emailColumn": "电子邮件", + "projectsColumn": "项目", + "tasksColumn": "任务", + "overdueTasksColumn": "逾期任务", + "completedTasksColumn": "已完成任务", + "ongoingTasksColumn": "进行中任务" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/reporting-overview.json b/worklenz-backend/src/public/locales/zh/reporting-overview.json new file mode 100644 index 00000000..fb172817 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/reporting-overview.json @@ -0,0 +1,22 @@ +{ + "overviewTitle": "概览", + "includeArchivedButton": "包含已归档项目", + "teamCount": "团队", + "teamCountPlural": "团队", + "projectCount": "项目", + "projectCountPlural": "项目", + "memberCount": "成员", + "memberCountPlural": "成员", + "activeProjectCount": "活跃项目", + "activeProjectCountPlural": "活跃项目", + "overdueProjectCount": "逾期项目", + "overdueProjectCountPlural": "逾期项目", + "unassignedMemberCount": "未分配成员", + "unassignedMemberCountPlural": "未分配成员", + "memberWithOverdueTaskCount": "有逾期任务的成员", + "memberWithOverdueTaskCountPlural": "有逾期任务的成员", + "teamsText": "团队", + "nameColumn": "名称", + "projectsColumn": "项目", + "membersColumn": "成员" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/reporting-projects-drawer.json b/worklenz-backend/src/public/locales/zh/reporting-projects-drawer.json new file mode 100644 index 00000000..d2f2f6ef --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/reporting-projects-drawer.json @@ -0,0 +1,52 @@ +{ + "exportButton": "导出", + "membersButton": "成员", + "tasksButton": "任务", + "searchByNameInputPlaceholder": "按名称搜索", + "overviewTab": "概览", + "membersTab": "成员", + "tasksTab": "任务", + "completedTasksText": "已完成任务", + "incompleteTasksText": "未完成任务", + "overdueTasksText": "逾期任务", + "allocatedHoursText": "已分配小时数", + "loggedHoursText": "已记录小时数", + "tasksText": "任务", + "allText": "全部", + "tasksByStatusText": "按状态分类任务", + "tasksByPriorityText": "按优先级分类任务", + "tasksByDueDateText": "按截止日期分类任务", + "todoText": "待办", + "doingText": "进行中", + "doneText": "已完成", + "lowText": "低", + "mediumText": "中", + "highText": "高", + "completedText": "已完成", + "upcomingText": "即将到来", + "overdueText": "逾期", + "noDueDateText": "无截止日期", + "nameColumn": "名称", + "tasksCountColumn": "任务计数", + "completedTasksColumn": "已完成任务", + "incompleteTasksColumn": "未完成任务", + "overdueTasksColumn": "逾期任务", + "contributionColumn": "贡献", + "progressColumn": "进度", + "loggedTimeColumn": "记录时间", + "taskColumn": "任务", + "projectColumn": "项目", + "statusColumn": "状态", + "priorityColumn": "优先级", + "phaseColumn": "阶段", + "dueDateColumn": "截止日期", + "completedDateColumn": "完成日期", + "estimatedTimeColumn": "预计用时", + "overloggedTimeColumn": "超额记录时间", + "completedOnColumn": "完成于", + "daysOverdueColumn": "逾期天数", + "groupByText": "分组依据:", + "statusText": "状态", + "priorityText": "优先级", + "phaseText": "阶段" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/reporting-projects-filters.json b/worklenz-backend/src/public/locales/zh/reporting-projects-filters.json new file mode 100644 index 00000000..ddfbe104 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/reporting-projects-filters.json @@ -0,0 +1,31 @@ +{ + "searchByNamePlaceholder": "按名称搜索", + "searchByCategoryPlaceholder": "按类别搜索", + "statusText": "状态", + "healthText": "健康状况", + "categoryText": "类别", + "projectManagerText": "项目经理", + "showFieldsText": "显示字段", + "cancelledText": "已取消", + "blockedText": "已阻塞", + "onHoldText": "暂停", + "proposedText": "提议", + "inPlanningText": "规划中", + "inProgressText": "进行中", + "completedText": "已完成", + "continuousText": "持续", + "notSetText": "未设置", + "needsAttentionText": "需要关注", + "atRiskText": "有风险", + "goodText": "良好", + "nameText": "项目", + "estimatedVsActualText": "预计用时 vs 实际用时", + "tasksProgressText": "任务进度", + "lastActivityText": "最后活动", + "datesText": "开始/结束日期", + "daysLeftText": "剩余天数/逾期", + "projectHealthText": "项目健康状况", + "projectUpdateText": "项目更新", + "clientText": "客户", + "teamText": "团队" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/reporting-projects.json b/worklenz-backend/src/public/locales/zh/reporting-projects.json new file mode 100644 index 00000000..0ff7d415 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/reporting-projects.json @@ -0,0 +1,44 @@ +{ + "projectCount": "项目", + "projectCountPlural": "项目", + "includeArchivedButton": "包含已归档项目", + "exportButton": "导出", + "excelButton": "Excel", + "projectColumn": "项目", + "estimatedVsActualColumn": "预计用时 vs 实际用时", + "tasksProgressColumn": "任务进度", + "lastActivityColumn": "最后活动", + "statusColumn": "状态", + "datesColumn": "开始/结束日期", + "daysLeftColumn": "剩余天数/逾期", + "projectHealthColumn": "项目健康状况", + "categoryColumn": "类别", + "projectUpdateColumn": "项目更新", + "clientColumn": "客户", + "teamColumn": "团队", + "projectManagerColumn": "项目经理", + "openButton": "打开", + "estimatedText": "预计", + "actualText": "实际", + "todoText": "待办", + "doingText": "进行中", + "doneText": "已完成", + "cancelledText": "已取消", + "blockedText": "已阻塞", + "onHoldText": "暂停", + "proposedText": "提议", + "inPlanningText": "规划中", + "inProgressText": "进行中", + "completedText": "已完成", + "continuousText": "持续", + "daysLeftText": "天剩余", + "dayLeftText": "天剩余", + "daysOverdueText": "天逾期", + "notSetText": "未设置", + "needsAttentionText": "需要关注", + "atRiskText": "有风险", + "goodText": "良好", + "setCategoryText": "设置类别", + "searchByNameInputPlaceholder": "按名称搜索", + "todayText": "今天" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/reporting-sidebar.json b/worklenz-backend/src/public/locales/zh/reporting-sidebar.json new file mode 100644 index 00000000..8a8206fb --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/reporting-sidebar.json @@ -0,0 +1,8 @@ +{ + "overview": "概览", + "projects": "项目", + "members": "成员", + "timeReports": "用时报告", + "estimateVsActual": "预计用时 vs 实际用时", + "currentOrganizationTooltip": "当前的组织" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/schedule.json b/worklenz-backend/src/public/locales/zh/schedule.json new file mode 100644 index 00000000..53fa8a97 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/schedule.json @@ -0,0 +1,34 @@ +{ + "today": "今天", + "week": "周", + "month": "月", + "settings": "设置", + "workingDays": "工作日", + "monday": "星期一", + "tuesday": "星期二", + "wednesday": "星期三", + "thursday": "星期四", + "friday": "星期五", + "saturday": "星期六", + "sunday": "星期日", + "workingHours": "工作时间", + "hours": "小时", + "saveButton": "保存", + "totalAllocation": "总分配", + "timeLogged": "记录时间", + "remainingTime": "剩余时间", + "total": "总计", + "perDay": "每天", + "tasks": "任务", + "startDate": "开始日期", + "endDate": "结束日期", + "hoursPerDay": "每天小时数", + "totalHours": "总小时数", + "deleteButton": "删除", + "cancelButton": "取消", + "tabTitle": "没有开始和结束日期的任务", + "allocatedTime": "分配时间", + "totalLogged": "总记录", + "loggedBillable": "已记录可计费", + "loggedNonBillable": "已记录不可计费" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/settings/categories.json b/worklenz-backend/src/public/locales/zh/settings/categories.json new file mode 100644 index 00000000..00027081 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/settings/categories.json @@ -0,0 +1,10 @@ +{ + "categoryColumn": "类别", + "deleteConfirmationTitle": "您确定吗?", + "deleteConfirmationOk": "是", + "deleteConfirmationCancel": "取消", + "associatedTaskColumn": "关联项目", + "searchPlaceholder": "按名称搜索", + "emptyText": "在更新或创建项目时可以创建类别。", + "colorChangeTooltip": "点击更改颜色" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/settings/change-password.json b/worklenz-backend/src/public/locales/zh/settings/change-password.json new file mode 100644 index 00000000..30cec581 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/settings/change-password.json @@ -0,0 +1,15 @@ +{ + "title": "更改密码", + "currentPassword": "当前密码", + "newPassword": "新密码", + "confirmPassword": "确认密码", + "currentPasswordPlaceholder": "输入您的当前密码", + "newPasswordPlaceholder": "新密码", + "confirmPasswordPlaceholder": "确认密码", + "currentPasswordRequired": "请输入您的当前密码!", + "newPasswordRequired": "请输入您的新密码!", + "passwordValidationError": "密码必须至少包含8个字符,包括一个大写字母、一个数字和一个符号。", + "passwordMismatch": "密码不匹配!", + "passwordRequirements": "新密码应至少包含8个字符,包括一个大写字母、一个数字和一个符号。", + "updateButton": "更新密码" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/settings/clients.json b/worklenz-backend/src/public/locales/zh/settings/clients.json new file mode 100644 index 00000000..c06b1adc --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/settings/clients.json @@ -0,0 +1,22 @@ +{ + "nameColumn": "名称", + "projectColumn": "项目", + "noProjectsAvailable": "没有可用的项目", + "deleteConfirmationTitle": "您确定吗?", + "deleteConfirmationOk": "是", + "deleteConfirmationCancel": "取消", + "searchPlaceholder": "按名称搜索", + "createClient": "创建客户", + "pinTooltip": "点击将其固定到主菜单", + "createClientDrawerTitle": "创建客户", + "updateClientDrawerTitle": "更新客户", + "nameLabel": "名称", + "namePlaceholder": "名称", + "nameRequiredError": "请输入名称", + "createButton": "创建", + "updateButton": "更新", + "createClientSuccessMessage": "客户创建成功!", + "createClientErrorMessage": "客户创建失败!", + "updateClientSuccessMessage": "客户更新成功!", + "updateClientErrorMessage": "客户更新失败!" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/settings/job-titles.json b/worklenz-backend/src/public/locales/zh/settings/job-titles.json new file mode 100644 index 00000000..c0458bb6 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/settings/job-titles.json @@ -0,0 +1,20 @@ +{ + "nameColumn": "名称", + "deleteConfirmationTitle": "您确定吗?", + "deleteConfirmationOk": "是", + "deleteConfirmationCancel": "取消", + "searchPlaceholder": "按名称搜索", + "createJobTitleButton": "创建职位", + "pinTooltip": "点击将其固定到主菜单", + "createJobTitleDrawerTitle": "创建职位", + "updateJobTitleDrawerTitle": "更新职位", + "nameLabel": "名称", + "namePlaceholder": "名称", + "nameRequiredError": "请输入名称", + "createButton": "创建", + "updateButton": "更新", + "createJobTitleSuccessMessage": "职位创建成功!", + "createJobTitleErrorMessage": "职位创建失败!", + "updateJobTitleSuccessMessage": "职位更新成功!", + "updateJobTitleErrorMessage": "职位更新失败!" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/settings/labels.json b/worklenz-backend/src/public/locales/zh/settings/labels.json new file mode 100644 index 00000000..ab0d01cd --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/settings/labels.json @@ -0,0 +1,11 @@ +{ + "labelColumn": "标签", + "deleteConfirmationTitle": "您确定吗?", + "deleteConfirmationOk": "是", + "deleteConfirmationCancel": "取消", + "associatedTaskColumn": "关联任务计数", + "searchPlaceholder": "按名称搜索", + "emptyText": "标签可以在更新或创建任务时创建。", + "pinTooltip": "点击将其固定到主菜单", + "colorChangeTooltip": "点击更改颜色" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/settings/language.json b/worklenz-backend/src/public/locales/zh/settings/language.json new file mode 100644 index 00000000..631eac11 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/settings/language.json @@ -0,0 +1,7 @@ +{ + "language": "语言", + "language_required": "语言是必需的", + "time_zone": "时区", + "time_zone_required": "时区是必需的", + "save_changes": "保存更改" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/settings/notifications.json b/worklenz-backend/src/public/locales/zh/settings/notifications.json new file mode 100644 index 00000000..f15784bf --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/settings/notifications.json @@ -0,0 +1,11 @@ +{ + "title": "通知设置", + "emailTitle": "向我发送电子邮件通知", + "emailDescription": "包括新的任务分配", + "dailyDigestTitle": "向我发送每日摘要", + "dailyDigestDescription": "每天晚上,您将收到任务中最近活动的摘要。", + "popupTitle": "当Worklenz打开时,在我的电脑上弹出通知", + "popupDescription": "弹出通知可能会被您的浏览器禁用。更改您的浏览器设置以允许它们。", + "unreadItemsTitle": "显示未读项目的数量", + "unreadItemsDescription": "您将看到每个通知的计数。" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/settings/profile.json b/worklenz-backend/src/public/locales/zh/settings/profile.json new file mode 100644 index 00000000..cfafeb12 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/settings/profile.json @@ -0,0 +1,14 @@ +{ + "uploadError": "您只能上传JPG/PNG文件!", + "uploadSizeError": "图片必须小于2MB!", + "upload": "上传", + "nameLabel": "名称", + "nameRequiredError": "名称是必需的", + "emailLabel": "电子邮件", + "emailRequiredError": "电子邮件是必需的", + "saveChanges": "保存更改", + "profileJoinedText": "一个月前加入", + "profileLastUpdatedText": "一个月前更新", + "avatarTooltip": "点击上传头像", + "title": "个人资料设置" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/settings/project-templates.json b/worklenz-backend/src/public/locales/zh/settings/project-templates.json new file mode 100644 index 00000000..5dcc866c --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/settings/project-templates.json @@ -0,0 +1,8 @@ +{ + "nameColumn": "名称", + "editToolTip": "编辑", + "deleteToolTip": "删除", + "confirmText": "您确定吗?", + "okText": "是", + "cancelText": "取消" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/settings/sidebar.json b/worklenz-backend/src/public/locales/zh/settings/sidebar.json new file mode 100644 index 00000000..b9f74709 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/settings/sidebar.json @@ -0,0 +1,15 @@ +{ + "profile": "个人资料", + "appearance": "外观", + "notifications": "通知", + "clients": "客户", + "job-titles": "职位", + "labels": "标签", + "categories": "类别", + "project-templates": "项目模板", + "task-templates": "任务模板", + "team-members": "团队成员", + "teams": "团队", + "change-password": "更改密码", + "language-and-region": "语言和地区" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/settings/task-templates.json b/worklenz-backend/src/public/locales/zh/settings/task-templates.json new file mode 100644 index 00000000..3fd9124a --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/settings/task-templates.json @@ -0,0 +1,9 @@ +{ + "nameColumn": "名称", + "createdColumn": "创建时间", + "editToolTip": "编辑", + "deleteToolTip": "删除", + "confirmText": "您确定吗?", + "okText": "是", + "cancelText": "取消" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/settings/team-members.json b/worklenz-backend/src/public/locales/zh/settings/team-members.json new file mode 100644 index 00000000..8b39483c --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/settings/team-members.json @@ -0,0 +1,47 @@ +{ + "title": "团队成员", + "nameColumn": "名称", + "projectsColumn": "项目", + "emailColumn": "电子邮件", + "teamAccessColumn": "团队访问", + "memberCount": "成员", + "membersCountPlural": "成员", + "searchPlaceholder": "按名称搜索成员", + "pinTooltip": "刷新成员列表", + "addMemberButton": "添加新成员", + "editTooltip": "编辑成员", + "deactivateTooltip": "停用成员", + "activateTooltip": "激活成员", + "deleteTooltip": "删除成员", + "confirmDeleteTitle": "您确定要删除此成员吗?", + "confirmActivateTitle": "您确定要更改此成员的状态吗?", + "okText": "是,继续", + "cancelText": "否,取消", + "deactivatedText": "(当前已停用)", + "pendingInvitationText": "(邀请待处理)", + "addMemberDrawerTitle": "添加新团队成员", + "updateMemberDrawerTitle": "更新团队成员", + "addMemberEmailHint": "无论是否接受邀请,成员都将被添加到团队中", + "memberEmailLabel": "电子邮件", + "memberEmailPlaceholder": "输入团队成员的电子邮件地址", + "memberEmailRequiredError": "请输入有效的电子邮件地址", + "jobTitleLabel": "职位", + "jobTitlePlaceholder": "选择或搜索职位(可选)", + "memberAccessLabel": "访问级别", + "addToTeamButton": "将成员添加到团队", + "updateButton": "保存更改", + "resendInvitationButton": "重新发送邀请邮件", + "invitationSentSuccessMessage": "团队邀请已成功发送!", + "createMemberSuccessMessage": "新团队成员已成功添加!", + "createMemberErrorMessage": "添加团队成员失败。请重试。", + "updateMemberSuccessMessage": "团队成员已成功更新!", + "updateMemberErrorMessage": "更新团队成员失败。请重试。", + "memberText": "成员", + "adminText": "管理员", + "ownerText": "团队所有者", + "addedText": "已添加", + "updatedText": "已更新", + "noResultFound": "输入电子邮件地址并按回车键...", + "jobTitlesFetchError": "获取职位失败", + "invitationResent": "邀请重新发送成功!" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/settings/teams.json b/worklenz-backend/src/public/locales/zh/settings/teams.json new file mode 100644 index 00000000..af2064ae --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/settings/teams.json @@ -0,0 +1,16 @@ +{ + "title": "团队", + "team": "团队", + "teams": "团队", + "name": "名称", + "created": "创建时间", + "ownsBy": "所有者", + "edit": "编辑", + "editTeam": "编辑团队", + "pinTooltip": "点击将此项固定到主菜单", + "editTeamName": "编辑团队名称", + "updateName": "更新名称", + "namePlaceholder": "名称", + "nameRequired": "请输入名称", + "updateFailed": "团队名称更改失败!" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/task-drawer/task-drawer-info-tab.json b/worklenz-backend/src/public/locales/zh/task-drawer/task-drawer-info-tab.json new file mode 100644 index 00000000..b0b36689 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/task-drawer/task-drawer-info-tab.json @@ -0,0 +1,29 @@ +{ + "details": { + "task-key": "任务ID", + "phase": "阶段", + "assignees": "受托人", + "due-date": "截止日期", + "time-estimation": "估计时间", + "priority": "优先级", + "labels": "标签", + "billable": "可计费", + "notify": "通知", + "when-done-notify": "完成时通知", + "start-date": "开始日期", + "end-date": "结束日期", + "hide-start-date": "隐藏开始日期", + "show-start-date": "显示开始日期", + "hours": "小时", + "minutes": "分钟" + }, + "description": { + "title": "描述", + "placeholder": "添加更详细的描述..." + }, + "subTasks": { + "title": "子任务", + "add-sub-task": "+ 添加子任务", + "refresh-sub-tasks": "刷新子任务" + } +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/task-drawer/task-drawer.json b/worklenz-backend/src/public/locales/zh/task-drawer/task-drawer.json new file mode 100644 index 00000000..dfe304fe --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/task-drawer/task-drawer.json @@ -0,0 +1,123 @@ +{ + "taskHeader": { + "taskNamePlaceholder": "输入您的任务", + "deleteTask": "删除任务" + }, + "taskInfoTab": { + "title": "信息", + "details": { + "title": "详情", + "task-key": "任务键", + "phase": "阶段", + "assignees": "受让人", + "due-date": "截止日期", + "time-estimation": "时间估算", + "priority": "优先级", + "labels": "标签", + "billable": "可计费", + "notify": "通知", + "when-done-notify": "完成时,通知", + "start-date": "开始日期", + "end-date": "结束日期", + "hide-start-date": "隐藏开始日期", + "show-start-date": "显示开始日期", + "hours": "小时", + "minutes": "分钟", + "progressValue": "进度值", + "progressValueTooltip": "设置进度百分比(0-100%)", + "progressValueRequired": "请输入进度值", + "progressValueRange": "进度必须在0到100之间", + "taskWeight": "任务权重", + "taskWeightTooltip": "设置此子任务的权重(百分比)", + "taskWeightRequired": "请输入任务权重", + "taskWeightRange": "权重必须在0到100之间", + "recurring": "重复" + }, + "labels": { + "labelInputPlaceholder": "搜索或创建", + "labelsSelectorInputTip": "按回车创建" + }, + "description": { + "title": "描述", + "placeholder": "添加更详细的描述..." + }, + "subTasks": { + "title": "子任务", + "addSubTask": "添加子任务", + "addSubTaskInputPlaceholder": "输入您的任务并按回车", + "refreshSubTasks": "刷新子任务", + "edit": "编辑", + "delete": "删除", + "confirmDeleteSubTask": "您确定要删除此子任务吗?", + "deleteSubTask": "删除子任务" + }, + "dependencies": { + "title": "依赖关系", + "addDependency": "+ 添加新依赖", + "blockedBy": "被阻止", + "searchTask": "输入搜索任务", + "noTasksFound": "未找到任务", + "confirmDeleteDependency": "您确定要删除吗?" + }, + "attachments": { + "title": "附件", + "chooseOrDropFileToUpload": "选择或拖放文件上传", + "uploading": "上传中..." + }, + "comments": { + "title": "评论", + "addComment": "+ 添加新评论", + "noComments": "还没有评论。成为第一个评论的人!", + "delete": "删除", + "confirmDeleteComment": "您确定要删除此评论吗?", + "addCommentPlaceholder": "添加评论...", + "cancel": "取消", + "commentButton": "评论", + "attachFiles": "附加文件", + "addMoreFiles": "添加更多文件", + "selectedFiles": "已选择的文件(最多25MB,最大{count}个)", + "maxFilesError": "您最多只能上传{count}个文件", + "processFilesError": "处理文件失败", + "addCommentError": "请添加评论或附加文件", + "createdBy": "{{time}}由{{user}}创建", + "updatedTime": "更新于{{time}}" + }, + "searchInputPlaceholder": "按名称搜索", + "pendingInvitation": "待处理邀请" + }, + "taskTimeLogTab": { + "title": "时间日志", + "addTimeLog": "添加新时间日志", + "totalLogged": "总记录时间", + "exportToExcel": "导出到Excel", + "noTimeLogsFound": "未找到时间日志", + "timeLogForm": { + "date": "日期", + "startTime": "开始时间", + "endTime": "结束时间", + "workDescription": "工作描述", + "descriptionPlaceholder": "添加描述", + "logTime": "记录时间", + "updateTime": "更新时间", + "cancel": "取消", + "selectDateError": "请选择日期", + "selectStartTimeError": "请选择开始时间", + "selectEndTimeError": "请选择结束时间", + "endTimeAfterStartError": "结束时间必须在开始时间之后" + } + }, + "taskActivityLogTab": { + "title": "活动日志", + "add": "添加", + "remove": "移除", + "none": "无", + "weight": "权重", + "createdTask": "创建了任务。" + }, + "taskProgress": { + "markAsDoneTitle": "将任务标记为完成?", + "confirmMarkAsDone": "是的,标记为完成", + "cancelMarkAsDone": "不,保持当前状态", + "markAsDoneDescription": "您已将进度设置为100%。您想将任务状态更新为\"完成\"吗?" + } +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/task-list-filters.json b/worklenz-backend/src/public/locales/zh/task-list-filters.json new file mode 100644 index 00000000..84387509 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/task-list-filters.json @@ -0,0 +1,79 @@ +{ + "searchButton": "搜索", + "resetButton": "重置", + "searchInputPlaceholder": "按名称搜索", + "sortText": "排序", + "statusText": "状态", + "phaseText": "阶段", + "memberText": "成员", + "assigneesText": "受托人", + "priorityText": "优先级", + "labelsText": "标签", + "membersText": "成员", + "groupByText": "分组依据", + "showArchivedText": "显示已归档的任务", + "showFieldsText": "显示字段", + "keyText": "ID", + "taskText": "任务", + "descriptionText": "描述", + "phasesText": "阶段", + "listText": "列表", + "progressText": "进度", + "timeTrackingText": "时间跟踪", + "timetrackingText": "时间跟踪", + "estimationText": "估计", + "startDateText": "开始日期", + "startdateText": "开始日期", + "endDateText": "结束日期", + "dueDateText": "截止日期", + "duedateText": "截止日期", + "completedDateText": "完成日期", + "completeddateText": "完成日期", + "createdDateText": "创建日期", + "createddateText": "创建日期", + "lastUpdatedText": "最后更新", + "lastupdatedText": "最后更新", + "reporterText": "报告人", + "dueTimeText": "截止时间", + "duetimeText": "截止时间", + "lowText": "低", + "mediumText": "中", + "highText": "高", + "createStatusButtonTooltip": "状态设置", + "configPhaseButtonTooltip": "阶段设置", + "noLabelsFound": "未找到标签", + "addStatusButton": "添加状态", + "addPhaseButton": "添加阶段", + "createStatus": "创建状态", + "name": "名称", + "category": "类别", + "selectCategory": "选择类别", + "pleaseEnterAName": "请输入名称", + "pleaseSelectACategory": "请选择类别", + "create": "创建", + "searchTasks": "搜索任务...", + "searchPlaceholder": "搜索...", + "fieldsText": "字段", + "loadingFilters": "加载筛选器...", + "noOptionsFound": "未找到选项", + "filtersActive": "个筛选器已激活", + "filterActive": "个筛选器已激活", + "clearAll": "清除全部", + "clearing": "清除中...", + "cancel": "取消", + "search": "搜索", + "groupedBy": "分组依据", + "manageStatuses": "管理状态", + "managePhases": "管理阶段", + "dragToReorderStatuses": "拖拽状态以重新排序。每个状态可以有不同的类别。", + "enterNewStatusName": "输入新状态名称...", + "addStatus": "添加状态", + "noStatusesFound": "未找到状态。请在上面创建您的第一个状态。", + "deleteStatus": "删除状态", + "deleteStatusConfirm": "您确定要删除此状态吗?此操作无法撤销。", + "rename": "重命名", + "delete": "删除", + "enterStatusName": "输入状态名称", + "selectCategory": "选择类别", + "close": "关闭" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/task-list-table.json b/worklenz-backend/src/public/locales/zh/task-list-table.json new file mode 100644 index 00000000..f3ec040f --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/task-list-table.json @@ -0,0 +1,129 @@ +{ + "keyColumn": "ID", + "taskColumn": "任务", + "descriptionColumn": "描述", + "progressColumn": "进度", + "membersColumn": "成员", + "assigneesColumn": "受托人", + "labelsColumn": "标签", + "phasesColumn": "阶段", + "phaseColumn": "阶段", + "statusColumn": "状态", + "priorityColumn": "优先级", + "timeTrackingColumn": "时间追踪", + "timetrackingColumn": "时间追踪", + "estimationColumn": "估算", + "startDateColumn": "开始日期", + "startdateColumn": "开始日期", + "dueDateColumn": "截止日期", + "duedateColumn": "截止日期", + "completedDateColumn": "完成日期", + "completeddateColumn": "完成日期", + "createdDateColumn": "创建日期", + "createddateColumn": "创建日期", + "lastUpdatedColumn": "最后更新", + "lastupdatedColumn": "最后更新", + "reporterColumn": "报告人", + "dueTimeColumn": "截止时间", + "todoSelectorText": "待办", + "doingSelectorText": "进行中", + "doneSelectorText": "已完成", + "lowSelectorText": "低", + "mediumSelectorText": "中", + "highSelectorText": "高", + "selectText": "选择", + "labelsSelectorInputTip": "按回车键创建!", + "addTaskText": "+ 添加任务", + "addSubTaskText": "+ 添加子任务", + "addTaskInputPlaceholder": "输入任务并按回车键", + "noTasksInGroup": "此组中没有任务", + "openButton": "打开", + "okButton": "确定", + "noLabelsFound": "未找到标签", + "searchInputPlaceholder": "搜索或创建", + "assigneeSelectorInviteButton": "通过电子邮件邀请新成员", + "labelInputPlaceholder": "搜索或创建", + "searchLabelsPlaceholder": "搜索标签...", + "createLabelButton": "创建 \"{{name}}\"", + "manageLabelsPath": "设置 → 标签", + "pendingInvitation": "待处理邀请", + "contextMenu": { + "assignToMe": "分配给我", + "moveTo": "移动到", + "unarchive": "取消归档", + "archive": "归档", + "convertToSubTask": "转换为子任务", + "convertToTask": "转换为任务", + "delete": "删除", + "searchByNameInputPlaceholder": "按名称搜索" + }, + "setDueDate": "设置截止日期", + "setStartDate": "设置开始日期", + "clearDueDate": "清除截止日期", + "clearStartDate": "清除开始日期", + "dueDatePlaceholder": "截止日期", + "startDatePlaceholder": "开始日期", + + "emptyStates": { + "noTaskGroups": "未找到任务组", + "noTaskGroupsDescription": "创建任务或应用筛选器后,任务将显示在此处。", + "errorPrefix": "错误:", + "dragTaskFallback": "任务" + }, + + "customColumns": { + "addCustomColumn": "添加自定义列", + "customColumnHeader": "自定义列", + "customColumnSettings": "自定义列设置", + "noCustomValue": "无值", + "peopleField": "人员字段", + "noDate": "无日期", + "unsupportedField": "不支持的字段类型", + + "modal": { + "addFieldTitle": "添加字段", + "editFieldTitle": "编辑字段", + "fieldTitle": "字段标题", + "fieldTitleRequired": "字段标题为必填项", + "columnTitlePlaceholder": "列标题", + "type": "类型", + "deleteConfirmTitle": "确定要删除此自定义列吗?", + "deleteConfirmDescription": "此操作无法撤销。与此列关联的所有数据将被永久删除。", + "deleteButton": "删除", + "cancelButton": "取消", + "createButton": "创建", + "updateButton": "更新", + "createSuccessMessage": "自定义列创建成功", + "updateSuccessMessage": "自定义列更新成功", + "deleteSuccessMessage": "自定义列删除成功", + "deleteErrorMessage": "删除自定义列失败", + "createErrorMessage": "创建自定义列失败", + "updateErrorMessage": "更新自定义列失败" + }, + + "fieldTypes": { + "people": "人员", + "number": "数字", + "date": "日期", + "selection": "选择", + "checkbox": "复选框", + "labels": "标签", + "key": "键", + "formula": "公式" + } + }, + + "indicators": { + "tooltips": { + "subtasks": "{{count}} 个子任务", + "subtasks_plural": "{{count}} 个子任务", + "comments": "{{count}} 条评论", + "comments_plural": "{{count}} 条评论", + "attachments": "{{count}} 个附件", + "attachments_plural": "{{count}} 个附件", + "subscribers": "任务有订阅者", + "dependencies": "任务有依赖项", + "recurring": "重复任务" + } + } +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/task-management.json b/worklenz-backend/src/public/locales/zh/task-management.json new file mode 100644 index 00000000..341ecc64 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/task-management.json @@ -0,0 +1,35 @@ +{ + "noTasksInGroup": "此组中没有任务", + "noTasksInGroupDescription": "添加任务开始使用", + "addFirstTask": "添加你的第一个任务", + "openTask": "打开", + "subtask": "子任务", + "subtasks": "子任务", + "comment": "评论", + "comments": "评论", + "attachment": "附件", + "attachments": "附件", + "enterSubtaskName": "输入子任务名称...", + "add": "添加", + "cancel": "取消", + "renameGroup": "重命名组", + "renameStatus": "重命名状态", + "renamePhase": "重命名阶段", + "changeCategory": "更改类别", + "clickToEditGroupName": "点击编辑组名称", + "enterGroupName": "输入组名称", + + "indicators": { + "tooltips": { + "subtasks": "{{count}} 个子任务", + "subtasks_plural": "{{count}} 个子任务", + "comments": "{{count}} 条评论", + "comments_plural": "{{count}} 条评论", + "attachments": "{{count}} 个附件", + "attachments_plural": "{{count}} 个附件", + "subscribers": "任务有订阅者", + "dependencies": "任务有依赖项", + "recurring": "重复任务" + } + } +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/task-template-drawer.json b/worklenz-backend/src/public/locales/zh/task-template-drawer.json new file mode 100644 index 00000000..53e99119 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/task-template-drawer.json @@ -0,0 +1,11 @@ +{ + "createTaskTemplate": "创建任务模板", + "editTaskTemplate": "编辑任务模板", + "cancelText": "取消", + "saveText": "保存", + "templateNameText": "模板名称", + "selectedTasks": "已选任务", + "removeTask": "移除", + "cancelButton": "取消", + "saveButton": "保存" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/tasks/task-table-bulk-actions.json b/worklenz-backend/src/public/locales/zh/tasks/task-table-bulk-actions.json new file mode 100644 index 00000000..2a4c89d6 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/tasks/task-table-bulk-actions.json @@ -0,0 +1,24 @@ +{ + "taskSelected": "任务已选择", + "tasksSelected": "任务已选择", + "changeStatus": "更改状态/优先级/阶段", + "changeLabel": "更改标签", + "assignToMe": "分配给我", + "changeAssignees": "更改受托人", + "archive": "归档", + "unarchive": "取消归档", + "delete": "删除", + "moreOptions": "更多选项", + "deselectAll": "取消全选", + "status": "状态", + "priority": "优先级", + "phase": "阶段", + "member": "成员", + "createTaskTemplate": "创建任务模板", + "apply": "应用", + "createLabel": "+ 创建标签", + "hitEnterToCreate": "按回车键创建", + "pendingInvitation": "待处理邀请", + "noMatchingLabels": "没有匹配的标签", + "noLabels": "没有标签" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/template-drawer.json b/worklenz-backend/src/public/locales/zh/template-drawer.json new file mode 100644 index 00000000..64fd242f --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/template-drawer.json @@ -0,0 +1,19 @@ +{ + "title": "编辑任务模板", + "cancelText": "取消", + "saveText": "保存", + "templateNameText": "模板名称", + "selectedTasks": "已选任务", + "removeTask": "移除", + "description": "描述", + "phase": "阶段", + "statuses": "状态", + "priorities": "优先级", + "labels": "标签", + "tasks": "任务", + "noTemplateSelected": "未选择模板", + "noDescription": "无描述", + "worklenzTemplates": "Worklenz模板", + "yourTemplatesLibrary": "您的模板库", + "searchTemplates": "搜索模板" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/templateDrawer.json b/worklenz-backend/src/public/locales/zh/templateDrawer.json new file mode 100644 index 00000000..8405f8ab --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/templateDrawer.json @@ -0,0 +1,23 @@ +{ + "bugTracking": "错误跟踪", + "construction": "建筑与施工", + "designCreative": "设计与创意", + "education": "教育", + "finance": "金融", + "hrRecruiting": "人力资源与招聘", + "informationTechnology": "信息技术", + "legal": "法律", + "manufacturing": "制造业", + "marketing": "市场营销", + "nonprofit": "非营利", + "personalUse": "个人使用", + "salesCRM": "销售与客户关系管理", + "serviceConsulting": "服务与咨询", + "softwareDevelopment": "软件开发", + "description": "描述", + "phase": "阶段", + "statuses": "状态", + "priorities": "优先级", + "labels": "标签", + "tasks": "任务" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/time-report.json b/worklenz-backend/src/public/locales/zh/time-report.json new file mode 100644 index 00000000..c376954a --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/time-report.json @@ -0,0 +1,33 @@ +{ + "includeArchivedProjects": "包含已归档项目", + "export": "导出", + "timeSheet": "时间表", + "searchByName": "按名称搜索", + "selectAll": "全选", + "teams": "团队", + "searchByProject": "按项目名称搜索", + "projects": "项目", + "searchByCategory": "按类别名称搜索", + "categories": "类别", + "billable": "可计费", + "nonBillable": "不可计费", + "total": "总计", + "projectsTimeSheet": "项目时间表", + "loggedTime": "已记录时间(小时)", + "exportToExcel": "导出到Excel", + "logged": "已记录", + "for": "为", + "membersTimeSheet": "成员时间表", + "member": "成员", + "estimatedVsActual": "预计用时 vs 实际用时", + "workingDays": "工作日", + "manDays": "人天", + "days": "天", + "estimatedDays": "预计天数", + "actualDays": "实际天数", + "noCategories": "未找到类别", + "noCategory": "无类别", + "noProjects": "未找到项目", + "noTeams": "未找到团队", + "noData": "未找到数据" +} \ No newline at end of file diff --git a/worklenz-backend/src/public/locales/zh/unauthorized.json b/worklenz-backend/src/public/locales/zh/unauthorized.json new file mode 100644 index 00000000..985b1d08 --- /dev/null +++ b/worklenz-backend/src/public/locales/zh/unauthorized.json @@ -0,0 +1,5 @@ +{ + "title": "未授权!", + "subtitle": "您无权访问此页面", + "button": "返回首页" +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx b/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx index cab31efa..0797783f 100644 --- a/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx +++ b/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx @@ -18,7 +18,7 @@ import { RootState } from '@/app/store'; import { useAppSelector } from '@/hooks/useAppSelector'; import { selectTasks } from '@/features/projects/bulkActions/bulkActionSlice'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { useBulkActionTranslations } from '@/hooks/useTranslationPreloader'; +import { useTranslation } from 'react-i18next'; import LabelsDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown'; import AssigneesDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/AssigneesDropdown'; import { ITaskLabel } from '@/types/tasks/taskLabel.types'; @@ -165,7 +165,7 @@ const OptimizedBulkActionBarContent: React.FC = Rea onBulkExport, onBulkSetDueDate, }) => { - const { t, ready, isLoading } = useBulkActionTranslations(); + const { t } = useTranslation(['tasks/task-table-bulk-actions', 'task-management']); const dispatch = useDispatch(); const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); @@ -549,10 +549,7 @@ const OptimizedBulkActionBarContent: React.FC = Rea [isDarkMode] ); - // Don't render until translations are ready to prevent Suspense - if (!ready || isLoading) { - return null; - } + // Remove translation loading check since we're using simple load-as-you-go approach if (!totalSelected || Number(totalSelected) < 1) { return null; diff --git a/worklenz-frontend/src/components/task-management/task-list-board.tsx b/worklenz-frontend/src/components/task-management/task-list-board.tsx index f6ea1721..ad2421f6 100644 --- a/worklenz-frontend/src/components/task-management/task-list-board.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { useTaskManagementTranslations } from '@/hooks/useTranslationPreloader'; +import { useTranslation } from 'react-i18next'; import { DndContext, DragOverlay, @@ -123,7 +123,7 @@ const throttle = void>(func: T, delay: number): T const TaskListBoard: React.FC = ({ projectId, className = '' }) => { const dispatch = useDispatch(); - const { t, ready, isLoading } = useTaskManagementTranslations(); + const { t } = useTranslation(['task-management', 'task-list-table']); const { trackMixpanelEvent } = useMixpanelTracking(); const [dragState, setDragState] = useState({ activeTask: null, @@ -757,16 +757,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' }; }, []); - // Don't render until translations are ready to prevent Suspense - if (!ready || isLoading) { - return ( - -
- -
-
- ); - } + // Remove translation loading check since we're using simple load-as-you-go approach if (error) { return ( diff --git a/worklenz-frontend/src/hooks/useTranslationPreloader.ts b/worklenz-frontend/src/hooks/useTranslationPreloader.ts deleted file mode 100644 index 226953c8..00000000 --- a/worklenz-frontend/src/hooks/useTranslationPreloader.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - ensureTranslationsLoaded, - preloadPageTranslations, - getPerformanceMetrics, - changeLanguageOptimized -} from '../i18n'; -import logger from '../utils/errorLogger'; - -// Cache for preloaded translation states -const preloadCache = new Map(); -const loadingStates = new Map(); - -interface TranslationHookOptions { - preload?: boolean; - priority?: number; - fallbackReady?: boolean; -} - -interface TranslationHookReturn { - t: (key: string, defaultValue?: string) => string; - ready: boolean; - isLoading: boolean; - error: Error | null; - retryLoad: () => Promise; - performanceMetrics: any; -} - -// Enhanced translation hook with better performance -export const useOptimizedTranslation = ( - namespace: string | string[], - options: TranslationHookOptions = {} -): TranslationHookReturn => { - const { preload = true, priority = 5, fallbackReady = true } = options; - - const namespaces = Array.isArray(namespace) ? namespace : [namespace]; - const namespaceKey = namespaces.join(','); - - const [ready, setReady] = useState(fallbackReady); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const hasInitialized = useRef(false); - const loadingPromise = useRef | null>(null); - - const { t, i18n } = useTranslation(namespaces); - - // Memoized preload function - const preloadTranslations = useCallback(async () => { - const cacheKey = `${i18n.language}:${namespaceKey}`; - - // Skip if already preloaded or currently loading - if (preloadCache.get(cacheKey) || loadingStates.get(cacheKey)) { - return; - } - - try { - setIsLoading(true); - setError(null); - loadingStates.set(cacheKey, true); - - const startTime = performance.now(); - - // Use the optimized preload function - await preloadPageTranslations(namespaces); - - const endTime = performance.now(); - const loadTime = endTime - startTime; - - if (process.env.NODE_ENV === 'development') { - console.log( - `✅ Preloaded translations for ${namespaceKey} in ${loadTime.toFixed(2)}ms` - ); - } - - preloadCache.set(cacheKey, true); - setReady(true); - } catch (err) { - const error = err instanceof Error ? err : new Error('Failed to preload translations'); - setError(error); - logger.error(`Failed to preload translations for ${namespaceKey}:`, error); - - // Fallback to ready state even on error to prevent blocking UI - if (fallbackReady) { - setReady(true); - } - } finally { - setIsLoading(false); - loadingStates.set(cacheKey, false); - } - }, [namespaces, namespaceKey, i18n.language, fallbackReady]); - - // Initialize preloading - useEffect(() => { - if (!hasInitialized.current && preload) { - hasInitialized.current = true; - - if (!loadingPromise.current) { - loadingPromise.current = preloadTranslations(); - } - } - }, [preload, preloadTranslations]); - - // Handle language changes - useEffect(() => { - const handleLanguageChange = () => { - const cacheKey = `${i18n.language}:${namespaceKey}`; - if (!preloadCache.get(cacheKey) && preload) { - setReady(false); - preloadTranslations(); - } - }; - - i18n.on('languageChanged', handleLanguageChange); - return () => { - i18n.off('languageChanged', handleLanguageChange); - }; - }, [i18n, namespaceKey, preload, preloadTranslations]); - - // Retry function - const retryLoad = useCallback(async () => { - const cacheKey = `${i18n.language}:${namespaceKey}`; - preloadCache.delete(cacheKey); - loadingStates.delete(cacheKey); - await preloadTranslations(); - }, [namespaceKey, i18n.language, preloadTranslations]); - - // Get performance metrics - const performanceMetrics = useMemo(() => getPerformanceMetrics(), [ready]); - - // Enhanced t function with better error handling - const enhancedT = useCallback((key: string, defaultValue?: string) => { - try { - const translation = t(key, { defaultValue }); - - // Return the translation if it's not the key itself (indicating it was found) - if (translation !== key) { - return translation; - } - - // If we have a default value, use it - if (defaultValue) { - return defaultValue; - } - - // Fallback to the key - return key; - } catch (err) { - logger.error(`Translation error for key ${key}:`, err); - return defaultValue || key; - } - }, [t]); - - return { - t: enhancedT, - ready, - isLoading, - error, - retryLoad, - performanceMetrics, - }; -}; - -// Specialized hooks for commonly used namespaces -export const useTaskManagementTranslations = (options?: TranslationHookOptions) => { - return useOptimizedTranslation(['task-management', 'task-list-table'], { - priority: 8, - ...options, - }); -}; - -export const useBulkActionTranslations = (options?: TranslationHookOptions) => { - return useOptimizedTranslation(['tasks/task-table-bulk-actions', 'task-management'], { - priority: 6, - ...options, - }); -}; - -export const useTaskDrawerTranslations = (options?: TranslationHookOptions) => { - return useOptimizedTranslation(['task-drawer/task-drawer', 'task-list-table'], { - priority: 7, - ...options, - }); -}; - -export const useProjectTranslations = (options?: TranslationHookOptions) => { - return useOptimizedTranslation(['project-drawer', 'common'], { - priority: 7, - ...options, - }); -}; - -export const useSettingsTranslations = (options?: TranslationHookOptions) => { - return useOptimizedTranslation(['settings', 'common'], { - priority: 4, - ...options, - }); -}; - -// Utility function to preload multiple namespaces -export const preloadMultipleNamespaces = async ( - namespaces: string[], - priority: number = 5 -): Promise => { - try { - await Promise.all( - namespaces.map(ns => preloadPageTranslations([ns])) - ); - return true; - } catch (error) { - logger.error('Failed to preload multiple namespaces:', error); - return false; - } -}; - -// Hook for pages that need multiple translation namespaces -export const usePageTranslations = ( - namespaces: string[], - options?: TranslationHookOptions -) => { - const { ready, isLoading, error } = useOptimizedTranslation(namespaces, options); - - // Create individual translation functions for each namespace - const translations = useMemo(() => { - const result: Record = {}; - - namespaces.forEach(ns => { - const { t } = useTranslation(ns); - result[ns] = t; - }); - - return result; - }, [namespaces, ready]); - - return { - ...translations, - ready, - isLoading, - error, - }; -}; - -// Language switching utilities -export const useLanguageSwitcher = () => { - const [switching, setSwitching] = useState(false); - - const switchLanguage = useCallback(async (language: string) => { - try { - setSwitching(true); - await changeLanguageOptimized(language); - - // Clear preload cache for new language - preloadCache.clear(); - loadingStates.clear(); - - } catch (error) { - logger.error('Failed to switch language:', error); - } finally { - setSwitching(false); - } - }, []); - - return { - switchLanguage, - switching, - }; -}; - -// Performance monitoring hook -export const useTranslationPerformance = () => { - const [metrics, setMetrics] = useState(getPerformanceMetrics()); - - useEffect(() => { - const interval = setInterval(() => { - setMetrics(getPerformanceMetrics()); - }, 5000); // Update every 5 seconds - - return () => clearInterval(interval); - }, []); - - return metrics; -}; diff --git a/worklenz-frontend/src/i18n.ts b/worklenz-frontend/src/i18n.ts index 444dd60d..c7337343 100644 --- a/worklenz-frontend/src/i18n.ts +++ b/worklenz-frontend/src/i18n.ts @@ -1,379 +1,34 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; -import HttpApi from 'i18next-http-backend'; import LanguageDetector from 'i18next-browser-languagedetector'; -import LocalStorageBackend from 'i18next-localstorage-backend'; -import logger from './utils/errorLogger'; - -// Essential namespaces that should be preloaded to prevent Suspense -const ESSENTIAL_NAMESPACES = [ - 'common', - 'auth/login', - 'navbar', -]; - -// Secondary namespaces that can be loaded on demand -const SECONDARY_NAMESPACES = [ - 'tasks/task-table-bulk-actions', - 'task-management', - 'settings', - 'home', - 'project-drawer', -]; - -// Tertiary namespaces that can be loaded even later -const TERTIARY_NAMESPACES = [ - 'task-drawer/task-drawer', - 'task-list-table', - 'phases-drawer', - 'schedule', - 'reporting', - 'admin-center/current-bill', -]; - -// Cache to track loaded translations and prevent duplicate requests -const loadedTranslations = new Set(); -const loadingPromises = new Map>(); - -// Background loading queue for non-essential translations -let backgroundLoadingQueue: Array<{ lang: string; ns: string; priority: number }> = []; -let isBackgroundLoading = false; - -// Performance monitoring -const performanceMetrics = { - totalLoadTime: 0, - translationsLoaded: 0, - cacheHits: 0, - cacheMisses: 0, -}; - -// Enhanced caching configuration -const CACHE_CONFIG = { - EXPIRATION_TIME: 7 * 24 * 60 * 60 * 1000, // 7 days - MAX_CACHE_SIZE: 50, // Maximum number of namespaces to cache - CLEANUP_INTERVAL: 24 * 60 * 60 * 1000, // Clean cache daily -}; +import HttpApi from 'i18next-http-backend'; i18n - .use(LocalStorageBackend) // Cache translations to localStorage - .use(LanguageDetector) // Detect user language - .use(HttpApi) // Fetch translations if not in cache + .use(HttpApi) + .use(LanguageDetector) .use(initReactI18next) .init({ fallbackLng: 'en', - backend: { - loadPath: '/locales/{{lng}}/{{ns}}.json', - addPath: '/locales/add/{{lng}}/{{ns}}', - // Enhanced LocalStorage caching options - backendOptions: [{ - expirationTime: CACHE_CONFIG.EXPIRATION_TIME, - // Store translations more efficiently - store: { - setItem: (key: string, value: string) => { - try { - // Compress large translation objects - const compressedValue = value.length > 1000 ? - JSON.stringify(JSON.parse(value)) : value; - localStorage.setItem(key, compressedValue); - performanceMetrics.cacheHits++; - } catch (error) { - logger.error('Failed to store translation in cache:', error); - } - }, - getItem: (key: string) => { - try { - const value = localStorage.getItem(key); - if (value) { - performanceMetrics.cacheHits++; - return value; - } - performanceMetrics.cacheMisses++; - return null; - } catch (error) { - logger.error('Failed to retrieve translation from cache:', error); - performanceMetrics.cacheMisses++; - return null; - } - } - } - }, { - loadPath: '/locales/{{lng}}/{{ns}}.json', - // Add request timeout and retry logic - requestOptions: { - cache: 'force-cache', // Use browser cache when possible - }, - parse: (data: string) => { - try { - return JSON.parse(data); - } catch (error) { - logger.error('Failed to parse translation data:', error); - return {}; - } - } - }], - }, defaultNS: 'common', - ns: ESSENTIAL_NAMESPACES, + interpolation: { escapeValue: false, }, - preload: [], - load: 'languageOnly', - initImmediate: false, + detection: { - order: ['localStorage', 'navigator'], // Check localStorage first, then browser language + order: ['localStorage', 'navigator'], caches: ['localStorage'], - // Cache the detected language for faster subsequent loads - cookieMinutes: 60 * 24 * 7, // 1 week }, - // Reduce debug output in production + debug: process.env.NODE_ENV === 'development', - // Performance optimizations - cleanCode: true, // Remove code characters - keySeparator: false, // Disable key separator for better performance - nsSeparator: false, // Disable namespace separator for better performance - pluralSeparator: '_', // Use underscore for plural separation + + backend: { + loadPath: '/locales/{{lng}}/{{ns}}.json', + }, + react: { - useSuspense: false, // Disable suspense for better control - bindI18n: 'languageChanged loaded', // Only bind necessary events - bindI18nStore: false, // Disable store binding for better performance + useSuspense: false, }, }); -// Optimized function to ensure translations are loaded with priority support -export const ensureTranslationsLoaded = async ( - namespaces: string[] = ESSENTIAL_NAMESPACES, - languages: string[] = [i18n.language || 'en'], - priority: number = 0 -) => { - const startTime = performance.now(); - - try { - const loadPromises: Promise[] = []; - - for (const lang of languages) { - for (const ns of namespaces) { - const key = `${lang}:${ns}`; - - // Skip if already loaded - if (loadedTranslations.has(key)) { - continue; - } - - // Check if already loading - if (loadingPromises.has(key)) { - loadPromises.push(loadingPromises.get(key)!); - continue; - } - - // Create loading promise with enhanced error handling - const loadingPromise = new Promise((resolve, reject) => { - const currentLang = i18n.language; - const shouldSwitchLang = currentLang !== lang; - - const loadForLanguage = async () => { - try { - if (shouldSwitchLang) { - await i18n.changeLanguage(lang); - } - - await i18n.loadNamespaces(ns); - - if (shouldSwitchLang && currentLang) { - await i18n.changeLanguage(currentLang); - } - - loadedTranslations.add(key); - performanceMetrics.translationsLoaded++; - resolve(); - } catch (error) { - logger.error(`Failed to load namespace: ${ns} for language: ${lang}`, error); - // Don't reject completely, just log and continue - resolve(); // Still resolve to prevent blocking other translations - } finally { - loadingPromises.delete(key); - } - }; - - loadForLanguage(); - }); - - loadingPromises.set(key, loadingPromise); - loadPromises.push(loadingPromise); - } - } - - await Promise.all(loadPromises); - - const endTime = performance.now(); - performanceMetrics.totalLoadTime += (endTime - startTime); - - return true; - } catch (error) { - logger.error('Failed to load translations:', error); - return false; - } -}; - -// Enhanced background loading function with priority queue -const processBackgroundQueue = async () => { - if (isBackgroundLoading || backgroundLoadingQueue.length === 0) return; - - isBackgroundLoading = true; - - try { - // Sort by priority (higher priority first) - backgroundLoadingQueue.sort((a, b) => b.priority - a.priority); - - // Process queue in smaller batches to avoid overwhelming the network - const batchSize = 2; // Reduced batch size for better performance - while (backgroundLoadingQueue.length > 0) { - const batch = backgroundLoadingQueue.splice(0, batchSize); - const batchPromises = batch.map(({ lang, ns }) => - ensureTranslationsLoaded([ns], [lang], 0).catch(error => { - logger.error(`Background loading failed for ${lang}:${ns}`, error); - }) - ); - - await Promise.all(batchPromises); - - // Add delay between batches to prevent blocking main thread - if (backgroundLoadingQueue.length > 0) { - await new Promise(resolve => setTimeout(resolve, 200)); // Increased delay - } - - // Break if we've been loading for too long (prevent infinite loops) - if (performance.now() - performanceMetrics.totalLoadTime > 30000) { // 30 seconds max - logger.error('Background translation loading taking too long, stopping'); - break; - } - } - } finally { - isBackgroundLoading = false; - } -}; - -// Enhanced queueing with priority support -const queueTranslations = (language: string, namespaces: string[], priority: number = 0) => { - namespaces.forEach(ns => { - const key = `${language}:${ns}`; - if (!loadedTranslations.has(key)) { - // Remove existing entry if it exists with lower priority - const existingIndex = backgroundLoadingQueue.findIndex(item => - item.lang === language && item.ns === ns); - if (existingIndex >= 0) { - if (backgroundLoadingQueue[existingIndex].priority < priority) { - backgroundLoadingQueue.splice(existingIndex, 1); - } else { - return; // Don't add duplicate with lower or equal priority - } - } - - backgroundLoadingQueue.push({ lang: language, ns, priority }); - } - }); - - // Start background loading with appropriate delay based on priority - const delay = priority > 5 ? 1000 : priority > 2 ? 2000 : 3000; - setTimeout(processBackgroundQueue, delay); -}; - -// Initialize only essential translations for current language -const initializeTranslations = async () => { - try { - const currentLang = i18n.language || 'en'; - - // Load only essential namespaces immediately - await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [currentLang], 10); - - // Queue secondary translations with medium priority - queueTranslations(currentLang, SECONDARY_NAMESPACES, 5); - - // Queue tertiary translations with low priority - queueTranslations(currentLang, TERTIARY_NAMESPACES, 1); - - return true; - } catch (error) { - logger.error('Failed to initialize translations:', error); - return false; - } -}; - -// Enhanced language change handler with better prioritization -export const changeLanguageOptimized = async (language: string) => { - try { - // Change language first - await i18n.changeLanguage(language); - - // Load essential namespaces immediately with high priority - await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [language], 10); - - // Queue secondary translations with medium priority - queueTranslations(language, SECONDARY_NAMESPACES, 5); - - // Queue tertiary translations with low priority - queueTranslations(language, TERTIARY_NAMESPACES, 1); - - return true; - } catch (error) { - logger.error(`Failed to change language to ${language}:`, error); - return false; - } -}; - -// Cache cleanup functionality -const cleanupCache = () => { - try { - const keys = Object.keys(localStorage).filter(key => - key.startsWith('i18next_res_') - ); - - if (keys.length > CACHE_CONFIG.MAX_CACHE_SIZE) { - // Remove oldest entries - const entriesToRemove = keys.slice(0, keys.length - CACHE_CONFIG.MAX_CACHE_SIZE); - entriesToRemove.forEach(key => { - try { - localStorage.removeItem(key); - } catch (error) { - logger.error('Failed to remove cache entry:', error); - } - }); - } - } catch (error) { - logger.error('Failed to cleanup translation cache:', error); - } -}; - -// Performance monitoring functions -export const getPerformanceMetrics = () => ({ - ...performanceMetrics, - cacheEfficiency: performanceMetrics.cacheHits / - (performanceMetrics.cacheHits + performanceMetrics.cacheMisses) * 100, - averageLoadTime: performanceMetrics.totalLoadTime / performanceMetrics.translationsLoaded, -}); - -export const resetPerformanceMetrics = () => { - performanceMetrics.totalLoadTime = 0; - performanceMetrics.translationsLoaded = 0; - performanceMetrics.cacheHits = 0; - performanceMetrics.cacheMisses = 0; -}; - -// Utility function to preload translations for a specific page/component -export const preloadPageTranslations = async (pageNamespaces: string[]) => { - const currentLang = i18n.language || 'en'; - return ensureTranslationsLoaded(pageNamespaces, [currentLang], 8); -}; - -// Set up periodic cache cleanup -if (typeof window !== 'undefined') { - setInterval(cleanupCache, CACHE_CONFIG.CLEANUP_INTERVAL); - - // Cleanup on page unload - window.addEventListener('beforeunload', cleanupCache); -} - -// Initialize translations on app startup -initializeTranslations(); - export default i18n; diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index c0228007..15dd1b2d 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -39,7 +39,7 @@ import { resetState as resetEnhancedKanbanState } from '@/features/enhanced-kanb import { setProjectId as setInsightsProjectId } from '@/features/projects/insights/project-insights.slice'; import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; import { useTranslation } from 'react-i18next'; -import { ensureTranslationsLoaded } from '@/i18n'; + // Import critical components synchronously to avoid suspense interruptions import TaskDrawer from '@components/task-drawer/task-drawer'; @@ -96,21 +96,10 @@ const ProjectView = React.memo(() => { setTaskId(urlParams.taskId); }, [urlParams]); - // Ensure translations are loaded for project-view namespace + // Remove translation preloading since we're using simple load-as-you-go approach useEffect(() => { - const loadTranslations = async () => { - try { - await ensureTranslationsLoaded(['project-view'], [i18n.language]); - updateTabLabels(); - setTranslationsReady(true); - } catch (error) { - console.error('Failed to load project-view translations:', error); - // Set ready to true anyway to prevent infinite loading - setTranslationsReady(true); - } - }; - - loadTranslations(); + updateTabLabels(); + setTranslationsReady(true); }, [i18n.language]); // Update tab labels when language changes diff --git a/worklenz-frontend/vite.config.ts b/worklenz-frontend/vite.config.ts index 6bca483d..d1c4687a 100644 --- a/worklenz-frontend/vite.config.ts +++ b/worklenz-frontend/vite.config.ts @@ -25,6 +25,7 @@ export default defineConfig(({ command, mode }) => { { find: '@shared', replacement: path.resolve(__dirname, './src/shared') }, { find: '@layouts', replacement: path.resolve(__dirname, './src/layouts') }, { find: '@services', replacement: path.resolve(__dirname, './src/services') }, + ], // **Ensure single React instance** dedupe: ['react', 'react-dom'], From 87675cc73c895c6b17d5687ec13c33884faa16be Mon Sep 17 00:00:00 2001 From: shancds Date: Fri, 11 Jul 2025 10:37:53 +0530 Subject: [PATCH 29/49] feat(kanban): implement portal for delete confirmation modal - Introduced a Portal component to render the delete confirmation modal outside the main DOM hierarchy, improving UI responsiveness. - Updated the delete confirmation modal to utilize the Portal for better overlay management and user experience. - Enhanced styling and interaction handling for the modal, ensuring it aligns with the application's theme and accessibility standards. --- .../KanbanGroup.tsx | 84 +++++++++++-------- 1 file changed, 49 insertions(+), 35 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx index 23b6613a..387efad1 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx @@ -24,7 +24,13 @@ import { fetchEnhancedKanbanGroups, IGroupBy, } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { createPortal } from 'react-dom'; +// Simple Portal component +const Portal: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const portalRoot = document.getElementById('portal-root') || document.body; + return createPortal(children, portalRoot); +}; interface KanbanGroupProps { group: ITaskListGroup; @@ -414,46 +420,54 @@ const KanbanGroup: React.FC = memo(({ {/* Simple Delete Confirmation */} {showDeleteConfirm && ( -
-
-
-
-
- - - + +
setShowDeleteConfirm(false)} + > +
e.stopPropagation()} + > +
+
+
+ + + +
+
+

+ {t('deleteConfirmationTitle')} +

+
-
-

- {t('deleteConfirmationTitle')} -

+
+ +
-
- - -
-
+
)}
{/* Create card at top */} From d9a5f76449f24879026341210f11d5a1b4de549f Mon Sep 17 00:00:00 2001 From: shancds Date: Fri, 11 Jul 2025 11:17:40 +0530 Subject: [PATCH 30/49] feat(assignee-selector): add kanbanMode prop and enhance styling - Introduced kanbanMode prop to AssigneeSelector for improved functionality in kanban view. - Updated styling in AssigneeSelector to adjust z-index for better overlay management. - Enhanced TaskCard to include LazyAssigneeSelectorWrapper, integrating the new prop for task assignment in kanban mode. --- .../src/components/AssigneeSelector.tsx | 6 ++++-- .../EnhancedKanbanBoardNativeDnD/TaskCard.tsx | 21 +++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx index 91866b7d..f9d8fd1f 100644 --- a/worklenz-frontend/src/components/AssigneeSelector.tsx +++ b/worklenz-frontend/src/components/AssigneeSelector.tsx @@ -18,12 +18,14 @@ interface AssigneeSelectorProps { task: IProjectTask; groupId?: string | null; isDarkMode?: boolean; + kanbanMode?: boolean; } const AssigneeSelector: React.FC = ({ task, groupId = null, - isDarkMode = false + isDarkMode = false, + kanbanMode = false }) => { const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); @@ -233,7 +235,7 @@ const AssigneeSelector: React.FC = ({ ref={dropdownRef} onClick={e => e.stopPropagation()} className={` - fixed z-9999 w-72 rounded-md shadow-lg border + fixed z-[99999] w-72 rounded-md shadow-lg border ${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200' diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx index fea952f0..fb03fb0b 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx @@ -424,7 +424,7 @@ const TaskCard: React.FC = memo(({
-
= memo(({ > {sub.end_date ? format(new Date(sub.end_date), 'MMM d, yyyy') : ''} - {sub.names && sub.names.length > 0 && ( - - )} + + {sub.names && sub.names.length > 0 && ( + + )} + + ))} From 278e221c759b24e8e3234b88329b34cba00d9bda Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 11:18:30 +0530 Subject: [PATCH 31/49] feat(task-list): add TaskListSkeleton component for improved loading state - Introduced TaskListSkeleton to provide a visual loading state for the task list, enhancing user experience during data fetching. - Updated TaskListV2Table to utilize TaskListSkeleton instead of a generic Skeleton component, allowing for a more tailored loading interface. - The new skeleton component includes customizable column widths and multiple rows to better represent the task list structure while loading. --- .../task-list-v2/TaskListV2Table.tsx | 5 +- .../components/TaskListSkeleton.tsx | 178 ++++++++++++++++++ 2 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 worklenz-frontend/src/components/task-list-v2/components/TaskListSkeleton.tsx diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index ac5227fe..c208882f 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -63,6 +63,7 @@ import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk- import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal'; import AddTaskRow from './components/AddTaskRow'; import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomColumnComponents'; +import TaskListSkeleton from './components/TaskListSkeleton'; // Hooks and utilities import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; @@ -597,7 +598,9 @@ const TaskListV2Section: React.FC = () => { ); // Loading and error states - if (loading || loadingColumns) return ; + if (loading || loadingColumns) { + return ; + } if (error) return (
diff --git a/worklenz-frontend/src/components/task-list-v2/components/TaskListSkeleton.tsx b/worklenz-frontend/src/components/task-list-v2/components/TaskListSkeleton.tsx new file mode 100644 index 00000000..54351f60 --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/components/TaskListSkeleton.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { Skeleton } from 'antd'; +import ImprovedTaskFilters from '@/components/task-management/improved-task-filters'; + +interface TaskListSkeletonProps { + visibleColumns?: Array<{ + id: string; + width: string; + isSticky?: boolean; + }>; +} + +const TaskListSkeleton: React.FC = ({ visibleColumns }) => { + // Default columns if none provided + const defaultColumns = [ + { id: 'dragHandle', width: '40px' }, + { id: 'checkbox', width: '40px' }, + { id: 'taskKey', width: '100px' }, + { id: 'title', width: '300px' }, + { id: 'assignees', width: '120px' }, + { id: 'status', width: '120px' }, + { id: 'priority', width: '100px' }, + { id: 'dueDate', width: '120px' }, + ]; + + const columns = visibleColumns || defaultColumns; + + // Generate multiple skeleton rows + const skeletonRows = Array.from({ length: 8 }, (_, index) => ( +
+ {columns.map((column, colIndex) => { + const columnStyle = { + width: column.width, + flexShrink: 0, + }; + + return ( +
+ {column.id === 'dragHandle' || column.id === 'checkbox' ? ( + + ) : column.id === 'title' ? ( +
+ +
+ ) : column.id === 'assignees' ? ( +
+ + +
+ ) : ( + + )} +
+ ); + })} +
+ )); + + return ( +
+
+ {/* Table Container */} +
+ {/* Skeleton Content */} +
+ {/* Skeleton Column Headers */} +
+
+ {columns.map((column, index) => { + const columnStyle = { + width: column.width, + flexShrink: 0, + }; + + return ( +
+ {column.id === 'dragHandle' || column.id === 'checkbox' ? ( + + ) : ( + + )} +
+ ); + })} + {/* Add Custom Column Button Skeleton */} +
+ +
+
+
+ + {/* Skeleton Group Headers and Rows */} +
+ {/* First Group */} +
+ {/* Group Header Skeleton */} +
+ +
+ +
+ +
+ + {/* Group Tasks Skeleton */} + {skeletonRows.slice(0, 3)} +
+ + {/* Second Group */} +
+ {/* Group Header Skeleton */} +
+ +
+ +
+ +
+ + {/* Group Tasks Skeleton */} + {skeletonRows.slice(3, 6)} +
+ + {/* Third Group */} +
+ {/* Group Header Skeleton */} +
+ +
+ +
+ +
+ + {/* Group Tasks Skeleton */} + {skeletonRows.slice(6, 8)} +
+
+
+
+
+
+ ); +}; + +export default TaskListSkeleton; \ No newline at end of file From f2b1262e3d1656fdaad8c7f7f0e35de200b58d2e Mon Sep 17 00:00:00 2001 From: shancds Date: Fri, 11 Jul 2025 13:45:13 +0530 Subject: [PATCH 32/49] feat(enhanced-kanban): enhance section creation with category selection and input handling - Added state management for section creation, including input focus and category selection. - Implemented dropdown for category selection with visual feedback and improved accessibility. - Refactored section creation logic to handle user input and category assignment more effectively. - Enhanced user experience by managing input focus and handling outside clicks to close dropdowns. --- .../EnhancedKanbanCreateSection.tsx | 241 ++++++++++++++---- 1 file changed, 188 insertions(+), 53 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx index 97f70bd0..1c4d7087 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx @@ -1,8 +1,9 @@ -import React from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import { Button, Flex } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; import { nanoid } from '@reduxjs/toolkit'; +import { DownOutlined } from '@ant-design/icons'; import { useAppSelector } from '@/hooks/useAppSelector'; import { themeWiseColor } from '@/utils/themeWiseColor'; @@ -33,6 +34,52 @@ const EnhancedKanbanCreateSection: React.FC = () => { const isOwnerorAdmin = useAuthService().isOwnerOrAdmin(); const isProjectManager = useIsProjectManager(); + const [isAdding, setIsAdding] = useState(false); + const [sectionName, setSectionName] = useState(''); + const [selectedCategoryId, setSelectedCategoryId] = useState(''); + const [showCategoryDropdown, setShowCategoryDropdown] = useState(false); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + const categoryDropdownRef = useRef(null); + + // Find selected category object + const selectedCategory = statusCategories?.find(cat => cat.id === selectedCategoryId); + + // Compute header background color + const headerBackgroundColor = React.useMemo(() => { + if (!selectedCategory) return themeWiseColor('#f5f5f5', '#1e1e1e', themeMode); + return selectedCategory.color_code || (themeMode === 'dark' ? '#1e1e1e' : '#f5f5f5'); + }, [themeMode, selectedCategory]); + + // Focus input when adding + useEffect(() => { + if (isAdding && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isAdding]); + + // Close on outside click (for both input and category dropdown) + useEffect(() => { + if (!isAdding && !showCategoryDropdown) return; + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) && + (!categoryDropdownRef.current || !categoryDropdownRef.current.contains(event.target as Node)) + ) { + setIsAdding(false); + setSectionName(''); + setSelectedCategoryId(''); + setShowCategoryDropdown(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isAdding, showCategoryDropdown]); + // Don't show for priority grouping or if user doesn't have permissions if (groupBy === IGroupBy.PRIORITY || (!isOwnerorAdmin && !isProjectManager)) { return null; @@ -59,49 +106,38 @@ const EnhancedKanbanCreateSection: React.FC = () => { }; const handleAddSection = async () => { - const sectionId = nanoid(); - const baseNameSection = 'Untitled section'; - const sectionName = getUniqueSectionName(baseNameSection); + setIsAdding(true); + setSectionName(''); + // Default to first category if available + if (statusCategories && statusCategories.length > 0 && typeof statusCategories[0].id === 'string') { + setSelectedCategoryId(statusCategories[0].id); + } else { + setSelectedCategoryId(''); + } + }; - if (groupBy === IGroupBy.STATUS && projectId) { - // Find the "To do" category - const todoCategory = statusCategories.find( - category => - category.name?.toLowerCase() === 'to do' || category.name?.toLowerCase() === 'todo' - ); - - if (todoCategory && todoCategory.id) { - // Create a new status - const body = { - name: sectionName, - project_id: projectId, - category_id: todoCategory.id, - }; - - try { - // Create the status - const response = await dispatch( - createStatus({ body, currentProjectId: projectId }) - ).unwrap(); - - if (response.done && response.body) { - // Refresh the board to show the new section - dispatch(fetchEnhancedKanbanGroups(projectId)); - // Refresh statuses - dispatch(fetchStatuses(projectId)); - } - } catch (error) { - logger.error('Failed to create status:', error); + const handleCreateSection = async () => { + if (!sectionName.trim() || !projectId) return; + const name = getUniqueSectionName(sectionName.trim()); + if (groupBy === IGroupBy.STATUS && selectedCategoryId) { + const body = { + name, + project_id: projectId, + category_id: selectedCategoryId, + }; + try { + const response = await dispatch( + createStatus({ body, currentProjectId: projectId }) + ).unwrap(); + if (response.done && response.body) { + dispatch(fetchEnhancedKanbanGroups(projectId)); + dispatch(fetchStatuses(projectId)); } + } catch (error) { + logger.error('Failed to create status:', error); } } - - if (groupBy === IGroupBy.PHASE && projectId) { - const body = { - name: sectionName, - project_id: projectId, - }; - + if (groupBy === IGroupBy.PHASE) { try { const response = await phasesApiService.addPhaseOption(projectId); if (response.done && response.body) { @@ -111,6 +147,19 @@ const EnhancedKanbanCreateSection: React.FC = () => { logger.error('Failed to create phase:', error); } } + setIsAdding(false); + setSectionName(''); + setSelectedCategoryId(''); + }; + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleCreateSection(); + } else if (e.key === 'Escape') { + setIsAdding(false); + setSectionName(''); + setSelectedCategoryId(''); + } }; return ( @@ -136,19 +185,105 @@ const EnhancedKanbanCreateSection: React.FC = () => { ), }} > - + {isAdding ? ( +
+ {/* Header-like area */} +
+ {/* Borderless input */} + setSectionName(e.target.value)} + onKeyDown={handleInputKeyDown} + className={`bg-transparent border-none outline-none text-sm font-semibold capitalize min-w-[120px] flex-1 ${themeMode === 'dark' ? 'text-gray-800 placeholder-gray-800' : 'text-gray-800 placeholder-gray-600'}`} + placeholder={t('untitledSection')} + style={{ marginBottom: 0 }} + /> + {/* Category selector dropdown */} + {groupBy === IGroupBy.STATUS && statusCategories && statusCategories.length > 0 && ( +
+ + {showCategoryDropdown && ( +
+
+ {statusCategories.filter(cat => typeof cat.id === 'string').map(cat => ( + + ))} +
+
+ )} +
+ )} +
+
+ + +
+
+ ) : ( + + )}
); From 6b5870984861590d2142489026062d7cfedad8cf Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 13:50:35 +0530 Subject: [PATCH 33/49] feat(task-management): enhance status management with drag-and-drop functionality - Updated ManageStatusModal to support drag-and-drop for reordering statuses and moving them between categories. - Introduced CategorySection component for better organization of statuses by category. - Added validation to prevent moving the last status out of a category, ensuring each category retains at least one status. - Enhanced localization for task management, updating translation keys across multiple languages for improved clarity and consistency. --- worklenz-backend/database/sql/4_functions.sql | 18 + .../public/locales/alb/task-list-filters.json | 82 +- .../public/locales/de/task-list-filters.json | 32 +- .../public/locales/en/task-list-filters.json | 10 +- .../public/locales/es/task-list-filters.json | 56 +- .../public/locales/pt/task-list-filters.json | 69 +- .../public/locales/zh/task-list-filters.json | 5 +- .../task-management/ManageStatusModal.tsx | 867 ++++++++++++++---- 8 files changed, 829 insertions(+), 310 deletions(-) diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index fb551450..441b08e8 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -5497,8 +5497,14 @@ $$ DECLARE _iterator NUMERIC := 0; _status_id TEXT; + _project_id UUID; BEGIN + -- Get the project_id from the first status to ensure we update all statuses in the same project + SELECT project_id INTO _project_id + FROM task_statuses + WHERE id = (SELECT TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT) LIMIT 1)::UUID; + -- Update the sort_order for statuses in the provided order FOR _status_id IN SELECT * FROM JSON_ARRAY_ELEMENTS((_status_ids)::JSON) LOOP UPDATE task_statuses @@ -5507,6 +5513,18 @@ BEGIN _iterator := _iterator + 1; END LOOP; + -- Ensure any remaining statuses in the project (not in the provided list) get sequential sort_order + -- This handles edge cases where not all statuses are provided + UPDATE task_statuses + SET sort_order = ( + SELECT COUNT(*) + FROM task_statuses ts2 + WHERE ts2.project_id = _project_id + AND ts2.id = ANY(SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID) + ) + ROW_NUMBER() OVER (ORDER BY sort_order) - 1 + WHERE project_id = _project_id + AND id NOT IN (SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID); + RETURN; END $$; diff --git a/worklenz-frontend/public/locales/alb/task-list-filters.json b/worklenz-frontend/public/locales/alb/task-list-filters.json index c3156498..e75e4802 100644 --- a/worklenz-frontend/public/locales/alb/task-list-filters.json +++ b/worklenz-frontend/public/locales/alb/task-list-filters.json @@ -7,79 +7,81 @@ "statusText": "Statusi", "phaseText": "Faza", "memberText": "Anëtarët", - "assigneesText": "Përgjegjësit", + "assigneesText": "Të caktuarit", "priorityText": "Prioriteti", - "labelsText": "Etiketa", + "labelsText": "Etiketat", "membersText": "Anëtarët", "groupByText": "Grupo sipas", - "showArchivedText": "Shfaq të arkivuara", + "showArchivedText": "Shfaq të arkivuarat", "showFieldsText": "Shfaq fushat", "keyText": "Çelësi", "taskText": "Detyra", "descriptionText": "Përshkrimi", "phasesText": "Fazat", - "listText": "Listë", + "listText": "Lista", "progressText": "Progresi", - "timeTrackingText": "Gjurmimi i Kohës", - "timetrackingText": "Gjurmimi i Kohës", + "timeTrackingText": "Ndjekja e kohës", + "timetrackingText": "Ndjekja e kohës", "estimationText": "Vlerësimi", - "startDateText": "Data e Fillimit", - "startdateText": "Data e Fillimit", - "endDateText": "Data e Përfundimit", - "dueDateText": "Afati", - "duedateText": "Afati", - "completedDateText": "Data e Përfundimit", - "completeddateText": "Data e Përfundimit", - "createdDateText": "Data e Krijimit", - "createddateText": "Data e Krijimit", - "lastUpdatedText": "Përditësuar Së Fundi", - "lastupdatedText": "Përditësuar Së Fundi", + "startDateText": "Data e fillimit", + "startdateText": "Data e fillimit", + "endDateText": "Data e mbarimit", + "dueDateText": "Data e afatit", + "duedateText": "Data e afatit", + "completedDateText": "Data e përfundimit", + "completeddateText": "Data e përfundimit", + "createdDateText": "Data e krijimit", + "createddateText": "Data e krijimit", + "lastUpdatedText": "Përditësimi i fundit", + "lastupdatedText": "Përditësimi i fundit", "reporterText": "Raportuesi", - "dueTimeText": "Koha e Afatit", - "duetimeText": "Koha e Afatit", + "dueTimeText": "Koha e afatit", + "duetimeText": "Koha e afatit", - "lowText": "I ulët", - "mediumText": "I mesëm", - "highText": "I lartë", + "lowText": "E ulët", + "mediumText": "E mesme", + "highText": "E lartë", "createStatusButtonTooltip": "Cilësimet e statusit", "configPhaseButtonTooltip": "Cilësimet e fazës", "noLabelsFound": "Nuk u gjetën etiketa", - "addStatusButton": "Shto Status", - "addPhaseButton": "Shto Fazë", + "addStatusButton": "Shto status", + "addPhaseButton": "Shto fazë", - "createStatus": "Krijo Status", + "createStatus": "Krijo status", "name": "Emri", "category": "Kategoria", "selectCategory": "Zgjidh një kategori", - "pleaseEnterAName": "Ju lutemi vendosni një emër", + "pleaseEnterAName": "Ju lutemi shkruani një emër", "pleaseSelectACategory": "Ju lutemi zgjidhni një kategori", "create": "Krijo", - "searchTasks": "Kërko detyrat...", + "searchTasks": "Kërko detyra...", "searchPlaceholder": "Kërko...", "fieldsText": "Fushat", - "loadingFilters": "Duke ngarkuar filtrat...", - "noOptionsFound": "Nuk u gjetën opsione", + "loadingFilters": "Po ngarkohen filtrat...", + "noOptionsFound": "Nuk u gjetën opcione", "filtersActive": "filtra aktiv", "filterActive": "filtër aktiv", "clearAll": "Pastro të gjitha", - "clearing": "Duke pastruar...", + "clearing": "Po pastron...", "cancel": "Anulo", "search": "Kërko", - "groupedBy": "Grupuar sipas", - "manageStatuses": "Menaxho Statuset", - "managePhases": "Menaxho Fazat", - "dragToReorderStatuses": "Zvarrit statuset për t'i rirenditur. Çdo status mund të ketë një kategori të ndryshme.", + "groupedBy": "I grupuar sipas", + "manageStatuses": "Menaxho statuset", + "managePhases": "Menaxho fazat", + "dragToReorderStatuses": "Statuset janë të organizuara sipas kategorive. Tërhiq për të rirenditur brenda kategorive. Kliko 'Shto status' për të krijuar statuse të reja në çdo kategori.", "enterNewStatusName": "Shkruani emrin e statusit të ri...", - "addStatus": "Shto Status", - "noStatusesFound": "Nuk u gjetën statuse. Krijoni statusin tuaj të parë më sipër.", - "deleteStatus": "Fshi Statusin", + "addStatus": "Shto status", + "noStatusesFound": "Nuk ka statuse në këtë kategori", + "deleteStatus": "Fshi statusin", "deleteStatusConfirm": "Jeni të sigurt që doni të fshini këtë status? Ky veprim nuk mund të zhbëhet.", - "rename": "Riemëro", + "rename": "Riemërto", "delete": "Fshi", "enterStatusName": "Shkruani emrin e statusit", - "selectCategory": "Zgjidh kategorinë", - "close": "Mbyll" + "close": "Mbyll", + "cannotMoveStatus": "Nuk mund të lëvizet statusi", + "cannotMoveStatusMessage": "Nuk mund të lëvizet ky status sepse do të linte kategorinë '{{categoryName}}' bosh. Çdo kategori duhet të ketë të paktën një status.", + "ok": "OK" } diff --git a/worklenz-frontend/public/locales/de/task-list-filters.json b/worklenz-frontend/public/locales/de/task-list-filters.json index 0854c34f..743f036a 100644 --- a/worklenz-frontend/public/locales/de/task-list-filters.json +++ b/worklenz-frontend/public/locales/de/task-list-filters.json @@ -28,15 +28,15 @@ "endDateText": "Enddatum", "dueDateText": "Fälligkeitsdatum", "duedateText": "Fälligkeitsdatum", - "completedDateText": "Abschlussdatum", - "completeddateText": "Abschlussdatum", - "createdDateText": "Erstellungsdatum", - "createddateText": "Erstellungsdatum", + "completedDateText": "Abgeschlossen am", + "completeddateText": "Abgeschlossen am", + "createdDateText": "Erstellt am", + "createddateText": "Erstellt am", "lastUpdatedText": "Zuletzt aktualisiert", "lastupdatedText": "Zuletzt aktualisiert", - "reporterText": "Melder", - "dueTimeText": "Fällige Zeit", - "duetimeText": "Fällige Zeit", + "reporterText": "Berichterstatter", + "dueTimeText": "Fälligkeitszeit", + "duetimeText": "Fälligkeitszeit", "lowText": "Niedrig", "mediumText": "Mittel", @@ -54,7 +54,7 @@ "category": "Kategorie", "selectCategory": "Kategorie auswählen", "pleaseEnterAName": "Bitte geben Sie einen Namen ein", - "pleaseSelectACategory": "Bitte wählen Sie eine Kategorie aus", + "pleaseSelectACategory": "Bitte wählen Sie eine Kategorie", "create": "Erstellen", "searchTasks": "Aufgaben suchen...", @@ -66,20 +66,22 @@ "filterActive": "Filter aktiv", "clearAll": "Alle löschen", "clearing": "Wird gelöscht...", - "cancel": "Stornieren", + "cancel": "Abbrechen", "search": "Suchen", "groupedBy": "Gruppiert nach", "manageStatuses": "Status verwalten", "managePhases": "Phasen verwalten", - "dragToReorderStatuses": "Ziehen Sie Status, um sie neu zu ordnen. Jeder Status kann eine andere Kategorie haben.", - "enterNewStatusName": "Neuen Statusnamen eingeben...", + "dragToReorderStatuses": "Status sind nach Kategorien organisiert. Ziehen Sie, um innerhalb von Kategorien neu zu ordnen. Klicken Sie auf 'Status hinzufügen', um neue Status in jeder Kategorie zu erstellen.", + "enterNewStatusName": "Neuen Status-Namen eingeben...", "addStatus": "Status hinzufügen", - "noStatusesFound": "Keine Status gefunden. Erstellen Sie Ihren ersten Status oben.", + "noStatusesFound": "Keine Status in dieser Kategorie", "deleteStatus": "Status löschen", "deleteStatusConfirm": "Sind Sie sicher, dass Sie diesen Status löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", "rename": "Umbenennen", "delete": "Löschen", - "enterStatusName": "Statusnamen eingeben", - "selectCategory": "Kategorie auswählen", - "close": "Schließen" + "enterStatusName": "Status-Namen eingeben", + "close": "Schließen", + "cannotMoveStatus": "Status kann nicht verschoben werden", + "cannotMoveStatusMessage": "Dieser Status kann nicht verschoben werden, da die Kategorie '{{categoryName}}' leer bleiben würde. Jede Kategorie muss mindestens einen Status haben.", + "ok": "OK" } diff --git a/worklenz-frontend/public/locales/en/task-list-filters.json b/worklenz-frontend/public/locales/en/task-list-filters.json index a38356c6..36ee10dc 100644 --- a/worklenz-frontend/public/locales/en/task-list-filters.json +++ b/worklenz-frontend/public/locales/en/task-list-filters.json @@ -71,15 +71,17 @@ "groupedBy": "Grouped by", "manageStatuses": "Manage Statuses", "managePhases": "Manage Phases", - "dragToReorderStatuses": "Drag statuses to reorder them. Each status can have a different category.", + "dragToReorderStatuses": "Statuses are organized by categories. Drag to reorder within categories. Click 'Add Status' to create new statuses in each category.", "enterNewStatusName": "Enter new status name...", "addStatus": "Add Status", - "noStatusesFound": "No statuses found. Create your first status above.", + "noStatusesFound": "No statuses in this category", "deleteStatus": "Delete Status", "deleteStatusConfirm": "Are you sure you want to delete this status? This action cannot be undone.", "rename": "Rename", "delete": "Delete", "enterStatusName": "Enter status name", - "selectCategory": "Select category", - "close": "Close" + "close": "Close", + "cannotMoveStatus": "Cannot Move Status", + "cannotMoveStatusMessage": "Cannot move this status because it would leave the '{{categoryName}}' category empty. Each category must have at least one status.", + "ok": "OK" } diff --git a/worklenz-frontend/public/locales/es/task-list-filters.json b/worklenz-frontend/public/locales/es/task-list-filters.json index 465368f0..dc42706a 100644 --- a/worklenz-frontend/public/locales/es/task-list-filters.json +++ b/worklenz-frontend/public/locales/es/task-list-filters.json @@ -6,6 +6,8 @@ "sortText": "Ordenar", "statusText": "Estado", "phaseText": "Fase", + "memberText": "Miembros", + "assigneesText": "Asignados", "priorityText": "Prioridad", "labelsText": "Etiquetas", "membersText": "Miembros", @@ -16,30 +18,32 @@ "taskText": "Tarea", "descriptionText": "Descripción", "phasesText": "Fases", + "listText": "Lista", "progressText": "Progreso", "timeTrackingText": "Seguimiento de tiempo", + "timetrackingText": "Seguimiento de tiempo", "estimationText": "Estimación", "startDateText": "Fecha de inicio", + "startdateText": "Fecha de inicio", "endDateText": "Fecha de fin", "dueDateText": "Fecha de vencimiento", + "duedateText": "Fecha de vencimiento", "completedDateText": "Fecha de finalización", + "completeddateText": "Fecha de finalización", "createdDateText": "Fecha de creación", + "createddateText": "Fecha de creación", "lastUpdatedText": "Última actualización", + "lastupdatedText": "Última actualización", "reporterText": "Reportero", "dueTimeText": "Hora de vencimiento", - "lowText": "Baja", - "mediumText": "Media", - "highText": "Alta", - "assigneesText": "Asignados", - "timetrackingText": "Seguimiento de tiempo", - "startdateText": "Fecha de inicio", - "duedateText": "Fecha de vencimiento", - "completeddateText": "Fecha de finalización", - "createddateText": "Fecha de creación", - "lastupdatedText": "Última actualización", "duetimeText": "Hora de vencimiento", - "createStatusButtonTooltip": "Configuración de estados", - "configPhaseButtonTooltip": "Configuración de fases", + + "lowText": "Bajo", + "mediumText": "Medio", + "highText": "Alto", + + "createStatusButtonTooltip": "Configuración de estado", + "configPhaseButtonTooltip": "Configuración de fase", "noLabelsFound": "No se encontraron etiquetas", "addStatusButton": "Agregar estado", @@ -49,8 +53,8 @@ "name": "Nombre", "category": "Categoría", "selectCategory": "Seleccionar una categoría", - "pleaseEnterAName": "Por favor, ingrese un nombre", - "pleaseSelectACategory": "Por favor, seleccione una categoría", + "pleaseEnterAName": "Por favor ingrese un nombre", + "pleaseSelectACategory": "Por favor seleccione una categoría", "create": "Crear", "searchTasks": "Buscar tareas...", @@ -65,17 +69,19 @@ "cancel": "Cancelar", "search": "Buscar", "groupedBy": "Agrupado por", - "manageStatuses": "Gestionar Estados", - "managePhases": "Gestionar Fases", - "dragToReorderStatuses": "Arrastra los estados para reordenarlos. Cada estado puede tener una categoría diferente.", - "enterNewStatusName": "Introducir nuevo nombre de estado...", - "addStatus": "Añadir Estado", - "noStatusesFound": "No se encontraron estados. Crea tu primer estado arriba.", - "deleteStatus": "Eliminar Estado", - "deleteStatusConfirm": "¿Estás seguro de que quieres eliminar este estado? Esta acción no se puede deshacer.", + "manageStatuses": "Gestionar estados", + "managePhases": "Gestionar fases", + "dragToReorderStatuses": "Los estados están organizados por categorías. Arrastra para reordenar dentro de las categorías. Haz clic en 'Agregar estado' para crear nuevos estados en cada categoría.", + "enterNewStatusName": "Ingrese el nombre del nuevo estado...", + "addStatus": "Agregar estado", + "noStatusesFound": "No hay estados en esta categoría", + "deleteStatus": "Eliminar estado", + "deleteStatusConfirm": "¿Está seguro de que desea eliminar este estado? Esta acción no se puede deshacer.", "rename": "Renombrar", "delete": "Eliminar", - "enterStatusName": "Introducir nombre del estado", - "selectCategory": "Seleccionar categoría", - "close": "Cerrar" + "enterStatusName": "Ingrese el nombre del estado", + "close": "Cerrar", + "cannotMoveStatus": "No se puede mover el estado", + "cannotMoveStatusMessage": "No se puede mover este estado porque dejaría vacía la categoría '{{categoryName}}'. Cada categoría debe tener al menos un estado.", + "ok": "OK" } diff --git a/worklenz-frontend/public/locales/pt/task-list-filters.json b/worklenz-frontend/public/locales/pt/task-list-filters.json index 21e8806b..49841ec5 100644 --- a/worklenz-frontend/public/locales/pt/task-list-filters.json +++ b/worklenz-frontend/public/locales/pt/task-list-filters.json @@ -6,8 +6,10 @@ "sortText": "Ordenar", "statusText": "Status", "phaseText": "Fase", + "memberText": "Membros", + "assigneesText": "Atribuídos", "priorityText": "Prioridade", - "labelsText": "Rótulos", + "labelsText": "Etiquetas", "membersText": "Membros", "groupByText": "Agrupar por", "showArchivedText": "Mostrar arquivados", @@ -16,41 +18,42 @@ "taskText": "Tarefa", "descriptionText": "Descrição", "phasesText": "Fases", + "listText": "Lista", "progressText": "Progresso", - "timeTrackingText": "Rastreamento de Tempo", + "timeTrackingText": "Rastreamento de tempo", + "timetrackingText": "Rastreamento de tempo", "estimationText": "Estimativa", - "startDateText": "Data de Início", - "endDateText": "Data de Fim", - "dueDateText": "Data de Vencimento", - "completedDateText": "Data de Conclusão", - "createdDateText": "Data de Criação", - "lastUpdatedText": "Última Atualização", + "startDateText": "Data de início", + "startdateText": "Data de início", + "endDateText": "Data de fim", + "dueDateText": "Data de vencimento", + "duedateText": "Data de vencimento", + "completedDateText": "Data de conclusão", + "completeddateText": "Data de conclusão", + "createdDateText": "Data de criação", + "createddateText": "Data de criação", + "lastUpdatedText": "Última atualização", + "lastupdatedText": "Última atualização", "reporterText": "Relator", - "dueTimeText": "Hora de Vencimento", - "assigneesText": "Atribuições", - "timetrackingText": "Rastreamento de Tempo", - "startdateText": "Data de Início", - "duedateText": "Data de Vencimento", - "completeddateText": "Data de Conclusão", - "createddateText": "Data de Criação", - "lastupdatedText": "Última Atualização", + "dueTimeText": "Hora de vencimento", + "duetimeText": "Hora de vencimento", "lowText": "Baixa", "mediumText": "Média", "highText": "Alta", - "createStatusButtonTooltip": "Configurações de Status", - "configPhaseButtonTooltip": "Configurações de Fase", - "noLabelsFound": "Nenhum rótulo encontrado", + "createStatusButtonTooltip": "Configurações de status", + "configPhaseButtonTooltip": "Configurações de fase", + "noLabelsFound": "Nenhuma etiqueta encontrada", - "addStatusButton": "Adicionar Status", - "addPhaseButton": "Adicionar Fase", + "addStatusButton": "Adicionar status", + "addPhaseButton": "Adicionar fase", - "createStatus": "Criar Status", + "createStatus": "Criar status", "name": "Nome", "category": "Categoria", "selectCategory": "Selecionar uma categoria", - "pleaseEnterAName": "Por favor, insira um nome", + "pleaseEnterAName": "Por favor, digite um nome", "pleaseSelectACategory": "Por favor, selecione uma categoria", "create": "Criar", @@ -66,17 +69,19 @@ "cancel": "Cancelar", "search": "Pesquisar", "groupedBy": "Agrupado por", - "manageStatuses": "Gerenciar Status", - "managePhases": "Gerenciar Fases", - "dragToReorderStatuses": "Arraste os status para reordená-los. Cada status pode ter uma categoria diferente.", - "enterNewStatusName": "Digite o novo nome do status...", - "addStatus": "Adicionar Status", - "noStatusesFound": "Nenhum status encontrado. Crie seu primeiro status acima.", - "deleteStatus": "Excluir Status", + "manageStatuses": "Gerenciar status", + "managePhases": "Gerenciar fases", + "dragToReorderStatuses": "Os status estão organizados por categorias. Arraste para reordenar dentro das categorias. Clique em 'Adicionar status' para criar novos status em cada categoria.", + "enterNewStatusName": "Digite o nome do novo status...", + "addStatus": "Adicionar status", + "noStatusesFound": "Nenhum status nesta categoria", + "deleteStatus": "Excluir status", "deleteStatusConfirm": "Tem certeza de que deseja excluir este status? Esta ação não pode ser desfeita.", "rename": "Renomear", "delete": "Excluir", "enterStatusName": "Digite o nome do status", - "selectCategory": "Selecionar categoria", - "close": "Fechar" + "close": "Fechar", + "cannotMoveStatus": "Não é possível mover o status", + "cannotMoveStatusMessage": "Não é possível mover este status porque deixaria a categoria '{{categoryName}}' vazia. Cada categoria deve ter pelo menos um status.", + "ok": "OK" } diff --git a/worklenz-frontend/public/locales/zh/task-list-filters.json b/worklenz-frontend/public/locales/zh/task-list-filters.json index 84387509..50dcb8e6 100644 --- a/worklenz-frontend/public/locales/zh/task-list-filters.json +++ b/worklenz-frontend/public/locales/zh/task-list-filters.json @@ -75,5 +75,8 @@ "delete": "删除", "enterStatusName": "输入状态名称", "selectCategory": "选择类别", - "close": "关闭" + "close": "关闭", + "cannotMoveStatus": "无法移动状态", + "cannotMoveStatusMessage": "无法移动此状态,因为这会使\"{{categoryName}}\"类别为空。每个类别必须至少有一个状态。", + "ok": "确定" } \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx b/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx index 054ecc88..6f6d85dc 100644 --- a/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx +++ b/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx @@ -1,9 +1,9 @@ import React, { useState, useCallback, useRef, useEffect } from 'react'; -import { Modal, Form, Input, Button, Space, Divider, Typography, Flex, Select } from 'antd'; -import { PlusOutlined, HolderOutlined } from '@ant-design/icons'; +import { Modal, Form, Input, Button, Space, Divider, Typography, Flex, Select, Tooltip } from 'antd'; +import { PlusOutlined, HolderOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; -import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; -import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors, DragOverEvent, useDroppable, closestCenter, DragOverlay } from '@dnd-kit/core'; +import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useAppSelector } from '@/hooks/useAppSelector'; @@ -11,9 +11,11 @@ import { useAppDispatch } from '@/hooks/useAppDispatch'; import { createStatus, fetchStatuses, fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types'; +import { IKanbanTaskStatus } from '@/types/tasks/taskStatus.types'; import { Modal as AntModal } from 'antd'; import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { fetchTaskGroups } from '@/features/tasks/tasks.slice'; import './ManageStatusModal.css'; const { Title, Text } = Typography; @@ -25,7 +27,7 @@ interface ManageStatusModalProps { } interface StatusItemProps { - status: any; + status: IKanbanTaskStatus; onRename: (id: string, name: string) => void; onDelete: (id: string) => void; onCategoryChange: (id: string, categoryId: string) => void; @@ -33,7 +35,22 @@ interface StatusItemProps { categories: any[]; } -// Sortable Status Item Component +interface CategorySectionProps { + category: any; + statuses: IKanbanTaskStatus[]; + onRename: (id: string, name: string) => void; + onDelete: (id: string) => void; + onCategoryChange: (id: string, categoryId: string) => void; + onCreateStatus: (categoryId: string, name: string) => void; + isDarkMode: boolean; + categories: any[]; + dragOverCategory: string | null; + activeId: string | null; + dragOverIndex: number | null; + localStatuses: IKanbanTaskStatus[]; +} + +// Sortable Status Item Component (compact with hover actions) const SortableStatusItem: React.FC = ({ id, status, @@ -43,8 +60,10 @@ const SortableStatusItem: React.FC = ({ isDarkMode, categories, }) => { + const { t } = useTranslation('task-list-filters'); const [isEditing, setIsEditing] = useState(false); const [editName, setEditName] = useState(status.name || ''); + const [isHovered, setIsHovered] = useState(false); const inputRef = useRef(null); const { @@ -82,6 +101,10 @@ const SortableStatusItem: React.FC = ({ } }, [handleSave, handleCancel]); + const handleClick = useCallback(() => { + setIsEditing(true); + }, []); + useEffect(() => { if (isEditing && inputRef.current) { inputRef.current.focus(); @@ -89,42 +112,43 @@ const SortableStatusItem: React.FC = ({ } }, [isEditing]); - return ( + return (
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} > - {/* Header Row - Drag Handle, Color, Name, Actions */} -
+
{/* Drag Handle */}
- +
{/* Status Color */}
{/* Status Name */} -
+
{isEditing ? ( = ({ onChange={(e) => setEditName(e.target.value)} onBlur={handleSave} onKeyDown={handleKeyDown} - className="font-medium" - placeholder="Enter status name" + className={`font-medium text-xs border-0 px-1 py-1 shadow-none ${ + isDarkMode + ? 'bg-transparent text-gray-200 placeholder-gray-400' + : 'bg-transparent text-gray-900 placeholder-gray-500' + }`} + placeholder={t('enterStatusName')} /> ) : ( setIsEditing(true)} + onClick={handleClick} + title={t('rename')} > {status.name} )}
- {/* Actions */} - - - - + {/* Hover Actions */} +
+ +
+
+
+ ); +}; + +// Category Section Component +const CategorySection: React.FC = ({ + category, + statuses, + onRename, + onDelete, + onCategoryChange, + onCreateStatus, + isDarkMode, + categories, + dragOverCategory, + activeId, + dragOverIndex, + localStatuses, +}) => { + const { t } = useTranslation('task-list-filters'); + const [newStatusName, setNewStatusName] = useState(''); + const [showAddForm, setShowAddForm] = useState(false); + + const { setNodeRef, isOver } = useDroppable({ + id: `category-${category.id}`, + data: { + type: 'category', + categoryId: category.id, + }, + }); + + const handleCreateStatus = useCallback(() => { + if (newStatusName.trim()) { + onCreateStatus(category.id, newStatusName.trim()); + setNewStatusName(''); + setShowAddForm(false); + } + }, [newStatusName, category.id, onCreateStatus]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleCreateStatus(); + } else if (e.key === 'Escape') { + setNewStatusName(''); + setShowAddForm(false); + } + }, [handleCreateStatus]); + + // Check if we should show cross-category drop placeholder + const shouldShowPlaceholder = dragOverCategory === category.id && activeId; + const draggedStatus = activeId ? localStatuses.find((s: IKanbanTaskStatus) => s.id === activeId) : null; + const isDraggedFromDifferentCategory = draggedStatus && (draggedStatus as IKanbanTaskStatus).category_id !== category.id; + + return ( +
+ {/* Category Header */} +
+
+
+ + {category.name} + + + {statuses.length} + +
+ + + +
- {/* Category Row */} -
- + {/* Status List */} + status.id).map(status => status.id as string)} + strategy={verticalListSortingStrategy} > - Category: - - +
+ {statuses.filter(status => status.id).map((status, index) => ( + + {/* Drop Placeholder - show at specific position for cross-category drops */} + {shouldShowPlaceholder && isDraggedFromDifferentCategory && dragOverIndex !== null && dragOverIndex === index && ( +
+
+
+ + Drop here to move to {category.name} + +
+
+ )} + + + + ))} + + {/* Drop Placeholder at the end for cross-category drops */} + {shouldShowPlaceholder && isDraggedFromDifferentCategory && dragOverIndex !== null && dragOverIndex >= statuses.length && ( +
+
+
+ + Drop here to move to {category.name} + +
+
+ )} +
+ + + {/* Add Status Form */} + {showAddForm && ( +
+
+ setNewStatusName(e.target.value)} + onKeyDown={handleKeyDown} + className={`flex-1 ${ + isDarkMode + ? 'bg-gray-600 border-gray-500 text-gray-100 placeholder-gray-400' + : 'bg-white border-gray-300 text-gray-900 placeholder-gray-500' + }`} + size="small" + autoFocus + /> + + +
+
+ )} + + {statuses.length === 0 && !showAddForm && ( +
+ + {t('noStatusesFound')} + +
+ +
+ )}
); @@ -210,9 +459,11 @@ const ManageStatusModal: React.FC = ({ const currentProjectId = useAppSelector(state => state.projectReducer.projectId); const { status: statuses } = useAppSelector(state => state.taskStatusReducer); - const [localStatuses, setLocalStatuses] = useState(statuses); - const [newStatusName, setNewStatusName] = useState(''); + const [localStatuses, setLocalStatuses] = useState(statuses); const [statusCategories, setStatusCategories] = useState([]); + const [activeId, setActiveId] = useState(null); + const [dragOverCategory, setDragOverCategory] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); const finalProjectId = projectId || currentProjectId; @@ -243,28 +494,249 @@ const ManageStatusModal: React.FC = ({ } }, [open, finalProjectId, dispatch]); + // Group statuses by category + const statusesByCategory = statusCategories.map(category => ({ + ...category, + statuses: localStatuses.filter(status => (status as IKanbanTaskStatus).category_id === category.id) + })); + + const handleCategoryChange = useCallback(async (id: string, categoryId: string, insertIndex?: number) => { + if (!finalProjectId) return; + + // Find the status being moved and its current category + const statusToMove = localStatuses.find(s => s.id === id) as IKanbanTaskStatus; + if (!statusToMove) return; + + const currentCategoryId = statusToMove.category_id; + + // Check if moving this status would leave the source category with less than 1 status + const statusesInCurrentCategory = localStatuses.filter(s => + (s as IKanbanTaskStatus).category_id === currentCategoryId + ); + + if (statusesInCurrentCategory.length <= 1) { + // Find the category name for the error message + const currentCategory = statusCategories.find(c => c.id === currentCategoryId); + const categoryName = currentCategory?.name || 'category'; + + AntModal.error({ + title: t('cannotMoveStatus'), + content: t('cannotMoveStatusMessage', { categoryName }), + okText: t('ok'), + }); + return; + } + + try { + // Update local state optimistically first + setLocalStatuses(prevStatuses => { + const updatedStatuses = prevStatuses.map(status => { + if (status.id === id) { + return { ...status, category_id: categoryId } as IKanbanTaskStatus; + } + return status; + }); + return updatedStatuses; + }); + + await statusApiService.updateStatusCategory(id, categoryId, finalProjectId); + + // If we have an insert index, we need to update the order as well + if (insertIndex !== undefined) { + // Create a complete new order for ALL statuses in the project + const updatedStatuses = localStatuses.map(status => { + if (status.id === id) { + return { ...status, category_id: categoryId } as IKanbanTaskStatus; + } + return status; + }); + + // Group statuses by category with the updated category assignment + const statusesByUpdatedCategory = statusCategories.map(category => ({ + ...category, + statuses: updatedStatuses.filter(status => (status as IKanbanTaskStatus).category_id === category.id) + })); + + // Find the target category and insert the moved status at the correct position + const targetCategoryIndex = statusesByUpdatedCategory.findIndex(cat => cat.id === categoryId); + if (targetCategoryIndex !== -1) { + const targetCategory = statusesByUpdatedCategory[targetCategoryIndex]; + const movedStatus = updatedStatuses.find((s: IKanbanTaskStatus) => s.id === id); + const otherStatuses = targetCategory.statuses.filter((s: IKanbanTaskStatus) => s.id !== id); + + // Insert at the specified index + const newCategoryOrder = [...otherStatuses]; + if (movedStatus) { + newCategoryOrder.splice(insertIndex, 0, movedStatus); + } + + // Update the category with the new order + statusesByUpdatedCategory[targetCategoryIndex] = { + ...targetCategory, + statuses: newCategoryOrder + }; + } + + // Create the final global order: flatten all categories in their display order + const globalOrder: string[] = []; + statusesByUpdatedCategory.forEach(category => { + category.statuses.forEach((status: IKanbanTaskStatus) => { + if (status.id) { + globalOrder.push(status.id); + } + }); + }); + + const requestBody = { status_order: globalOrder }; + await statusApiService.updateStatusOrder(requestBody, finalProjectId); + } + + // Refresh from server to ensure consistency + dispatch(fetchStatuses(finalProjectId)); + dispatch(fetchTasksV3(finalProjectId)); + dispatch(fetchTaskGroups(finalProjectId)); + dispatch(fetchEnhancedKanbanGroups(finalProjectId)); + } catch (error) { + console.error('Error changing status category:', error); + // Revert optimistic update on error + dispatch(fetchStatuses(finalProjectId)); + } + }, [finalProjectId, dispatch, localStatuses, statusCategories, t]); + + const handleDragStart = useCallback((event: any) => { + setActiveId(event.active.id); + }, []); + + const handleDragOver = useCallback((event: DragOverEvent) => { + const { over, active } = event; + + if (!over || !active) { + setDragOverCategory(null); + setDragOverIndex(null); + return; + } + + const overId = over.id.toString(); + const activeId = active.id.toString(); + + const draggedStatus = localStatuses.find(s => s.id === activeId) as IKanbanTaskStatus | undefined; + if (!draggedStatus) { + setDragOverCategory(null); + setDragOverIndex(null); + return; + } + + // Check if we're dragging over a category area + if (overId.startsWith('category-')) { + const categoryId = overId.replace('category-', ''); + + // Only show placeholder for cross-category drops + if (draggedStatus.category_id !== categoryId) { + setDragOverCategory(categoryId); + // Default to end of category for category drops + const targetCategory = statusesByCategory.find(c => c.id === categoryId); + setDragOverIndex(targetCategory?.statuses.length || 0); + } else { + setDragOverCategory(null); + setDragOverIndex(null); + } + return; + } + + // Check if we're dragging over a status item + const targetStatus = localStatuses.find(s => s.id === overId) as IKanbanTaskStatus | undefined; + if (!targetStatus || !targetStatus.category_id) { + setDragOverCategory(null); + setDragOverIndex(null); + return; + } + + // Only show placeholder for cross-category drops + if (draggedStatus.category_id !== targetStatus.category_id) { + setDragOverCategory(targetStatus.category_id); + + // Find the exact index of the target status in its category + const targetCategory = statusesByCategory.find(c => c.id === targetStatus.category_id); + if (targetCategory) { + const targetIndex = targetCategory.statuses.findIndex((s: IKanbanTaskStatus) => s.id === overId); + setDragOverIndex(targetIndex >= 0 ? targetIndex : 0); + } else { + setDragOverIndex(0); + } + } else { + // Same category - no placeholder needed (sortable handles it) + setDragOverCategory(null); + setDragOverIndex(null); + } + }, [statusesByCategory, localStatuses]); + const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event; - if (!over || active.id === over.id || !finalProjectId) { + setActiveId(null); + setDragOverCategory(null); + setDragOverIndex(null); + + if (!over || !finalProjectId) { + return; + } + + const draggedStatusId = active.id as string; + const overId = over.id as string; + + const draggedStatus = localStatuses.find(s => s.id === draggedStatusId) as IKanbanTaskStatus | undefined; + if (!draggedStatus) return; + + // Check if we're dropping on a category (cross-category move) + if (overId.startsWith('category-')) { + const newCategoryId = overId.replace('category-', ''); + + // Only change category if it's different + if (draggedStatus.category_id !== newCategoryId) { + handleCategoryChange(draggedStatusId, newCategoryId); + } + return; + } + + // Handle dropping on a status item + const targetStatus = localStatuses.find(s => s.id === overId) as IKanbanTaskStatus | undefined; + if (!targetStatus || !targetStatus.category_id) return; + + // Check if this is a cross-category move + if (draggedStatus.category_id !== targetStatus.category_id) { + // Cross-category move - move to target category at target position + const targetCategoryId = targetStatus.category_id; + const targetCategoryStatuses = statusesByCategory.find(c => c.id === targetCategoryId)?.statuses || []; + const targetIndex = targetCategoryStatuses.findIndex((s: IKanbanTaskStatus) => s.id === overId); + + handleCategoryChange(draggedStatusId, targetCategoryId, targetIndex); + return; + } + + // Same category reordering + if (draggedStatusId === overId) { return; } setLocalStatuses((items) => { - const oldIndex = items.findIndex((item) => item.id === active.id); - const newIndex = items.findIndex((item) => item.id === over.id); + const oldIndex = items.findIndex((item) => item.id === draggedStatusId); + const newIndex = items.findIndex((item) => item.id === overId); - if (oldIndex === -1 || newIndex === -1) return items; + if (oldIndex === -1 || newIndex === -1) { + return items; + } - const newItems = [...items]; - const [movedItem] = newItems.splice(oldIndex, 1); - newItems.splice(newIndex, 0, movedItem); + // Use arrayMove for proper reordering + const newItems = arrayMove(items, oldIndex, newIndex); - // Update status order via API (fire and forget) - const columnOrder = newItems.map(item => item.id).filter(Boolean) as string[]; - const requestBody = { status_order: columnOrder }; + // Update status order via API - send ALL statuses in global order + const globalOrder = newItems.map(item => item.id).filter(Boolean) as string[]; + const requestBody = { status_order: globalOrder }; + statusApiService.updateStatusOrder(requestBody, finalProjectId).then(() => { - // Refresh enhanced kanban after status order change + // Refresh task lists after status order change + dispatch(fetchTasksV3(finalProjectId)); + dispatch(fetchTaskGroups(finalProjectId)); dispatch(fetchEnhancedKanbanGroups(finalProjectId)); }).catch(error => { console.error('Error updating status order:', error); @@ -272,193 +744,202 @@ const ManageStatusModal: React.FC = ({ return newItems; }); - }, [finalProjectId]); + }, [finalProjectId, dispatch, handleCategoryChange, localStatuses, statusesByCategory]); - const handleCreateStatus = useCallback(async () => { - if (!newStatusName.trim() || !finalProjectId) return; + const handleCreateStatus = useCallback(async (categoryId: string, name: string) => { + if (!name.trim() || !finalProjectId) return; try { - const statusCategories = await dispatch(fetchStatusesCategories()).unwrap(); - const defaultCategory = statusCategories[0]?.id; - - if (!defaultCategory) { - console.error('No status categories found'); - return; - } + // Find the highest order_index in the same category to add to the bottom + const categoryStatuses = localStatuses.filter(status => (status as IKanbanTaskStatus).category_id === categoryId); + const maxOrderIndex = categoryStatuses.length > 0 + ? Math.max(...categoryStatuses.map(s => s.order_index || 0)) + : 0; const body = { - name: newStatusName.trim(), - category_id: defaultCategory, + name: name.trim(), + category_id: categoryId, project_id: finalProjectId, + order_index: maxOrderIndex + 1, }; const res = await dispatch(createStatus({ body, currentProjectId: finalProjectId })).unwrap(); if (res.done) { - setNewStatusName(''); dispatch(fetchStatuses(finalProjectId)); dispatch(fetchTasksV3(finalProjectId)); + dispatch(fetchTaskGroups(finalProjectId)); dispatch(fetchEnhancedKanbanGroups(finalProjectId)); } } catch (error) { console.error('Error creating status:', error); } - }, [newStatusName, finalProjectId, dispatch]); + }, [finalProjectId, dispatch, localStatuses]); const handleRenameStatus = useCallback(async (id: string, name: string) => { - if (!finalProjectId) return; + if (!finalProjectId || !name.trim()) return; try { + // Find the current status to get its category_id (required by backend validator) + const currentStatus = localStatuses.find(s => s.id === id) as IKanbanTaskStatus; + const body: ITaskStatusUpdateModel = { name: name.trim(), project_id: finalProjectId, + category_id: currentStatus?.category_id || '', // Required by backend validator }; await statusApiService.updateNameOfStatus(id, body, finalProjectId); dispatch(fetchStatuses(finalProjectId)); dispatch(fetchTasksV3(finalProjectId)); + dispatch(fetchTaskGroups(finalProjectId)); dispatch(fetchEnhancedKanbanGroups(finalProjectId)); } catch (error) { console.error('Error renaming status:', error); } - }, [finalProjectId, dispatch]); + }, [finalProjectId, dispatch, localStatuses]); const handleDeleteStatus = useCallback(async (id: string) => { if (!finalProjectId) return; AntModal.confirm({ - title: 'Delete Status', - content: 'Are you sure you want to delete this status? This action cannot be undone.', + title: t('deleteStatus'), + content: t('deleteStatusConfirm'), + okText: t('delete'), + cancelText: t('cancel'), + okButtonProps: { danger: true }, onOk: async () => { try { const replacingStatusId = localStatuses.find(s => s.id !== id)?.id || ''; await statusApiService.deleteStatus(id, finalProjectId, replacingStatusId); dispatch(fetchStatuses(finalProjectId)); dispatch(fetchTasksV3(finalProjectId)); + dispatch(fetchTaskGroups(finalProjectId)); dispatch(fetchEnhancedKanbanGroups(finalProjectId)); } catch (error) { console.error('Error deleting status:', error); } }, }); - }, [localStatuses, finalProjectId, dispatch]); - - const handleCategoryChange = useCallback(async (id: string, categoryId: string) => { - if (!finalProjectId) return; - - try { - const body: ITaskStatusUpdateModel = { - category_id: categoryId, - project_id: finalProjectId, - }; - - await statusApiService.updateNameOfStatus(id, body, finalProjectId); - dispatch(fetchStatuses(finalProjectId)); - dispatch(fetchTasksV3(finalProjectId)); - dispatch(fetchEnhancedKanbanGroups(finalProjectId)); - } catch (error) { - console.error('Error changing status category:', error); - } - }, [finalProjectId, dispatch]); + }, [localStatuses, finalProjectId, dispatch, t]); const handleClose = useCallback(() => { - setNewStatusName(''); onClose(); }, [onClose]); return ( + {t('manageStatuses')} } open={open} onCancel={handleClose} - width={600} + width={720} style={{ top: 20 }} - bodyStyle={{ - maxHeight: 'calc(100vh - 200px)', - overflowY: 'auto', - padding: '24px', + styles={{ + body: { + maxHeight: 'calc(100vh - 200px)', + overflowY: 'auto', + padding: '16px', + }, }} footer={ - - - - } - className={isDarkMode ? 'dark-modal' : ''} - > -
-
- - 💡 Drag statuses to reorder them. Each status can have a different category. + {t('close')} + +
+ } + className={`${isDarkMode ? 'dark-modal' : ''} status-manage-modal`} + > +
+ {/* Info Banner */} +
+ + 💡 Drag statuses to reorder within categories or drag between categories to change their type. + +
+ + ⚠️ Note: Each category must have at least one status. You cannot move a status if it's the only one in its category.
- {/* Create New Status */} -
-
- setNewStatusName(e.target.value)} - onPressEnter={handleCreateStatus} - className="flex-1" - size="small" - /> - + {/* Category Sections with Drag & Drop */} + +
+ {statusesByCategory.map((category) => ( + + ))}
-
- - - - {/* Status List with Drag & Drop */} - - status.id).map(status => status.id as string)} - strategy={verticalListSortingStrategy} - > -
- {localStatuses.filter(status => status.id).map((status) => ( - - ))} -
-
+ + + {activeId ? ( +
+
+ +
+ + {localStatuses.find(s => s.id === activeId)?.name || 'Status'} + +
+
+ ) : null} + - {localStatuses.length === 0 && ( -
- No statuses found. Create your first status above. + {statusCategories.length === 0 && ( +
+ + {t('noStatusesFound')} +
)}
From cc0ff20ca162b5b7f8c04ff89fe3171a4a3dae46 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 14:04:02 +0530 Subject: [PATCH 34/49] feat(localization): update translation keys for phase management - Revised translation keys in Albanian, German, English, Spanish, Portuguese, and Chinese for improved clarity and consistency in phase management. - Adjusted phrases related to phase naming, options, and actions to enhance user experience across multiple languages. - Added new keys for creating and canceling phases to align with recent UI updates in the ManagePhaseModal component. --- .../public/locales/alb/phases-drawer.json | 9 +- .../public/locales/de/phases-drawer.json | 9 +- .../public/locales/en/phases-drawer.json | 9 +- .../public/locales/es/phases-drawer.json | 9 +- .../public/locales/pt/phases-drawer.json | 9 +- .../public/locales/zh/phases-drawer.json | 9 +- .../task-management/ManagePhaseModal.tsx | 377 +++++++++++------- 7 files changed, 272 insertions(+), 159 deletions(-) diff --git a/worklenz-frontend/public/locales/alb/phases-drawer.json b/worklenz-frontend/public/locales/alb/phases-drawer.json index cccda7d2..23816727 100644 --- a/worklenz-frontend/public/locales/alb/phases-drawer.json +++ b/worklenz-frontend/public/locales/alb/phases-drawer.json @@ -1,18 +1,19 @@ { "configurePhases": "Konfiguro Fazat", "phaseLabel": "Etiketa e Fazës", - "enterPhaseName": "Vendosni një emër për etiketën e fazës", + "enterPhaseName": "Shkruani emrin e fazës", "addOption": "Shto Opsion", - "phaseOptions": "Opsionet e Fazës:", + "phaseOptions": "Opsionet e Fazës", "dragToReorderPhases": "Zvarrit fazat për t'i rirenditur. Çdo fazë mund të ketë një ngjyrë të ndryshme.", "enterNewPhaseName": "Shkruani emrin e fazës së re...", "addPhase": "Shto Fazë", - "noPhasesFound": "Nuk u gjetën faza. Krijoni fazën tuaj të parë më sipër.", + "noPhasesFound": "Nuk u gjetën faza", "deletePhase": "Fshi Fazën", "deletePhaseConfirm": "Jeni të sigurt që doni të fshini këtë fazë? Ky veprim nuk mund të zhbëhet.", "rename": "Riemëro", "delete": "Fshi", - "enterPhaseName": "Shkruani emrin e fazës", + "create": "Krijo", + "cancel": "Anulo", "selectColor": "Zgjidh ngjyrën", "managePhases": "Menaxho Fazat", "close": "Mbyll" diff --git a/worklenz-frontend/public/locales/de/phases-drawer.json b/worklenz-frontend/public/locales/de/phases-drawer.json index c9e41e09..4a143c7f 100644 --- a/worklenz-frontend/public/locales/de/phases-drawer.json +++ b/worklenz-frontend/public/locales/de/phases-drawer.json @@ -1,18 +1,19 @@ { "configurePhases": "Phasen konfigurieren", "phaseLabel": "Phasenbezeichnung", - "enterPhaseName": "Namen für Phasenbezeichnung eingeben", + "enterPhaseName": "Phasennamen eingeben", "addOption": "Option hinzufügen", - "phaseOptions": "Phasenoptionen:", + "phaseOptions": "Phasenoptionen", "dragToReorderPhases": "Ziehen Sie Phasen, um sie neu zu ordnen. Jede Phase kann eine andere Farbe haben.", "enterNewPhaseName": "Neuen Phasennamen eingeben...", "addPhase": "Phase hinzufügen", - "noPhasesFound": "Keine Phasen gefunden. Erstellen Sie Ihre erste Phase oben.", + "noPhasesFound": "Keine Phasen gefunden", "deletePhase": "Phase löschen", "deletePhaseConfirm": "Sind Sie sicher, dass Sie diese Phase löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", "rename": "Umbenennen", "delete": "Löschen", - "enterPhaseName": "Phasennamen eingeben", + "create": "Erstellen", + "cancel": "Abbrechen", "selectColor": "Farbe auswählen", "managePhases": "Phasen verwalten", "close": "Schließen" diff --git a/worklenz-frontend/public/locales/en/phases-drawer.json b/worklenz-frontend/public/locales/en/phases-drawer.json index 10ad78a4..7791a08b 100644 --- a/worklenz-frontend/public/locales/en/phases-drawer.json +++ b/worklenz-frontend/public/locales/en/phases-drawer.json @@ -1,18 +1,19 @@ { "configurePhases": "Configure Phases", "phaseLabel": "Phase Label", - "enterPhaseName": "Enter a name for phase label", + "enterPhaseName": "Enter phase name", "addOption": "Add Option", - "phaseOptions": "Phase Options:", + "phaseOptions": "Phase Options", "dragToReorderPhases": "Drag phases to reorder them. Each phase can have a different color.", "enterNewPhaseName": "Enter new phase name...", "addPhase": "Add Phase", - "noPhasesFound": "No phases found. Create your first phase above.", + "noPhasesFound": "No phases found", "deletePhase": "Delete Phase", "deletePhaseConfirm": "Are you sure you want to delete this phase? This action cannot be undone.", "rename": "Rename", "delete": "Delete", - "enterPhaseName": "Enter phase name", + "create": "Create", + "cancel": "Cancel", "selectColor": "Select color", "managePhases": "Manage Phases", "close": "Close" diff --git a/worklenz-frontend/public/locales/es/phases-drawer.json b/worklenz-frontend/public/locales/es/phases-drawer.json index e961b068..abb6ee81 100644 --- a/worklenz-frontend/public/locales/es/phases-drawer.json +++ b/worklenz-frontend/public/locales/es/phases-drawer.json @@ -1,18 +1,19 @@ { "configurePhases": "Configurar fases", "phaseLabel": "Etiqueta de fase", - "enterPhaseName": "Ingrese un nombre para la etiqueta de fase", + "enterPhaseName": "Introducir nombre de la fase", "addOption": "Agregar opción", - "phaseOptions": "Opciones de fase:", + "phaseOptions": "Opciones de fase", "dragToReorderPhases": "Arrastra las fases para reordenarlas. Cada fase puede tener un color diferente.", "enterNewPhaseName": "Introducir nuevo nombre de fase...", "addPhase": "Añadir Fase", - "noPhasesFound": "No se encontraron fases. Crea tu primera fase arriba.", + "noPhasesFound": "No se encontraron fases", "deletePhase": "Eliminar Fase", "deletePhaseConfirm": "¿Estás seguro de que quieres eliminar esta fase? Esta acción no se puede deshacer.", "rename": "Renombrar", "delete": "Eliminar", - "enterPhaseName": "Introducir nombre de la fase", + "create": "Crear", + "cancel": "Cancelar", "selectColor": "Seleccionar color", "managePhases": "Gestionar Fases", "close": "Cerrar" diff --git a/worklenz-frontend/public/locales/pt/phases-drawer.json b/worklenz-frontend/public/locales/pt/phases-drawer.json index 080b13df..b0ca7c51 100644 --- a/worklenz-frontend/public/locales/pt/phases-drawer.json +++ b/worklenz-frontend/public/locales/pt/phases-drawer.json @@ -1,18 +1,19 @@ { "configurePhases": "Configurar fases", "phaseLabel": "Etiqueta de fase", - "enterPhaseName": "Digite um nome para o rótulo da fase", + "enterPhaseName": "Digite o nome da fase", "addOption": "Adicionar Opção", - "phaseOptions": "Opções de Fase:", + "phaseOptions": "Opções de Fase", "dragToReorderPhases": "Arraste as fases para reordená-las. Cada fase pode ter uma cor diferente.", "enterNewPhaseName": "Digite o novo nome da fase...", "addPhase": "Adicionar Fase", - "noPhasesFound": "Nenhuma fase encontrada. Crie sua primeira fase acima.", + "noPhasesFound": "Nenhuma fase encontrada", "deletePhase": "Excluir Fase", "deletePhaseConfirm": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.", "rename": "Renomear", "delete": "Excluir", - "enterPhaseName": "Digite o nome da fase", + "create": "Criar", + "cancel": "Cancelar", "selectColor": "Selecionar cor", "managePhases": "Gerenciar Fases", "close": "Fechar" diff --git a/worklenz-frontend/public/locales/zh/phases-drawer.json b/worklenz-frontend/public/locales/zh/phases-drawer.json index 24d21b38..8f55e527 100644 --- a/worklenz-frontend/public/locales/zh/phases-drawer.json +++ b/worklenz-frontend/public/locales/zh/phases-drawer.json @@ -1,18 +1,19 @@ { "configurePhases": "配置阶段", "phaseLabel": "阶段标签", - "enterPhaseName": "输入阶段标签名称", + "enterPhaseName": "输入阶段名称", "addOption": "添加选项", - "phaseOptions": "阶段选项:", + "phaseOptions": "阶段选项", "dragToReorderPhases": "拖拽阶段以重新排序。每个阶段可以有不同的颜色。", "enterNewPhaseName": "输入新阶段名称...", "addPhase": "添加阶段", - "noPhasesFound": "未找到阶段。请在上面创建您的第一个阶段。", + "noPhasesFound": "未找到阶段", "deletePhase": "删除阶段", "deletePhaseConfirm": "您确定要删除此阶段吗?此操作无法撤销。", "rename": "重命名", "delete": "删除", - "enterPhaseName": "输入阶段名称", + "create": "创建", + "cancel": "取消", "selectColor": "选择颜色", "managePhases": "管理阶段", "close": "关闭" diff --git a/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx b/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx index 0e10d437..81a44378 100644 --- a/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx +++ b/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useRef, useEffect } from 'react'; -import { Modal, Form, Input, Button, Space, Divider, Typography, Flex, ColorPicker } from 'antd'; -import { PlusOutlined, HolderOutlined } from '@ant-design/icons'; +import { Modal, Form, Input, Button, Space, Divider, Typography, Flex, ColorPicker, Tooltip } from 'antd'; +import { PlusOutlined, HolderOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; @@ -41,7 +41,7 @@ interface PhaseItemProps { isDarkMode: boolean; } -// Sortable Phase Item Component +// Sortable Phase Item Component (compact with hover actions) const SortablePhaseItem: React.FC = ({ id, phase, @@ -50,9 +50,11 @@ const SortablePhaseItem: React.FC = ({ onColorChange, isDarkMode, }) => { + const { t } = useTranslation('phases-drawer'); const [isEditing, setIsEditing] = useState(false); const [editName, setEditName] = useState(phase.name || ''); const [color, setColor] = useState(phase.color_code || PhaseColorCodes[0]); + const [isHovered, setIsHovered] = useState(false); const inputRef = useRef(null); const { @@ -90,6 +92,10 @@ const SortablePhaseItem: React.FC = ({ } }, [handleSave, handleCancel]); + const handleClick = useCallback(() => { + setIsEditing(true); + }, []); + const handleColorChangeComplete = useCallback(() => { if (color !== phase.color_code) { onColorChange(id, color); @@ -107,33 +113,52 @@ const SortablePhaseItem: React.FC = ({ setColor(phase.color_code || PhaseColorCodes[0]); }, [phase.color_code]); - return ( + return (
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} > - {/* Header Row - Drag Handle, Phase Name, Actions */} -
+
{/* Drag Handle */}
- + +
+ + {/* Phase Color */} +
+ setColor(value.toHexString())} + onChangeComplete={handleColorChangeComplete} + size="small" + className="phase-color-picker" + /> +
{/* Phase Name */} -
+
{isEditing ? ( = ({ onChange={(e) => setEditName(e.target.value)} onBlur={handleSave} onKeyDown={handleKeyDown} - className="font-medium" - placeholder="Enter phase name" + className={`font-medium text-xs border-0 px-1 py-1 shadow-none ${ + isDarkMode + ? 'bg-transparent text-gray-200 placeholder-gray-400' + : 'bg-transparent text-gray-900 placeholder-gray-500' + }`} + placeholder={t('enterPhaseName')} /> ) : ( setIsEditing(true)} + onClick={handleClick} + title={t('rename')} > {phase.name} )}
- {/* Actions */} - - - - -
- - {/* Color Row */} -
- - Color: - - setColor(value.toHexString())} - onChangeComplete={handleColorChangeComplete} - size="small" - className="phase-color-picker" - /> -
+ {/* Hover Actions */} +
+ +
); @@ -224,6 +240,8 @@ const ManagePhaseModal: React.FC = ({ const [initialPhaseName, setInitialPhaseName] = useState(project?.phase_label || ''); const [sorting, setSorting] = useState(false); const [isSaving, setIsSaving] = useState(false); + const [newPhaseName, setNewPhaseName] = useState(''); + const [showAddForm, setShowAddForm] = useState(false); const finalProjectId = projectId || currentProjectId; @@ -285,17 +303,28 @@ const ManagePhaseModal: React.FC = ({ } }, [finalProjectId, phaseList, dispatch, refreshTasks]); - const handleAddPhase = useCallback(async () => { - if (!finalProjectId) return; + const handleCreatePhase = useCallback(async () => { + if (!newPhaseName.trim() || !finalProjectId) return; try { await dispatch(addPhaseOption({ projectId: finalProjectId })); await dispatch(fetchPhasesByProjectId(finalProjectId)); await refreshTasks(); + setNewPhaseName(''); + setShowAddForm(false); } catch (error) { console.error('Error adding phase:', error); } - }, [finalProjectId, dispatch, refreshTasks]); + }, [finalProjectId, dispatch, refreshTasks, newPhaseName]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleCreatePhase(); + } else if (e.key === 'Escape') { + setNewPhaseName(''); + setShowAddForm(false); + } + }, [handleCreatePhase]); const handleRenamePhase = useCallback(async (id: string, name: string) => { if (!finalProjectId) return; @@ -326,8 +355,11 @@ const ManagePhaseModal: React.FC = ({ if (!finalProjectId) return; AntModal.confirm({ - title: 'Delete Phase', - content: 'Are you sure you want to delete this phase? This action cannot be undone.', + title: t('deletePhase'), + content: t('deletePhaseConfirm'), + okText: t('delete'), + cancelText: t('cancel'), + okButtonProps: { danger: true }, onOk: async () => { try { const response = await dispatch( @@ -343,7 +375,7 @@ const ManagePhaseModal: React.FC = ({ } }, }); - }, [finalProjectId, dispatch, refreshTasks]); + }, [finalProjectId, dispatch, refreshTasks, t]); const handleColorChange = useCallback(async (id: string, color: string) => { if (!finalProjectId) return; @@ -393,39 +425,52 @@ const ManagePhaseModal: React.FC = ({ return ( + {t('configurePhases')} } open={open} onCancel={handleClose} - width={600} + width={720} style={{ top: 20 }} - bodyStyle={{ - maxHeight: 'calc(100vh - 200px)', - overflowY: 'auto', - padding: '24px', + styles={{ + body: { + maxHeight: 'calc(100vh - 200px)', + overflowY: 'auto', + padding: '16px', + }, }} footer={ - - - +
} - className={isDarkMode ? 'dark-modal' : ''} + className={`${isDarkMode ? 'dark-modal' : ''} phase-manage-modal`} loading={loadingPhases || sorting} >
{/* Phase Label Configuration */} -
{t('phaseLabel')} @@ -441,76 +486,138 @@ const ManagePhaseModal: React.FC = ({
- - - {/* Phase Options */} -
-
+ - - 🎨 Drag phases to reorder them. Each phase can have a custom color. - -
+ 🎨 Drag phases to reorder them. Click on a phase name to rename it. Each phase can have a custom color. + +
- {/* Add New Phase */} -
+
+ setNewPhaseName(e.target.value)} + onKeyDown={handleKeyDown} + className={`flex-1 ${ + isDarkMode + ? 'bg-gray-600 border-gray-500 text-gray-100 placeholder-gray-400' + : 'bg-white border-gray-300 text-gray-900 placeholder-gray-500' + }`} + size="small" + autoFocus + /> + + +
+
+ )} + + {/* Add Phase Button */} + {!showAddForm && ( +
-
- + - {t('phaseOptions')}: + {t('phaseOptions')}
+ )} - {/* Phase List with Drag & Drop */} - - phase.id)} - strategy={verticalListSortingStrategy} - > -
- {phaseList.map((phase) => ( - - ))} -
-
-
- - {phaseList.length === 0 && ( -
- No phases found. Add your first phase above. + {/* Phase List with Drag & Drop */} + + phase.id)} + strategy={verticalListSortingStrategy} + > +
+ {phaseList.map((phase) => ( + + ))}
- )} -
+ + + + {phaseList.length === 0 && ( +
+ + {t('noPhasesFound')} + +
+ +
+ )}
); From ea37b550788d875e823de348ac40315dc33896b9 Mon Sep 17 00:00:00 2001 From: shancds Date: Fri, 11 Jul 2025 14:06:11 +0530 Subject: [PATCH 35/49] fix(kanban-group): update section name handling in status update - Removed unused section name generation and replaced it with trimmed name input for better consistency. - Ensured that the updated name is set correctly after a successful status update, improving data integrity. --- .../EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx index 387efad1..705c467a 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx @@ -108,9 +108,9 @@ const KanbanGroup: React.FC = memo(({ const updateStatus = async (category = group.category_id ?? null) => { if (!category || !projectId || !group.id) return; - const sectionName = getUniqueSectionName(name); + // const sectionName = getUniqueSectionName(name); const body: ITaskStatusUpdateModel = { - name: sectionName, + name: name.trim(), project_id: projectId, category_id: category, }; @@ -118,7 +118,7 @@ const KanbanGroup: React.FC = memo(({ if (res.done) { dispatch(fetchEnhancedKanbanGroups(projectId)); dispatch(fetchStatuses(projectId)); - setName(sectionName); + setName(name.trim()); } else { setName(editName); logger.error('Error updating status', res.message); From 0efcbf448b4f68e1606bb500a935d3c2caeca922 Mon Sep 17 00:00:00 2001 From: shancds Date: Fri, 11 Jul 2025 14:34:49 +0530 Subject: [PATCH 36/49] fix(task-card): improve loading state visual feedback - Replaced loading text with a skeleton loader for subtasks in TaskCard component, enhancing user experience during data fetching. --- .../enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx index fb03fb0b..236d9245 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx @@ -437,7 +437,7 @@ const TaskCard: React.FC = memo(({
{/* Loading state */} {task.sub_tasks_loading && ( -
Loading...
+
)} {/* Loaded subtasks */} {!task.sub_tasks_loading && Array.isArray(task.sub_tasks) && task.sub_tasks.length > 0 && ( From e4dfae9f1d324c8b8c5c2fab3b451ef4ca6fa5be Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 14:46:07 +0530 Subject: [PATCH 37/49] feat(database): optimize task sorting functions and introduce bulk update capability - Added new SQL migration to fix window function errors in task sorting functions, replacing CTEs with direct updates for better performance. - Introduced a bulk update function for task sort orders, allowing multiple updates in a single call to improve efficiency. - Updated socket command to support bulk updates, enhancing the task sorting experience in the frontend. - Simplified task update handling in the frontend to utilize the new bulk update feature, improving overall performance and user experience. --- ...250128000000-fix-window-function-error.sql | 143 +++++++++++++++++ worklenz-backend/database/sql/4_functions.sql | 149 +++++++++++------- .../commands/on-task-sort-order-change.ts | 106 +++++++++---- .../task-management/ManageStatusModal.tsx | 6 - .../task-group-wrapper/task-group-wrapper.tsx | 64 ++++++++ .../task-list-table-wrapper.tsx | 24 +-- 6 files changed, 385 insertions(+), 107 deletions(-) create mode 100644 worklenz-backend/database/migrations/20250128000000-fix-window-function-error.sql diff --git a/worklenz-backend/database/migrations/20250128000000-fix-window-function-error.sql b/worklenz-backend/database/migrations/20250128000000-fix-window-function-error.sql new file mode 100644 index 00000000..9a20e173 --- /dev/null +++ b/worklenz-backend/database/migrations/20250128000000-fix-window-function-error.sql @@ -0,0 +1,143 @@ +-- Fix window function error in task sort optimized functions +-- Error: window functions are not allowed in UPDATE + +-- Replace the optimized sort functions to avoid CTE usage in UPDATE statements +CREATE OR REPLACE FUNCTION handle_task_list_sort_between_groups_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _offset INT := 0; + _affected_rows INT; +BEGIN + -- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE + IF (_to_index = -1) + THEN + _to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0); + END IF; + + -- PERFORMANCE OPTIMIZATION: Batch updates for large datasets + IF _to_index > _from_index + THEN + LOOP + UPDATE tasks + SET sort_order = sort_order - 1 + WHERE project_id = _project_id + AND sort_order > _from_index + AND sort_order < _to_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + + GET DIAGNOSTICS _affected_rows = ROW_COUNT; + EXIT WHEN _affected_rows = 0; + _offset := _offset + _batch_size; + END LOOP; + + UPDATE tasks SET sort_order = _to_index - 1 WHERE id = _task_id AND project_id = _project_id; + END IF; + + IF _to_index < _from_index + THEN + _offset := 0; + LOOP + UPDATE tasks + SET sort_order = sort_order + 1 + WHERE project_id = _project_id + AND sort_order > _to_index + AND sort_order < _from_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + + GET DIAGNOSTICS _affected_rows = ROW_COUNT; + EXIT WHEN _affected_rows = 0; + _offset := _offset + _batch_size; + END LOOP; + + UPDATE tasks SET sort_order = _to_index + 1 WHERE id = _task_id AND project_id = _project_id; + END IF; +END +$$; + +-- Replace the second optimized sort function +CREATE OR REPLACE FUNCTION handle_task_list_sort_inside_group_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _offset INT := 0; + _affected_rows INT; +BEGIN + -- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE + IF _to_index > _from_index + THEN + LOOP + UPDATE tasks + SET sort_order = sort_order - 1 + WHERE project_id = _project_id + AND sort_order > _from_index + AND sort_order <= _to_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + + GET DIAGNOSTICS _affected_rows = ROW_COUNT; + EXIT WHEN _affected_rows = 0; + _offset := _offset + _batch_size; + END LOOP; + END IF; + + IF _to_index < _from_index + THEN + _offset := 0; + LOOP + UPDATE tasks + SET sort_order = sort_order + 1 + WHERE project_id = _project_id + AND sort_order >= _to_index + AND sort_order < _from_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + + GET DIAGNOSTICS _affected_rows = ROW_COUNT; + EXIT WHEN _affected_rows = 0; + _offset := _offset + _batch_size; + END LOOP; + END IF; + + UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id; +END +$$; + +-- Add simple bulk update function as alternative +CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _update_record RECORD; +BEGIN + -- Simple approach: update each task's sort_order from the provided array + FOR _update_record IN + SELECT + (item->>'task_id')::uuid as task_id, + (item->>'sort_order')::int as sort_order, + (item->>'status_id')::uuid as status_id, + (item->>'priority_id')::uuid as priority_id, + (item->>'phase_id')::uuid as phase_id + FROM json_array_elements(_updates) as item + LOOP + UPDATE tasks + SET + sort_order = _update_record.sort_order, + status_id = COALESCE(_update_record.status_id, status_id), + priority_id = COALESCE(_update_record.priority_id, priority_id) + WHERE id = _update_record.task_id; + + -- Handle phase updates separately since it's in a different table + IF _update_record.phase_id IS NOT NULL THEN + INSERT INTO task_phase (task_id, phase_id) + VALUES (_update_record.task_id, _update_record.phase_id) + ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id; + END IF; + END LOOP; +END +$$; \ No newline at end of file diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index 441b08e8..2c57d3c4 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -5498,6 +5498,7 @@ DECLARE _iterator NUMERIC := 0; _status_id TEXT; _project_id UUID; + _base_sort_order NUMERIC; BEGIN -- Get the project_id from the first status to ensure we update all statuses in the same project SELECT project_id INTO _project_id @@ -5513,17 +5514,28 @@ BEGIN _iterator := _iterator + 1; END LOOP; - -- Ensure any remaining statuses in the project (not in the provided list) get sequential sort_order - -- This handles edge cases where not all statuses are provided - UPDATE task_statuses - SET sort_order = ( - SELECT COUNT(*) - FROM task_statuses ts2 - WHERE ts2.project_id = _project_id - AND ts2.id = ANY(SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID) - ) + ROW_NUMBER() OVER (ORDER BY sort_order) - 1 - WHERE project_id = _project_id - AND id NOT IN (SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID); + -- Get the base sort order for remaining statuses (simple count approach) + SELECT COUNT(*) INTO _base_sort_order + FROM task_statuses ts2 + WHERE ts2.project_id = _project_id + AND ts2.id = ANY(SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID); + + -- Update remaining statuses with simple sequential numbering + -- Reset iterator to start from base_sort_order + _iterator := _base_sort_order; + + -- Use a cursor approach to avoid window functions + FOR _status_id IN + SELECT id::TEXT FROM task_statuses + WHERE project_id = _project_id + AND id NOT IN (SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID) + ORDER BY sort_order + LOOP + UPDATE task_statuses + SET sort_order = _iterator + WHERE id = _status_id::UUID; + _iterator := _iterator + 1; + END LOOP; RETURN; END @@ -6412,7 +6424,7 @@ DECLARE _offset INT := 0; _affected_rows INT; BEGIN - -- PERFORMANCE OPTIMIZATION: Use CTE for better query planning + -- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE IF (_to_index = -1) THEN _to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0); @@ -6422,18 +6434,15 @@ BEGIN IF _to_index > _from_index THEN LOOP - WITH batch_update AS ( - UPDATE tasks - SET sort_order = sort_order - 1 - WHERE project_id = _project_id - AND sort_order > _from_index - AND sort_order < _to_index - AND sort_order > _offset - AND sort_order <= _offset + _batch_size - RETURNING 1 - ) - SELECT COUNT(*) INTO _affected_rows FROM batch_update; + UPDATE tasks + SET sort_order = sort_order - 1 + WHERE project_id = _project_id + AND sort_order > _from_index + AND sort_order < _to_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + GET DIAGNOSTICS _affected_rows = ROW_COUNT; EXIT WHEN _affected_rows = 0; _offset := _offset + _batch_size; END LOOP; @@ -6445,18 +6454,15 @@ BEGIN THEN _offset := 0; LOOP - WITH batch_update AS ( - UPDATE tasks - SET sort_order = sort_order + 1 - WHERE project_id = _project_id - AND sort_order > _to_index - AND sort_order < _from_index - AND sort_order > _offset - AND sort_order <= _offset + _batch_size - RETURNING 1 - ) - SELECT COUNT(*) INTO _affected_rows FROM batch_update; + UPDATE tasks + SET sort_order = sort_order + 1 + WHERE project_id = _project_id + AND sort_order > _to_index + AND sort_order < _from_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + GET DIAGNOSTICS _affected_rows = ROW_COUNT; EXIT WHEN _affected_rows = 0; _offset := _offset + _batch_size; END LOOP; @@ -6475,22 +6481,19 @@ DECLARE _offset INT := 0; _affected_rows INT; BEGIN - -- PERFORMANCE OPTIMIZATION: Batch updates for large datasets + -- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE IF _to_index > _from_index THEN LOOP - WITH batch_update AS ( - UPDATE tasks - SET sort_order = sort_order - 1 - WHERE project_id = _project_id - AND sort_order > _from_index - AND sort_order <= _to_index - AND sort_order > _offset - AND sort_order <= _offset + _batch_size - RETURNING 1 - ) - SELECT COUNT(*) INTO _affected_rows FROM batch_update; + UPDATE tasks + SET sort_order = sort_order - 1 + WHERE project_id = _project_id + AND sort_order > _from_index + AND sort_order <= _to_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + GET DIAGNOSTICS _affected_rows = ROW_COUNT; EXIT WHEN _affected_rows = 0; _offset := _offset + _batch_size; END LOOP; @@ -6500,18 +6503,15 @@ BEGIN THEN _offset := 0; LOOP - WITH batch_update AS ( - UPDATE tasks - SET sort_order = sort_order + 1 - WHERE project_id = _project_id - AND sort_order >= _to_index - AND sort_order < _from_index - AND sort_order > _offset - AND sort_order <= _offset + _batch_size - RETURNING 1 - ) - SELECT COUNT(*) INTO _affected_rows FROM batch_update; + UPDATE tasks + SET sort_order = sort_order + 1 + WHERE project_id = _project_id + AND sort_order >= _to_index + AND sort_order < _from_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + GET DIAGNOSTICS _affected_rows = ROW_COUNT; EXIT WHEN _affected_rows = 0; _offset := _offset + _batch_size; END LOOP; @@ -6520,3 +6520,38 @@ BEGIN UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id; END $$; + +-- Simple function to update task sort orders in bulk +CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _update_record RECORD; +BEGIN + -- Simple approach: update each task's sort_order from the provided array + FOR _update_record IN + SELECT + (item->>'task_id')::uuid as task_id, + (item->>'sort_order')::int as sort_order, + (item->>'status_id')::uuid as status_id, + (item->>'priority_id')::uuid as priority_id, + (item->>'phase_id')::uuid as phase_id + FROM json_array_elements(_updates) as item + LOOP + UPDATE tasks + SET + sort_order = _update_record.sort_order, + status_id = COALESCE(_update_record.status_id, status_id), + priority_id = COALESCE(_update_record.priority_id, priority_id) + WHERE id = _update_record.task_id; + + -- Handle phase updates separately since it's in a different table + IF _update_record.phase_id IS NOT NULL THEN + INSERT INTO task_phase (task_id, phase_id) + VALUES (_update_record.task_id, _update_record.phase_id) + ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id; + END IF; + END LOOP; +END +$$; diff --git a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts index 79abae7a..11ec09cd 100644 --- a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts +++ b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts @@ -24,6 +24,14 @@ interface ChangeRequest { priority: string; }; team_id: string; + // New simplified approach + task_updates?: Array<{ + task_id: string; + sort_order: number; + status_id?: string; + priority_id?: string; + phase_id?: string; + }>; } interface Config { @@ -64,38 +72,72 @@ function updateUnmappedStatus(config: Config) { export async function on_task_sort_order_change(_io: Server, socket: Socket, data: ChangeRequest) { try { - const q = `SELECT handle_task_list_sort_order_change($1);`; - - const config: Config = { - from_index: data.from_index, - to_index: data.to_index, - task_id: data.task.id, - from_group: data.from_group, - to_group: data.to_group, - project_id: data.project_id, - group_by: data.group_by, - to_last_index: Boolean(data.to_last_index) - }; - - if ((config.group_by === GroupBy.STATUS) && config.to_group) { - const canContinue = await TasksControllerV2.checkForCompletedDependencies(config.task_id, config?.to_group); - if (!canContinue) { - return socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { - completed_deps: canContinue - }); + // New simplified approach - use bulk updates if provided + if (data.task_updates && data.task_updates.length > 0) { + // Check dependencies for status changes + if (data.group_by === GroupBy.STATUS && data.to_group) { + const canContinue = await TasksControllerV2.checkForCompletedDependencies(data.task.id, data.to_group); + if (!canContinue) { + return socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { + completed_deps: canContinue + }); + } } - notifyStatusChange(socket, config); + // Use the simple bulk update function + const q = `SELECT update_task_sort_orders_bulk($1);`; + await db.query(q, [JSON.stringify(data.task_updates)]); + await emitSortOrderChange(data, socket); + + // Handle notifications and logging + if (data.group_by === GroupBy.STATUS && data.to_group) { + notifyStatusChange(socket, { + task_id: data.task.id, + to_group: data.to_group, + from_group: data.from_group, + from_index: data.from_index, + to_index: data.to_index, + project_id: data.project_id, + group_by: data.group_by, + to_last_index: data.to_last_index + }); + } + } else { + // Fallback to old complex method + const q = `SELECT handle_task_list_sort_order_change($1);`; + + const config: Config = { + from_index: data.from_index, + to_index: data.to_index, + task_id: data.task.id, + from_group: data.from_group, + to_group: data.to_group, + project_id: data.project_id, + group_by: data.group_by, + to_last_index: Boolean(data.to_last_index) + }; + + if ((config.group_by === GroupBy.STATUS) && config.to_group) { + const canContinue = await TasksControllerV2.checkForCompletedDependencies(config.task_id, config?.to_group); + if (!canContinue) { + return socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { + completed_deps: canContinue + }); + } + + notifyStatusChange(socket, config); + } + + if (config.group_by === GroupBy.PHASE) { + updateUnmappedStatus(config); + } + + await db.query(q, [JSON.stringify(config)]); + await emitSortOrderChange(data, socket); } - if (config.group_by === GroupBy.PHASE) { - updateUnmappedStatus(config); - } - - await db.query(q, [JSON.stringify(config)]); - await emitSortOrderChange(data, socket); - - if (config.group_by === GroupBy.STATUS) { + // Common post-processing logic for both approaches + if (data.group_by === GroupBy.STATUS) { const userId = getLoggedInUserIdFromSocket(socket); const isAlreadyAssigned = await TasksControllerV2.checkUserAssignedToTask(data.task.id, userId as string, data.team_id); @@ -104,7 +146,7 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat } } - if (config.group_by === GroupBy.PHASE) { + if (data.group_by === GroupBy.PHASE) { void logPhaseChange({ task_id: data.task.id, socket, @@ -113,7 +155,7 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat }); } - if (config.group_by === GroupBy.STATUS) { + if (data.group_by === GroupBy.STATUS) { void logStatusChange({ task_id: data.task.id, socket, @@ -122,7 +164,7 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat }); } - if (config.group_by === GroupBy.PRIORITY) { + if (data.group_by === GroupBy.PRIORITY) { void logPriorityChange({ task_id: data.task.id, socket, @@ -131,7 +173,7 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat }); } - void notifyProjectUpdates(socket, config.task_id); + void notifyProjectUpdates(socket, data.task.id); return; } catch (error) { log_error(error); diff --git a/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx b/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx index 6f6d85dc..8c24c88b 100644 --- a/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx +++ b/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx @@ -15,7 +15,6 @@ import { IKanbanTaskStatus } from '@/types/tasks/taskStatus.types'; import { Modal as AntModal } from 'antd'; import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice'; -import { fetchTaskGroups } from '@/features/tasks/tasks.slice'; import './ManageStatusModal.css'; const { Title, Text } = Typography; @@ -594,7 +593,6 @@ const ManageStatusModal: React.FC = ({ // Refresh from server to ensure consistency dispatch(fetchStatuses(finalProjectId)); dispatch(fetchTasksV3(finalProjectId)); - dispatch(fetchTaskGroups(finalProjectId)); dispatch(fetchEnhancedKanbanGroups(finalProjectId)); } catch (error) { console.error('Error changing status category:', error); @@ -736,7 +734,6 @@ const ManageStatusModal: React.FC = ({ statusApiService.updateStatusOrder(requestBody, finalProjectId).then(() => { // Refresh task lists after status order change dispatch(fetchTasksV3(finalProjectId)); - dispatch(fetchTaskGroups(finalProjectId)); dispatch(fetchEnhancedKanbanGroups(finalProjectId)); }).catch(error => { console.error('Error updating status order:', error); @@ -767,7 +764,6 @@ const ManageStatusModal: React.FC = ({ if (res.done) { dispatch(fetchStatuses(finalProjectId)); dispatch(fetchTasksV3(finalProjectId)); - dispatch(fetchTaskGroups(finalProjectId)); dispatch(fetchEnhancedKanbanGroups(finalProjectId)); } } catch (error) { @@ -791,7 +787,6 @@ const ManageStatusModal: React.FC = ({ await statusApiService.updateNameOfStatus(id, body, finalProjectId); dispatch(fetchStatuses(finalProjectId)); dispatch(fetchTasksV3(finalProjectId)); - dispatch(fetchTaskGroups(finalProjectId)); dispatch(fetchEnhancedKanbanGroups(finalProjectId)); } catch (error) { console.error('Error renaming status:', error); @@ -813,7 +808,6 @@ const ManageStatusModal: React.FC = ({ await statusApiService.deleteStatus(id, finalProjectId, replacingStatusId); dispatch(fetchStatuses(finalProjectId)); dispatch(fetchTasksV3(finalProjectId)); - dispatch(fetchTaskGroups(finalProjectId)); dispatch(fetchEnhancedKanbanGroups(finalProjectId)); } catch (error) { console.error('Error deleting status:', error); diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx index 84c30f59..f7a8a52c 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx @@ -524,6 +524,69 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }); } + // NEW SIMPLIFIED APPROACH: Calculate all affected task updates and send them + const taskUpdates: Array<{ + task_id: string; + sort_order: number; + status_id?: string; + priority_id?: string; + phase_id?: string; + }> = []; + + // Add updates for all tasks in affected groups + if (activeGroupId === overGroupId) { + // Same group - just reorder + const updatedTasks = [...sourceGroup.tasks]; + updatedTasks.splice(fromIndex, 1); + updatedTasks.splice(toIndex, 0, task); + + updatedTasks.forEach((task, index) => { + taskUpdates.push({ + task_id: task.id, + sort_order: index + 1, // 1-based indexing + }); + }); + } else { + // Different groups - update both source and target + const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex); + const updatedTargetTasks = [...targetGroup.tasks]; + + if (isTargetGroupEmpty) { + updatedTargetTasks.push(task); + } else if (toIndex >= 0 && toIndex <= updatedTargetTasks.length) { + updatedTargetTasks.splice(toIndex, 0, task); + } else { + updatedTargetTasks.push(task); + } + + // Add updates for source group + updatedSourceTasks.forEach((task, index) => { + taskUpdates.push({ + task_id: task.id, + sort_order: index + 1, + }); + }); + + // Add updates for target group (including the moved task) + updatedTargetTasks.forEach((task, index) => { + const update: any = { + task_id: task.id, + sort_order: index + 1, + }; + + // Add group-specific updates + if (groupBy === IGroupBy.STATUS) { + update.status_id = targetGroup.id; + } else if (groupBy === IGroupBy.PRIORITY) { + update.priority_id = targetGroup.id; + } else if (groupBy === IGroupBy.PHASE) { + update.phase_id = targetGroup.id; + } + + taskUpdates.push(update); + }); + } + socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { project_id: projectId, from_index: sourceGroup.tasks[fromIndex].sort_order, @@ -534,6 +597,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { group_by: groupBy, task: sourceGroup.tasks[fromIndex], team_id: currentSession?.team_id, + task_updates: taskUpdates, // NEW: Send calculated updates }); setTimeout(resetTaskRowStyles, 0); diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx index bee5c22a..fb7c9fb1 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx @@ -208,6 +208,18 @@ const TaskListTableWrapper = ({ > + {groupBy !== IGroupBy.PRIORITY && + !showRenameInput && + isEditable && + name !== 'Unmapped' && ( + + - {groupBy !== IGroupBy.PRIORITY && - !showRenameInput && - isEditable && - name !== 'Unmapped' && ( - - )} - - {/* Drop indicator at the end of the group */} - {hoveredGroupId === group.id && hoveredTaskIdx === group.tasks.length && ( -
-
-
- )}
From 26de439faba47f31faad76047c31b4272ce44ae5 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 15:54:43 +0530 Subject: [PATCH 41/49] feat(task-management): add progress statistics and visual representation for task groups - Implemented progress calculations for tasks grouped by priority and phase, including todo, doing, and done counts. - Introduced a new GroupProgressBar component to visually represent task progress in the TaskGroupHeader. - Updated TaskGroupHeader and TaskListV2Table to integrate progress data and display the progress bar conditionally based on grouping. - Enhanced local storage handling for grouping preferences in the task management feature. --- .../src/controllers/tasks-controller-v2.ts | 61 ++++- .../task-list-v2/GroupProgressBar.tsx | 93 +++++++ .../task-list-v2/TaskGroupHeader.tsx | 237 ++++++++++++------ .../task-list-v2/TaskListV2Table.tsx | 4 + .../enhanced-kanban/enhanced-kanban.slice.ts | 2 +- .../task-management/grouping.slice.ts | 31 ++- 6 files changed, 350 insertions(+), 78 deletions(-) create mode 100644 worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 27df13e7..d941f824 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -1174,9 +1174,39 @@ export default class TasksControllerV2 extends TasksControllerBase { } }); + // Calculate progress stats for priority and phase grouping + if (groupBy === GroupBy.PRIORITY || groupBy === GroupBy.PHASE) { + Object.values(groupedResponse).forEach((group: any) => { + if (group.tasks && group.tasks.length > 0) { + const todoCount = group.tasks.filter((task: any) => { + // For tasks, we need to check their original status category + const originalTask = tasks.find(t => t.id === task.id); + return originalTask?.status_category?.is_todo; + }).length; + + const doingCount = group.tasks.filter((task: any) => { + const originalTask = tasks.find(t => t.id === task.id); + return originalTask?.status_category?.is_doing; + }).length; + + const doneCount = group.tasks.filter((task: any) => { + const originalTask = tasks.find(t => t.id === task.id); + return originalTask?.status_category?.is_done; + }).length; + + const total = group.tasks.length; + + // Calculate progress percentages + group.todo_progress = total > 0 ? +((todoCount / total) * 100).toFixed(0) : 0; + group.doing_progress = total > 0 ? +((doingCount / total) * 100).toFixed(0) : 0; + group.done_progress = total > 0 ? +((doneCount / total) * 100).toFixed(0) : 0; + } + }); + } + // Create unmapped group if there are tasks without proper phase assignment if (unmappedTasks.length > 0 && groupBy === GroupBy.PHASE) { - groupedResponse[UNMAPPED.toLowerCase()] = { + const unmappedGroup = { id: UNMAPPED, title: UNMAPPED, groupType: groupBy, @@ -1189,7 +1219,36 @@ export default class TasksControllerV2 extends TasksControllerBase { start_date: null, end_date: null, sort_index: 999, // Put unmapped group at the end + todo_progress: 0, + doing_progress: 0, + done_progress: 0, }; + + // Calculate progress stats for unmapped group + if (unmappedTasks.length > 0) { + const todoCount = unmappedTasks.filter((task: any) => { + const originalTask = tasks.find(t => t.id === task.id); + return originalTask?.status_category?.is_todo; + }).length; + + const doingCount = unmappedTasks.filter((task: any) => { + const originalTask = tasks.find(t => t.id === task.id); + return originalTask?.status_category?.is_doing; + }).length; + + const doneCount = unmappedTasks.filter((task: any) => { + const originalTask = tasks.find(t => t.id === task.id); + return originalTask?.status_category?.is_done; + }).length; + + const total = unmappedTasks.length; + + unmappedGroup.todo_progress = total > 0 ? +((todoCount / total) * 100).toFixed(0) : 0; + unmappedGroup.doing_progress = total > 0 ? +((doingCount / total) * 100).toFixed(0) : 0; + unmappedGroup.done_progress = total > 0 ? +((doneCount / total) * 100).toFixed(0) : 0; + } + + groupedResponse[UNMAPPED.toLowerCase()] = unmappedGroup; } // Sort tasks within each group by order diff --git a/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx b/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx new file mode 100644 index 00000000..fd280bdf --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +interface GroupProgressBarProps { + todoProgress: number; + doingProgress: number; + doneProgress: number; + groupType: string; +} + +const GroupProgressBar: React.FC = ({ + todoProgress, + doingProgress, + doneProgress, + groupType +}) => { + const { t } = useTranslation('task-management'); + + // Only show for priority and phase grouping + if (groupType !== 'priority' && groupType !== 'phase') { + return null; + } + + const total = todoProgress + doingProgress + doneProgress; + + // Don't show if no progress values exist + if (total === 0) { + return null; + } + + return ( +
+ {/* Compact progress text */} + + {doneProgress}% {t('done')} + + + {/* Compact progress bar */} +
+
+ {/* Todo section - light gray */} + {todoProgress > 0 && ( +
+ )} + {/* Doing section - blue */} + {doingProgress > 0 && ( +
+ )} + {/* Done section - green */} + {doneProgress > 0 && ( +
+ )} +
+
+ + {/* Small legend dots with better spacing */} +
+ {todoProgress > 0 && ( +
+ )} + {doingProgress > 0 && ( +
+ )} + {doneProgress > 0 && ( +
+ )} +
+
+ ); +}; + +export default GroupProgressBar; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx index 32456b86..0b25be2e 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx @@ -3,6 +3,7 @@ import { useDroppable } from '@dnd-kit/core'; // @ts-ignore: Heroicons module types import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline'; import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd'; +import GroupProgressBar from './GroupProgressBar'; import { useTranslation } from 'react-i18next'; import { getContrastColor } from '@/utils/colorUtils'; import { useAppSelector } from '@/hooks/useAppSelector'; @@ -27,6 +28,10 @@ interface TaskGroupHeaderProps { name: string; count: number; color?: string; // Color for the group indicator + todo_progress?: number; + doing_progress?: number; + done_progress?: number; + groupType?: string; }; isCollapsed: boolean; onToggle: () => void; @@ -44,7 +49,7 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o const { isOwnerOrAdmin } = useAuthService(); const [dropdownVisible, setDropdownVisible] = useState(false); - const [categoryModalVisible, setCategoryModalVisible] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); const [isChangingCategory, setIsChangingCategory] = useState(false); const [isEditingName, setIsEditingName] = useState(false); @@ -94,7 +99,12 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o // Handle inline name editing const handleNameSave = useCallback(async () => { - if (!editingName.trim() || editingName.trim() === group.name || isRenaming) return; + // If no changes or already renaming, just exit editing mode + if (!editingName.trim() || editingName.trim() === group.name || isRenaming) { + setIsEditingName(false); + setEditingName(group.name); + return; + } setIsRenaming(true); try { @@ -122,12 +132,12 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o // Refresh task list to get updated group names dispatch(fetchTasksV3(projectId)); - setIsEditingName(false); } catch (error) { logger.error('Error renaming group:', error); setEditingName(group.name); } finally { + setIsEditingName(false); setIsRenaming(false); } }, [editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming]); @@ -150,9 +160,8 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o }, [group.name, handleNameSave]); const handleNameBlur = useCallback(() => { - setIsEditingName(false); - setEditingName(group.name); - }, [group.name]); + handleNameSave(); + }, [handleNameSave]); // Handle dropdown menu actions const handleRenameGroup = useCallback(() => { @@ -161,10 +170,7 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o setEditingName(group.name); }, [group.name]); - const handleChangeCategory = useCallback(() => { - setDropdownVisible(false); - setCategoryModalVisible(true); - }, []); + // Handle category change const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => { @@ -182,7 +188,6 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o // Refresh status list and tasks dispatch(fetchStatuses(projectId)); dispatch(fetchTasksV3(projectId)); - setCategoryModalVisible(false); } catch (error) { logger.error('Error changing category:', error); @@ -209,19 +214,30 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o // Only show "Change Category" when grouped by status if (currentGrouping === 'status') { - items.push({ - key: 'changeCategory', - icon: , - label: t('changeCategory'), + const categorySubMenuItems = statusCategories.map((category) => ({ + key: `category-${category.id}`, + label: ( +
+ + {category.name} +
+ ), onClick: (e: any) => { e?.domEvent?.stopPropagation(); - handleChangeCategory(); + handleCategoryChange(category.id || '', e?.domEvent); }, - }); + })); + + items.push({ + key: 'changeCategory', + icon: , + label: t('changeCategory'), + children: categorySubMenuItems, + } as any); } return items; - }, [currentGrouping, handleRenameGroup, handleChangeCategory, isOwnerOrAdmin]); + }, [currentGrouping, handleRenameGroup, handleCategoryChange, isOwnerOrAdmin, statusCategories, t]); // Make the group header droppable const { isOver, setNodeRef } = useDroppable({ @@ -232,75 +248,146 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o }, }); - return ( -
- {/* Drag Handle Space - ultra minimal width */} -
- {/* Chevron button */} -
- -
+
+ +
+ +
- {/* Select All Checkbox Space - ultra minimal width */} -
- e.stopPropagation()} - style={{ - color: headerTextColor, - }} - /> -
+ {/* Select All Checkbox Space - ultra minimal width */} +
+ e.stopPropagation()} + style={{ + color: headerTextColor, + }} + /> +
- {/* Group indicator and name - no gap at all */} + {/* Group indicator and name - no gap at all */}
{/* Group name and count */}
- - {group.name} - + {isEditingName ? ( + setEditingName(e.target.value)} + onKeyDown={handleNameKeyDown} + onBlur={handleNameBlur} + autoFocus + size="small" + className="text-sm font-semibold" + style={{ + width: 'auto', + minWidth: '100px', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + color: headerTextColor, + border: '1px solid rgba(255, 255, 255, 0.3)' + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {group.name} + + )} ({group.count})
+ + {/* Three-dot menu - only show for status and phase grouping */} + {menuItems.length > 0 && (currentGrouping === 'status' || currentGrouping === 'phase') && ( +
+ + + +
+ )} + +
+ + {/* Progress Bar - sticky to the right edge during horizontal scroll */} + {(currentGrouping === 'priority' || currentGrouping === 'phase') && + (group.todo_progress || group.doing_progress || group.done_progress) && ( +
+ +
+ )}
); }; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index c208882f..8d3c8452 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -452,6 +452,10 @@ const TaskListV2Section: React.FC = () => { name: group.title, count: group.actualCount, color: group.color, + todo_progress: group.todo_progress, + doing_progress: group.doing_progress, + done_progress: group.done_progress, + groupType: group.groupType, }} isCollapsed={isGroupCollapsed} onToggle={() => handleGroupCollapse(group.id)} diff --git a/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts b/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts index 8b4e1419..3ccac5d2 100644 --- a/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts +++ b/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts @@ -33,7 +33,7 @@ export const GROUP_BY_OPTIONS: IGroupByOption[] = [ { label: 'Phase', value: IGroupBy.PHASE }, ]; -const LOCALSTORAGE_GROUP_KEY = 'worklenz.enhanced-kanban.group_by'; +const LOCALSTORAGE_GROUP_KEY = 'worklenz.kanban.group_by'; export const getCurrentGroup = (): IGroupBy => { const key = localStorage.getItem(LOCALSTORAGE_GROUP_KEY); diff --git a/worklenz-frontend/src/features/task-management/grouping.slice.ts b/worklenz-frontend/src/features/task-management/grouping.slice.ts index cea2c047..49f5bb61 100644 --- a/worklenz-frontend/src/features/task-management/grouping.slice.ts +++ b/worklenz-frontend/src/features/task-management/grouping.slice.ts @@ -17,8 +17,36 @@ interface LocalGroupingState { collapsedGroups: string[]; } +// Local storage constants +const LOCALSTORAGE_GROUP_KEY = 'worklenz.tasklist.group_by'; + +// Utility functions for local storage +const loadGroupingFromLocalStorage = (): GroupingType | null => { + try { + const stored = localStorage.getItem(LOCALSTORAGE_GROUP_KEY); + if (stored && ['status', 'priority', 'phase'].includes(stored)) { + return stored as GroupingType; + } + } catch (error) { + console.warn('Failed to load grouping from localStorage:', error); + } + return 'status'; // Default to 'status' instead of null +}; + +const saveGroupingToLocalStorage = (grouping: GroupingType | null): void => { + try { + if (grouping) { + localStorage.setItem(LOCALSTORAGE_GROUP_KEY, grouping); + } else { + localStorage.removeItem(LOCALSTORAGE_GROUP_KEY); + } + } catch (error) { + console.warn('Failed to save grouping to localStorage:', error); + } +}; + const initialState: LocalGroupingState = { - currentGrouping: null, + currentGrouping: loadGroupingFromLocalStorage(), customPhases: ['Planning', 'Development', 'Testing', 'Deployment'], groupOrder: { status: ['todo', 'doing', 'done'], @@ -35,6 +63,7 @@ const groupingSlice = createSlice({ reducers: { setCurrentGrouping: (state, action: PayloadAction) => { state.currentGrouping = action.payload; + saveGroupingToLocalStorage(action.payload); }, addCustomPhase: (state, action: PayloadAction) => { From 6226ae35ff470afb948d5286ebf1f464f2fbe345 Mon Sep 17 00:00:00 2001 From: shancds Date: Fri, 11 Jul 2025 16:01:01 +0530 Subject: [PATCH 42/49] fix(task-card): add title attribute for better accessibility - Added a title attribute to the task name div in TaskCard component to improve accessibility and provide additional context on hover. --- .../enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx index 236d9245..4046a8f2 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx @@ -245,7 +245,7 @@ const TaskCard: React.FC = memo(({ className="w-2 h-2 rounded-full inline-block" style={{ backgroundColor: themeMode === 'dark' ? (task.priority_color_dark || task.priority_color || '#d9d9d9') : (task.priority_color || '#d9d9d9') }} > -
{task.name}
+
{task.name}
From 2498effce34f18694c1a1d69b06913071960dc35 Mon Sep 17 00:00:00 2001 From: shancds Date: Fri, 11 Jul 2025 16:20:21 +0530 Subject: [PATCH 43/49] fix(enhanced-kanban): integrate socket event handling for real-time updates - Added useTaskSocketHandlers hook to manage socket event handlers for real-time task updates, improving code organization and readability. - Removed inline socket handling logic to streamline the component and enhance maintainability. --- .../EnhancedKanbanBoardNativeDnD.tsx | 37 ++++--------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx index a1a08105..e1d7248a 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -21,6 +21,7 @@ import alertService from '@/services/alerts/alertService'; import logger from '@/utils/errorLogger'; import Skeleton from 'antd/es/skeleton/Skeleton'; import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status'; +import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ projectId }) => { const dispatch = useDispatch(); @@ -41,6 +42,10 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project const [hoveredTaskIdx, setHoveredTaskIdx] = useState(null); const [dragType, setDragType] = useState<'group' | 'task' | null>(null); const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer); + + // Set up socket event handlers for real-time updates + useTaskSocketHandlers(); + useEffect(() => { if (projectId) { dispatch(fetchEnhancedKanbanGroups(projectId) as any); @@ -268,36 +273,8 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project setHoveredTaskIdx(null); }; - useEffect(() => { - if (!socket) return; - - // Handler for new task received via socket - const handleNewTaskReceived = (data: any) => { - if (!data) return; - if (data.parent_task_id) { - // Subtask: update subtasks in the correct group - dispatch({ - type: 'enhancedKanbanReducer/updateEnhancedKanbanSubtask', - payload: { sectionId: '', subtask: data, mode: 'add' } - }); - } else { - // Regular task: add to the correct group - let sectionId = ''; - if (groupBy === 'status') sectionId = data.status; - else if (groupBy === 'priority') sectionId = data.priority; - else if (groupBy === 'phase') sectionId = data.phase_id; - dispatch({ - type: 'enhancedKanbanReducer/addTaskToGroup', - payload: { sectionId, task: data } - }); - } - }; - - socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived); - return () => { - socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived); - }; - }, [socket, groupBy, dispatch]); + // Note: Socket event handlers are now managed by useTaskSocketHandlers hook + // This includes TASK_NAME_CHANGE, QUICK_TASK, and other real-time updates if (error) { return ( From 12b430a3494cb8407ba217230a14341b6ba9b594 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 16:41:30 +0530 Subject: [PATCH 44/49] feat(task-context-menu): implement context menu for task actions - Added TaskContextMenu component to provide a context menu for task-related actions such as assigning, archiving, deleting, and moving tasks. - Integrated context menu into TitleColumn component, allowing users to access task actions via right-click. - Enhanced user experience by providing immediate feedback for actions like assigning tasks and archiving. - Improved code organization by separating context menu logic into its own component. --- .../components/TaskContextMenu.tsx | 491 ++++++++++++++++++ .../task-list-v2/components/TitleColumn.tsx | 36 ++ .../task-list-table/task-list-table.tsx | 90 ---- 3 files changed, 527 insertions(+), 90 deletions(-) create mode 100644 worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx diff --git a/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx b/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx new file mode 100644 index 00000000..0982dafa --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx @@ -0,0 +1,491 @@ +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useSocket } from '@/socket/socketContext'; +import { useAuthService } from '@/hooks/useAuth'; +import { SocketEvents } from '@/shared/socket-events'; +import logger from '@/utils/errorLogger'; +import { Task } from '@/types/task-management.types'; +import { tasksApiService } from '@/api/tasks/tasks.api.service'; +import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service'; +import { IBulkAssignRequest } from '@/types/tasks/bulk-action-bar.types'; +import { + deleteTask, + fetchTasksV3, + IGroupBy, + toggleTaskExpansion, + updateTaskAssignees, +} from '@/features/task-management/task-management.slice'; +import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; +import { setConvertToSubtaskDrawerOpen } from '@/features/task-drawer/task-drawer.slice'; +import { useTranslation } from 'react-i18next'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { + evt_project_task_list_context_menu_archive, + evt_project_task_list_context_menu_assign_me, + evt_project_task_list_context_menu_delete, +} from '@/shared/worklenz-analytics-events'; +import { + DeleteOutlined, + DoubleRightOutlined, + InboxOutlined, + RetweetOutlined, + UserAddOutlined, + LoadingOutlined, +} from '@ant-design/icons'; + +interface TaskContextMenuProps { + task: Task; + projectId: string; + position: { x: number; y: number }; + onClose: () => void; +} + +const TaskContextMenu: React.FC = ({ + task, + projectId, + position, + onClose, +}) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation('task-list-table'); + const { socket, connected } = useSocket(); + const currentSession = useAuthService().getCurrentSession(); + const { trackMixpanelEvent } = useMixpanelTracking(); + + const { groups: taskGroups } = useAppSelector(state => state.taskManagement); + const statusList = useAppSelector(state => state.taskStatusReducer.status); + const priorityList = useAppSelector(state => state.priorityReducer.priorities); + const phaseList = useAppSelector(state => state.phaseReducer.phaseList); + const currentGrouping = useAppSelector(state => state.grouping.currentGrouping); + const archived = useAppSelector(state => state.taskReducer.archived); + + const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false); + + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [onClose]); + + const handleAssignToMe = useCallback(async () => { + if (!projectId || !task.id || !currentSession?.team_member_id) return; + + try { + setUpdatingAssignToMe(true); + + // Immediate UI update - add current user to assignees + const currentUser = { + id: currentSession.team_member_id, + name: currentSession.name || '', + email: currentSession.email || '', + avatar_url: currentSession.avatar_url || '', + team_member_id: currentSession.team_member_id, + }; + + const updatedAssignees = task.assignees || []; + const updatedAssigneeNames = task.assignee_names || []; + + // Check if current user is already assigned + const isAlreadyAssigned = updatedAssignees.includes(currentSession.team_member_id); + + if (!isAlreadyAssigned) { + // Add current user to assignees for immediate UI feedback + const newAssignees = [...updatedAssignees, currentSession.team_member_id]; + const newAssigneeNames = [...updatedAssigneeNames, currentUser]; + + // Update Redux store immediately for instant UI feedback + dispatch( + updateTaskAssignees({ + taskId: task.id, + assigneeIds: newAssignees, + assigneeNames: newAssigneeNames, + }) + ); + } + + const body: IBulkAssignRequest = { + tasks: [task.id], + project_id: projectId, + }; + const res = await taskListBulkActionsApiService.assignToMe(body); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_context_menu_assign_me); + // Socket event will handle syncing with other users + } + } catch (error) { + logger.error('Error assigning to me:', error); + // Revert the optimistic update on error + dispatch( + updateTaskAssignees({ + taskId: task.id, + assigneeIds: task.assignees || [], + assigneeNames: task.assignee_names || [], + }) + ); + } finally { + setUpdatingAssignToMe(false); + onClose(); + } + }, [projectId, task.id, task.assignees, task.assignee_names, currentSession, dispatch, onClose, trackMixpanelEvent]); + + const handleArchive = useCallback(async () => { + if (!projectId || !task.id) return; + + try { + const res = await taskListBulkActionsApiService.archiveTasks( + { + tasks: [task.id], + project_id: projectId, + }, + false + ); + + if (res.done) { + trackMixpanelEvent(evt_project_task_list_context_menu_archive); + dispatch(deleteTask(task.id)); + dispatch(deselectAll()); + if (task.parent_task_id) { + socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id); + } + } + } catch (error) { + logger.error('Error archiving task:', error); + } finally { + onClose(); + } + }, [projectId, task.id, task.parent_task_id, dispatch, socket, onClose, trackMixpanelEvent]); + + const handleDelete = useCallback(async () => { + if (!projectId || !task.id) return; + + try { + const res = await taskListBulkActionsApiService.deleteTasks({ tasks: [task.id] }, projectId); + + if (res.done) { + trackMixpanelEvent(evt_project_task_list_context_menu_delete); + dispatch(deleteTask(task.id)); + dispatch(deselectAll()); + if (task.parent_task_id) { + socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id); + } + } + } catch (error) { + logger.error('Error deleting task:', error); + } finally { + onClose(); + } + }, [projectId, task.id, task.parent_task_id, dispatch, socket, onClose, trackMixpanelEvent]); + + const handleStatusMoveTo = useCallback( + async (targetId: string) => { + if (!projectId || !task.id || !targetId) return; + + try { + socket?.emit( + SocketEvents.TASK_STATUS_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + status_id: targetId, + parent_task: task.parent_task_id || null, + team_id: currentSession?.team_id, + }) + ); + } catch (error) { + logger.error('Error moving status:', error); + } finally { + onClose(); + } + }, + [projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose] + ); + + const handlePriorityMoveTo = useCallback( + async (targetId: string) => { + if (!projectId || !task.id || !targetId) return; + + try { + socket?.emit( + SocketEvents.TASK_PRIORITY_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + priority_id: targetId, + parent_task: task.parent_task_id || null, + team_id: currentSession?.team_id, + }) + ); + } catch (error) { + logger.error('Error moving priority:', error); + } finally { + onClose(); + } + }, + [projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose] + ); + + const handlePhaseMoveTo = useCallback( + async (targetId: string) => { + if (!projectId || !task.id || !targetId) return; + + try { + socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), { + task_id: task.id, + phase_id: targetId, + parent_task: task.parent_task_id || null, + team_id: currentSession?.team_id, + }); + } catch (error) { + logger.error('Error moving phase:', error); + } finally { + onClose(); + } + }, + [projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose] + ); + + const getMoveToOptions = useCallback(() => { + let options: { key: string; label: React.ReactNode; onClick: () => void }[] = []; + + if (currentGrouping === IGroupBy.STATUS) { + options = statusList.filter(status => status.id).map(status => ({ + key: status.id!, + label: ( +
+ + {status.name} +
+ ), + onClick: () => handleStatusMoveTo(status.id!), + })); + } else if (currentGrouping === IGroupBy.PRIORITY) { + options = priorityList.filter(priority => priority.id).map(priority => ({ + key: priority.id!, + label: ( +
+ + {priority.name} +
+ ), + onClick: () => handlePriorityMoveTo(priority.id!), + })); + } else if (currentGrouping === IGroupBy.PHASE) { + options = phaseList.filter(phase => phase.id).map(phase => ({ + key: phase.id!, + label: ( +
+ + {phase.name} +
+ ), + onClick: () => handlePhaseMoveTo(phase.id!), + })); + } + return options; + }, [ + currentGrouping, + statusList, + priorityList, + phaseList, + handleStatusMoveTo, + handlePriorityMoveTo, + handlePhaseMoveTo, + ]); + + const handleConvertToTask = useCallback(async () => { + if (!task?.id || !projectId) return; + + try { + const res = await tasksApiService.convertToTask(task.id as string, projectId as string); + if (res.done) { + dispatch(deselectAll()); + dispatch(fetchTasksV3(projectId)); + } + } catch (error) { + logger.error('Error converting to task', error); + } finally { + onClose(); + } + }, [task?.id, projectId, dispatch, onClose]); + + const menuItems = useMemo(() => { + const items = [ + { + key: 'assignToMe', + label: ( + + ), + }, + ]; + + // Add Move To submenu if there are options + const moveToOptions = getMoveToOptions(); + if (moveToOptions.length > 0) { + items.push({ + key: 'moveTo', + label: ( +
+ +
    + {moveToOptions.map(option => ( +
  • + +
  • + ))} +
+
+ ), + }); + } + + // Add Archive/Unarchive for parent tasks only + if (!task?.parent_task_id) { + items.push({ + key: 'archive', + label: ( + + ), + }); + } + + // Add Convert to Sub Task for parent tasks with no subtasks + if (task?.sub_tasks_count === 0 && !task?.parent_task_id) { + items.push({ + key: 'convertToSubTask', + label: ( + + ), + }); + } + + // Add Convert to Task for subtasks + if (task?.parent_task_id) { + items.push({ + key: 'convertToTask', + label: ( + + ), + }); + } + + // Add Delete + items.push({ + key: 'delete', + label: ( + + ), + }); + + return items; + }, [ + task, + projectId, + updatingAssignToMe, + archived, + handleAssignToMe, + handleArchive, + handleDelete, + handleConvertToTask, + getMoveToOptions, + dispatch, + t, + ]); + + return ( +
+
    + {menuItems.map(item => ( +
  • + {item.label} +
  • + ))} +
+
+ ); +}; + +export default TaskContextMenu; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx b/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx index a005a1c4..1fc5ae38 100644 --- a/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx +++ b/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx @@ -2,6 +2,7 @@ import React, { memo, useCallback, useState, useRef, useEffect } from 'react'; import { RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons'; import { Input, Tooltip } from 'antd'; import type { InputRef } from 'antd'; +import { createPortal } from 'react-dom'; import { Task } from '@/types/task-management.types'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { toggleTaskExpansion, fetchSubTasks } from '@/features/task-management/task-management.slice'; @@ -10,6 +11,7 @@ import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import { useTranslation } from 'react-i18next'; import { getTaskDisplayName } from './TaskRowColumns'; +import TaskContextMenu from './TaskContextMenu'; interface TitleColumnProps { width: string; @@ -41,6 +43,10 @@ export const TitleColumn: React.FC = memo(({ const { t } = useTranslation('task-list-table'); const inputRef = useRef(null); const wrapperRef = useRef(null); + + // Context menu state + const [contextMenuVisible, setContextMenuVisible] = useState(false); + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); // Handle task expansion toggle const handleToggleExpansion = useCallback((e: React.MouseEvent) => { @@ -71,6 +77,24 @@ export const TitleColumn: React.FC = memo(({ onEditTaskName(false); }, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, onEditTaskName]); + // Handle context menu + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Use clientX and clientY directly for fixed positioning + setContextMenuPosition({ + x: e.clientX, + y: e.clientY + }); + setContextMenuVisible(true); + }, []); + + // Handle context menu close + const handleContextMenuClose = useCallback(() => { + setContextMenuVisible(false); + }, []); + // Handle click outside for task name editing useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -169,6 +193,7 @@ export const TitleColumn: React.FC = memo(({ e.preventDefault(); onEditTaskName(true); }} + onContextMenu={handleContextMenu} title={taskDisplayName} > {taskDisplayName} @@ -251,6 +276,17 @@ export const TitleColumn: React.FC = memo(({ )} + + {/* Context Menu */} + {contextMenuVisible && createPortal( + , + document.body + )}
); }); diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx index 5f250577..255bbf78 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx @@ -944,18 +944,7 @@ const SelectionFieldCell: React.FC<{ columnKey: string; updateValue: (taskId: string, columnKey: string, value: string) => void; }> = ({ selectionsList, value, task, columnKey, updateValue }) => { - // Debug the selectionsList data - const [loggedInfo, setLoggedInfo] = useState(false); - useEffect(() => { - if (!loggedInfo) { - console.log('Selection column data:', { - columnKey, - selectionsList, - }); - setLoggedInfo(true); - } - }, [columnKey, selectionsList, loggedInfo]); return ( { - // Debug the selectionsList data - const [loggedInfo, setLoggedInfo] = useState(false); - - useEffect(() => { - if (!loggedInfo) { - console.log('Selection column data:', { - columnKey, - selectionsList: columnObj?.selectionsList, - }); - setLoggedInfo(true); - } - }, [columnKey, loggedInfo]); - return ( = ({ taskList, tableId, active 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) { @@ -1686,36 +1639,17 @@ const TaskListTable: React.FC = ({ taskList, tableId, active 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({ @@ -1758,34 +1692,10 @@ const TaskListTable: React.FC = ({ taskList, tableId, active // 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, - }); } } } From affbbbffbffb85304de3649e21a31493a1116088 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 17:26:21 +0530 Subject: [PATCH 45/49] feat(task-phases): enhance phase creation with custom naming and localization updates - Updated the phase creation logic to allow custom names, defaulting to a generated name if none is provided. - Modified localization files for multiple languages to improve phase-related text consistency and clarity. - Enhanced the UI components to reflect the new phase naming functionality and ensure proper integration with the task management system. - Added dark mode styling for confirmation modals to improve user experience across themes. --- .../src/controllers/task-phases-controller.ts | 10 +- .../public/locales/alb/phases-drawer.json | 14 +- .../public/locales/alb/task-list-filters.json | 7 +- .../public/locales/de/phases-drawer.json | 8 +- .../public/locales/de/task-list-filters.json | 1 + .../public/locales/en/phases-drawer.json | 4 + .../public/locales/en/task-list-filters.json | 1 + .../public/locales/es/phases-drawer.json | 18 ++- .../public/locales/es/task-list-filters.json | 5 +- .../public/locales/pt/phases-drawer.json | 10 +- .../public/locales/pt/task-list-filters.json | 5 +- .../public/locales/zh/phases-drawer.json | 40 ++--- .../public/locales/zh/task-list-filters.json | 3 +- .../phases/phases.api.service.ts | 11 +- .../EnhancedKanbanCreateSection.tsx | 2 +- .../task-management/ManagePhaseModal.css | 106 +++++++++++++ .../task-management/ManagePhaseModal.tsx | 12 +- .../task-management/ManageStatusModal.css | 106 +++++++++++++ .../task-management/improved-task-filters.tsx | 6 +- .../singleProject/phase/PhaseHeader.tsx | 15 +- .../singleProject/phase/phases.slice.ts | 4 +- .../board-create-section-card.tsx | 7 +- .../src/styles/task-management.css | 146 ++++++++++++++++++ 23 files changed, 464 insertions(+), 77 deletions(-) diff --git a/worklenz-backend/src/controllers/task-phases-controller.ts b/worklenz-backend/src/controllers/task-phases-controller.ts index e72fbbab..163ff250 100644 --- a/worklenz-backend/src/controllers/task-phases-controller.ts +++ b/worklenz-backend/src/controllers/task-phases-controller.ts @@ -16,19 +16,23 @@ export default class TaskPhasesController extends WorklenzControllerBase { if (!req.query.id) return res.status(400).send(new ServerResponse(false, null, "Invalid request")); + // Use custom name if provided, otherwise use default naming pattern + const phaseName = req.body.name?.trim() || + `Untitled Phase (${(await db.query("SELECT COUNT(*) FROM project_phases WHERE project_id = $1", [req.query.id])).rows[0].count + 1})`; + const q = ` INSERT INTO project_phases (name, color_code, project_id, sort_index) VALUES ( - CONCAT('Untitled Phase (', (SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1, ')'), $1, $2, - (SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1) + $3, + (SELECT COUNT(*) FROM project_phases WHERE project_id = $3) + 1) RETURNING id, name, color_code, sort_index; `; req.body.color_code = this.DEFAULT_PHASE_COLOR; - const result = await db.query(q, [req.body.color_code, req.query.id]); + const result = await db.query(q, [phaseName, req.body.color_code, req.query.id]); const [data] = result.rows; data.color_code = getColor(data.name) + TASK_STATUS_COLOR_ALPHA; diff --git a/worklenz-frontend/public/locales/alb/phases-drawer.json b/worklenz-frontend/public/locales/alb/phases-drawer.json index 23816727..b0ba817b 100644 --- a/worklenz-frontend/public/locales/alb/phases-drawer.json +++ b/worklenz-frontend/public/locales/alb/phases-drawer.json @@ -1,16 +1,20 @@ { "configurePhases": "Konfiguro Fazat", + "configure": "Konfiguro", "phaseLabel": "Etiketa e Fazës", - "enterPhaseName": "Shkruani emrin e fazës", + "enterPhaseName": "Shkruaj emrin e fazës", "addOption": "Shto Opsion", "phaseOptions": "Opsionet e Fazës", - "dragToReorderPhases": "Zvarrit fazat për t'i rirenditur. Çdo fazë mund të ketë një ngjyrë të ndryshme.", - "enterNewPhaseName": "Shkruani emrin e fazës së re...", + "optionsText": "Opsione", + "dragToReorderPhases": "Tërhiq fazat për t'i rirenditur. Çdo fazë mund të ketë një ngjyrë të ndryshme.", + "enterNewPhaseName": "Shkruaj emrin e fazës së re...", "addPhase": "Shto Fazë", "noPhasesFound": "Nuk u gjetën faza", + "no": "Asnjë", + "found": "u gjet", "deletePhase": "Fshi Fazën", - "deletePhaseConfirm": "Jeni të sigurt që doni të fshini këtë fazë? Ky veprim nuk mund të zhbëhet.", - "rename": "Riemëro", + "deletePhaseConfirm": "Jeni i sigurt që doni të fshini këtë fazë? Ky veprim nuk mund të zhbëhet.", + "rename": "Riemërto", "delete": "Fshi", "create": "Krijo", "cancel": "Anulo", diff --git a/worklenz-frontend/public/locales/alb/task-list-filters.json b/worklenz-frontend/public/locales/alb/task-list-filters.json index e75e4802..4fc4dbdf 100644 --- a/worklenz-frontend/public/locales/alb/task-list-filters.json +++ b/worklenz-frontend/public/locales/alb/task-list-filters.json @@ -68,9 +68,10 @@ "clearing": "Po pastron...", "cancel": "Anulo", "search": "Kërko", - "groupedBy": "I grupuar sipas", - "manageStatuses": "Menaxho statuset", - "managePhases": "Menaxho fazat", + "groupedBy": "Grupuar sipas", + "manage": "Menaxho", + "manageStatuses": "Menaxho Statuset", + "managePhases": "Menaxho Fazat", "dragToReorderStatuses": "Statuset janë të organizuara sipas kategorive. Tërhiq për të rirenditur brenda kategorive. Kliko 'Shto status' për të krijuar statuse të reja në çdo kategori.", "enterNewStatusName": "Shkruani emrin e statusit të ri...", "addStatus": "Shto status", diff --git a/worklenz-frontend/public/locales/de/phases-drawer.json b/worklenz-frontend/public/locales/de/phases-drawer.json index 4a143c7f..3cdfb255 100644 --- a/worklenz-frontend/public/locales/de/phases-drawer.json +++ b/worklenz-frontend/public/locales/de/phases-drawer.json @@ -1,13 +1,17 @@ { "configurePhases": "Phasen konfigurieren", - "phaseLabel": "Phasenbezeichnung", - "enterPhaseName": "Phasennamen eingeben", + "configure": "Konfigurieren", + "phaseLabel": "Phasen-Label", + "enterPhaseName": "Phasenname eingeben", "addOption": "Option hinzufügen", "phaseOptions": "Phasenoptionen", + "optionsText": "Optionen", "dragToReorderPhases": "Ziehen Sie Phasen, um sie neu zu ordnen. Jede Phase kann eine andere Farbe haben.", "enterNewPhaseName": "Neuen Phasennamen eingeben...", "addPhase": "Phase hinzufügen", "noPhasesFound": "Keine Phasen gefunden", + "no": "Keine", + "found": "gefunden", "deletePhase": "Phase löschen", "deletePhaseConfirm": "Sind Sie sicher, dass Sie diese Phase löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", "rename": "Umbenennen", diff --git a/worklenz-frontend/public/locales/de/task-list-filters.json b/worklenz-frontend/public/locales/de/task-list-filters.json index 743f036a..18d50b6c 100644 --- a/worklenz-frontend/public/locales/de/task-list-filters.json +++ b/worklenz-frontend/public/locales/de/task-list-filters.json @@ -69,6 +69,7 @@ "cancel": "Abbrechen", "search": "Suchen", "groupedBy": "Gruppiert nach", + "manage": "Verwalten", "manageStatuses": "Status verwalten", "managePhases": "Phasen verwalten", "dragToReorderStatuses": "Status sind nach Kategorien organisiert. Ziehen Sie, um innerhalb von Kategorien neu zu ordnen. Klicken Sie auf 'Status hinzufügen', um neue Status in jeder Kategorie zu erstellen.", diff --git a/worklenz-frontend/public/locales/en/phases-drawer.json b/worklenz-frontend/public/locales/en/phases-drawer.json index 7791a08b..9eb24582 100644 --- a/worklenz-frontend/public/locales/en/phases-drawer.json +++ b/worklenz-frontend/public/locales/en/phases-drawer.json @@ -1,13 +1,17 @@ { "configurePhases": "Configure Phases", + "configure": "Configure", "phaseLabel": "Phase Label", "enterPhaseName": "Enter phase name", "addOption": "Add Option", "phaseOptions": "Phase Options", + "optionsText": "Options", "dragToReorderPhases": "Drag phases to reorder them. Each phase can have a different color.", "enterNewPhaseName": "Enter new phase name...", "addPhase": "Add Phase", "noPhasesFound": "No phases found", + "no": "No", + "found": "found", "deletePhase": "Delete Phase", "deletePhaseConfirm": "Are you sure you want to delete this phase? This action cannot be undone.", "rename": "Rename", diff --git a/worklenz-frontend/public/locales/en/task-list-filters.json b/worklenz-frontend/public/locales/en/task-list-filters.json index 36ee10dc..118ac4ce 100644 --- a/worklenz-frontend/public/locales/en/task-list-filters.json +++ b/worklenz-frontend/public/locales/en/task-list-filters.json @@ -69,6 +69,7 @@ "cancel": "Cancel", "search": "Search", "groupedBy": "Grouped by", + "manage": "Manage", "manageStatuses": "Manage Statuses", "managePhases": "Manage Phases", "dragToReorderStatuses": "Statuses are organized by categories. Drag to reorder within categories. Click 'Add Status' to create new statuses in each category.", diff --git a/worklenz-frontend/public/locales/es/phases-drawer.json b/worklenz-frontend/public/locales/es/phases-drawer.json index abb6ee81..ed912ac7 100644 --- a/worklenz-frontend/public/locales/es/phases-drawer.json +++ b/worklenz-frontend/public/locales/es/phases-drawer.json @@ -1,13 +1,17 @@ { - "configurePhases": "Configurar fases", - "phaseLabel": "Etiqueta de fase", - "enterPhaseName": "Introducir nombre de la fase", - "addOption": "Agregar opción", - "phaseOptions": "Opciones de fase", + "configurePhases": "Configurar Fases", + "configure": "Configurar", + "phaseLabel": "Etiqueta de Fase", + "enterPhaseName": "Ingresa el nombre de la fase", + "addOption": "Agregar Opción", + "phaseOptions": "Opciones de Fase", + "optionsText": "Opciones", "dragToReorderPhases": "Arrastra las fases para reordenarlas. Cada fase puede tener un color diferente.", - "enterNewPhaseName": "Introducir nuevo nombre de fase...", - "addPhase": "Añadir Fase", + "enterNewPhaseName": "Ingresa el nombre de la nueva fase...", + "addPhase": "Agregar Fase", "noPhasesFound": "No se encontraron fases", + "no": "No", + "found": "encontrado", "deletePhase": "Eliminar Fase", "deletePhaseConfirm": "¿Estás seguro de que quieres eliminar esta fase? Esta acción no se puede deshacer.", "rename": "Renombrar", diff --git a/worklenz-frontend/public/locales/es/task-list-filters.json b/worklenz-frontend/public/locales/es/task-list-filters.json index dc42706a..00c27f16 100644 --- a/worklenz-frontend/public/locales/es/task-list-filters.json +++ b/worklenz-frontend/public/locales/es/task-list-filters.json @@ -69,8 +69,9 @@ "cancel": "Cancelar", "search": "Buscar", "groupedBy": "Agrupado por", - "manageStatuses": "Gestionar estados", - "managePhases": "Gestionar fases", + "manage": "Gestionar", + "manageStatuses": "Gestionar Estados", + "managePhases": "Gestionar Fases", "dragToReorderStatuses": "Los estados están organizados por categorías. Arrastra para reordenar dentro de las categorías. Haz clic en 'Agregar estado' para crear nuevos estados en cada categoría.", "enterNewStatusName": "Ingrese el nombre del nuevo estado...", "addStatus": "Agregar estado", diff --git a/worklenz-frontend/public/locales/pt/phases-drawer.json b/worklenz-frontend/public/locales/pt/phases-drawer.json index b0ca7c51..0d5b8cb7 100644 --- a/worklenz-frontend/public/locales/pt/phases-drawer.json +++ b/worklenz-frontend/public/locales/pt/phases-drawer.json @@ -1,13 +1,17 @@ { - "configurePhases": "Configurar fases", - "phaseLabel": "Etiqueta de fase", + "configurePhases": "Configurar Fases", + "configure": "Configurar", + "phaseLabel": "Rótulo da Fase", "enterPhaseName": "Digite o nome da fase", "addOption": "Adicionar Opção", "phaseOptions": "Opções de Fase", + "optionsText": "Opções", "dragToReorderPhases": "Arraste as fases para reordená-las. Cada fase pode ter uma cor diferente.", - "enterNewPhaseName": "Digite o novo nome da fase...", + "enterNewPhaseName": "Digite o nome da nova fase...", "addPhase": "Adicionar Fase", "noPhasesFound": "Nenhuma fase encontrada", + "no": "Nenhuma", + "found": "encontrada", "deletePhase": "Excluir Fase", "deletePhaseConfirm": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.", "rename": "Renomear", diff --git a/worklenz-frontend/public/locales/pt/task-list-filters.json b/worklenz-frontend/public/locales/pt/task-list-filters.json index 49841ec5..3674a29a 100644 --- a/worklenz-frontend/public/locales/pt/task-list-filters.json +++ b/worklenz-frontend/public/locales/pt/task-list-filters.json @@ -69,8 +69,9 @@ "cancel": "Cancelar", "search": "Pesquisar", "groupedBy": "Agrupado por", - "manageStatuses": "Gerenciar status", - "managePhases": "Gerenciar fases", + "manage": "Gerenciar", + "manageStatuses": "Gerenciar Status", + "managePhases": "Gerenciar Fases", "dragToReorderStatuses": "Os status estão organizados por categorias. Arraste para reordenar dentro das categorias. Clique em 'Adicionar status' para criar novos status em cada categoria.", "enterNewStatusName": "Digite o nome do novo status...", "addStatus": "Adicionar status", diff --git a/worklenz-frontend/public/locales/zh/phases-drawer.json b/worklenz-frontend/public/locales/zh/phases-drawer.json index 8f55e527..37d68cfb 100644 --- a/worklenz-frontend/public/locales/zh/phases-drawer.json +++ b/worklenz-frontend/public/locales/zh/phases-drawer.json @@ -1,20 +1,24 @@ { - "configurePhases": "配置阶段", - "phaseLabel": "阶段标签", - "enterPhaseName": "输入阶段名称", - "addOption": "添加选项", - "phaseOptions": "阶段选项", - "dragToReorderPhases": "拖拽阶段以重新排序。每个阶段可以有不同的颜色。", - "enterNewPhaseName": "输入新阶段名称...", - "addPhase": "添加阶段", - "noPhasesFound": "未找到阶段", - "deletePhase": "删除阶段", - "deletePhaseConfirm": "您确定要删除此阶段吗?此操作无法撤销。", - "rename": "重命名", - "delete": "删除", - "create": "创建", - "cancel": "取消", - "selectColor": "选择颜色", - "managePhases": "管理阶段", - "close": "关闭" + "configurePhases": "配置阶段", + "configure": "配置", + "phaseLabel": "阶段标签", + "enterPhaseName": "输入阶段名称", + "addOption": "添加选项", + "phaseOptions": "阶段选项", + "optionsText": "选项", + "dragToReorderPhases": "拖拽阶段来重新排序。每个阶段可以有不同的颜色。", + "enterNewPhaseName": "输入新阶段名称...", + "addPhase": "添加阶段", + "noPhasesFound": "未找到阶段", + "no": "没有", + "found": "找到", + "deletePhase": "删除阶段", + "deletePhaseConfirm": "您确定要删除此阶段吗?此操作无法撤消。", + "rename": "重命名", + "delete": "删除", + "create": "创建", + "cancel": "取消", + "selectColor": "选择颜色", + "managePhases": "管理阶段", + "close": "关闭" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/task-list-filters.json b/worklenz-frontend/public/locales/zh/task-list-filters.json index 50dcb8e6..4d1d6b43 100644 --- a/worklenz-frontend/public/locales/zh/task-list-filters.json +++ b/worklenz-frontend/public/locales/zh/task-list-filters.json @@ -62,7 +62,8 @@ "clearing": "清除中...", "cancel": "取消", "search": "搜索", - "groupedBy": "分组依据", + "groupedBy": "分组方式", + "manage": "管理", "manageStatuses": "管理状态", "managePhases": "管理阶段", "dragToReorderStatuses": "拖拽状态以重新排序。每个状态可以有不同的类别。", diff --git a/worklenz-frontend/src/api/taskAttributes/phases/phases.api.service.ts b/worklenz-frontend/src/api/taskAttributes/phases/phases.api.service.ts index 3c494049..fc47544a 100644 --- a/worklenz-frontend/src/api/taskAttributes/phases/phases.api.service.ts +++ b/worklenz-frontend/src/api/taskAttributes/phases/phases.api.service.ts @@ -1,12 +1,12 @@ -import apiClient from '@/api/api-client'; -import { API_BASE_URL } from '@/shared/constants'; import { IServerResponse } from '@/types/common.types'; +import apiClient from '@api/api-client'; +import { API_BASE_URL } from '@/shared/constants'; import { ITaskPhase } from '@/types/tasks/taskPhase.types'; import { toQueryString } from '@/utils/toQueryString'; const rootUrl = `${API_BASE_URL}/task-phases`; -interface UpdateSortOrderBody { +export interface UpdateSortOrderBody { from_index: number; to_index: number; phases: ITaskPhase[]; @@ -14,9 +14,10 @@ interface UpdateSortOrderBody { } export const phasesApiService = { - addPhaseOption: async (projectId: string) => { + addPhaseOption: async (projectId: string, name?: string) => { const q = toQueryString({ id: projectId, current_project_id: projectId }); - const response = await apiClient.post>(`${rootUrl}${q}`); + const body = name ? { name } : {}; + const response = await apiClient.post>(`${rootUrl}${q}`, body); return response.data; }, diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx index 1c4d7087..eb9b87cc 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx @@ -139,7 +139,7 @@ const EnhancedKanbanCreateSection: React.FC = () => { } if (groupBy === IGroupBy.PHASE) { try { - const response = await phasesApiService.addPhaseOption(projectId); + const response = await phasesApiService.addPhaseOption(projectId, name); if (response.done && response.body) { dispatch(fetchEnhancedKanbanGroups(projectId)); } diff --git a/worklenz-frontend/src/components/task-management/ManagePhaseModal.css b/worklenz-frontend/src/components/task-management/ManagePhaseModal.css index e36bcd73..4d75b424 100644 --- a/worklenz-frontend/src/components/task-management/ManagePhaseModal.css +++ b/worklenz-frontend/src/components/task-management/ManagePhaseModal.css @@ -20,6 +20,112 @@ border-top: 1px solid #303030; } +/* Dark mode confirmation modal styling */ +.dark .ant-modal-confirm .ant-modal-content, +[data-theme="dark"] .ant-modal-confirm .ant-modal-content { + background-color: #1f1f1f !important; + border: 1px solid #303030 !important; +} + +.dark .ant-modal-confirm .ant-modal-header, +[data-theme="dark"] .ant-modal-confirm .ant-modal-header { + background-color: #1f1f1f !important; + border-bottom: 1px solid #303030 !important; +} + +.dark .ant-modal-confirm .ant-modal-body, +[data-theme="dark"] .ant-modal-confirm .ant-modal-body { + background-color: #1f1f1f !important; + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-modal-footer, +[data-theme="dark"] .ant-modal-confirm .ant-modal-footer { + background-color: #1f1f1f !important; + border-top: 1px solid #303030 !important; +} + +.dark .ant-modal-confirm .ant-modal-confirm-title, +[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-title { + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-modal-confirm-content, +[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-content { + color: #8c8c8c !important; +} + +.dark .ant-modal-confirm .ant-btn-default, +[data-theme="dark"] .ant-modal-confirm .ant-btn-default { + background-color: #141414 !important; + border-color: #303030 !important; + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-btn-default:hover, +[data-theme="dark"] .ant-modal-confirm .ant-btn-default:hover { + background-color: #262626 !important; + border-color: #40a9ff !important; + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-btn-primary, +[data-theme="dark"] .ant-modal-confirm .ant-btn-primary { + background-color: #1890ff !important; + border-color: #1890ff !important; + color: #ffffff !important; +} + +.dark .ant-modal-confirm .ant-btn-primary:hover, +[data-theme="dark"] .ant-modal-confirm .ant-btn-primary:hover { + background-color: #40a9ff !important; + border-color: #40a9ff !important; + color: #ffffff !important; +} + +.dark .ant-modal-confirm .ant-btn-dangerous, +[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous { + background-color: #ff4d4f !important; + border-color: #ff4d4f !important; + color: #ffffff !important; +} + +.dark .ant-modal-confirm .ant-btn-dangerous:hover, +[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous:hover { + background-color: #ff7875 !important; + border-color: #ff7875 !important; + color: #ffffff !important; +} + +/* Light mode confirmation modal styling (ensure consistency) */ +.ant-modal-confirm .ant-modal-content { + background-color: #ffffff; + border: 1px solid #f0f0f0; +} + +.ant-modal-confirm .ant-modal-header { + background-color: #ffffff; + border-bottom: 1px solid #f0f0f0; +} + +.ant-modal-confirm .ant-modal-body { + background-color: #ffffff; + color: #262626; +} + +.ant-modal-confirm .ant-modal-footer { + background-color: #ffffff; + border-top: 1px solid #f0f0f0; +} + +.ant-modal-confirm .ant-modal-confirm-title { + color: #262626; +} + +.ant-modal-confirm .ant-modal-confirm-content { + color: #595959; +} + .dark-modal .ant-form-item-label > label { color: #d9d9d9; } diff --git a/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx b/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx index 81a44378..e746851c 100644 --- a/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx +++ b/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx @@ -18,6 +18,7 @@ import { deletePhaseOption, updatePhaseColor, } from '@/features/projects/singleProject/phase/phases.slice'; +import { updatePhaseLabel } from '@/features/project/project.slice'; import { ITaskPhase } from '@/types/tasks/taskPhase.types'; import { Modal as AntModal } from 'antd'; import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; @@ -307,7 +308,7 @@ const ManagePhaseModal: React.FC = ({ if (!newPhaseName.trim() || !finalProjectId) return; try { - await dispatch(addPhaseOption({ projectId: finalProjectId })); + await dispatch(addPhaseOption({ projectId: finalProjectId, name: newPhaseName.trim() })); await dispatch(fetchPhasesByProjectId(finalProjectId)); await refreshTasks(); setNewPhaseName(''); @@ -408,6 +409,7 @@ const ManagePhaseModal: React.FC = ({ ).unwrap(); if (res.done) { + dispatch(updatePhaseLabel(phaseName)); setInitialPhaseName(phaseName); await refreshTasks(); } @@ -428,7 +430,7 @@ const ManagePhaseModal: React.FC = ({ - {t('configurePhases')} + {t('configure')} {phaseName || project?.phase_label || t('phasesText')} } open={open} @@ -495,7 +497,7 @@ const ManagePhaseModal: React.FC = ({ - 🎨 Drag phases to reorder them. Click on a phase name to rename it. Each phase can have a custom color. + 🎨 Drag {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} to reorder them. Click on a {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} name to rename it. Each {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} can have a custom color.
@@ -558,7 +560,7 @@ const ManagePhaseModal: React.FC = ({ - {t('phaseOptions')} + {phaseName || project?.phase_label || t('phasesText')} {t('optionsText')} )} {section.selectedValues[0] === 'status' && ( @@ -994,6 +996,7 @@ const ImprovedTaskFilters: React.FC = ({ position, cla const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark'); const { projectId } = useAppSelector(state => state.projectReducer); const { projectView } = useTabSearchParam(); + const projectPhaseLabel = useAppSelector(state => state.projectReducer.project?.phase_label); // Theme-aware class names - memoize to prevent unnecessary re-renders // Using greyish colors for both dark and light modes @@ -1298,6 +1301,7 @@ const ImprovedTaskFilters: React.FC = ({ position, cla dispatch={dispatch} onManageStatus={() => setShowManageStatusModal(true)} onManagePhase={() => setShowManagePhaseModal(true)} + projectPhaseLabel={projectPhaseLabel} /> )) ) : ( diff --git a/worklenz-frontend/src/features/projects/singleProject/phase/PhaseHeader.tsx b/worklenz-frontend/src/features/projects/singleProject/phase/PhaseHeader.tsx index 4607ae35..e10df057 100644 --- a/worklenz-frontend/src/features/projects/singleProject/phase/PhaseHeader.tsx +++ b/worklenz-frontend/src/features/projects/singleProject/phase/PhaseHeader.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useSelectedProject } from '../../../../hooks/useSelectedProject'; import { useAppSelector } from '../../../../hooks/useAppSelector'; import { Flex } from 'antd'; import ConfigPhaseButton from './ConfigPhaseButton'; @@ -10,19 +9,13 @@ const PhaseHeader = () => { // localization const { t } = useTranslation('task-list-filters'); - // get selected project for useSelectedProject hook - const selectedProject = useSelectedProject(); - - // get phase data from redux - const phaseList = useAppSelector(state => state.phaseReducer.phaseList); - - //get phases details from phases slice - const phase = phaseList.find(el => el.projectId === selectedProject?.projectId); + // get project data from redux + const { project } = useAppSelector(state => state.projectReducer); return ( - {phase?.phase || t('phasesText')} - + {project?.phase_label || t('phasesText')} + ); }; diff --git a/worklenz-frontend/src/features/projects/singleProject/phase/phases.slice.ts b/worklenz-frontend/src/features/projects/singleProject/phase/phases.slice.ts index 050c5765..304a1635 100644 --- a/worklenz-frontend/src/features/projects/singleProject/phase/phases.slice.ts +++ b/worklenz-frontend/src/features/projects/singleProject/phase/phases.slice.ts @@ -16,9 +16,9 @@ const initialState: PhaseState = { export const addPhaseOption = createAsyncThunk( 'phase/addPhaseOption', - async ({ projectId }: { projectId: string }, { rejectWithValue }) => { + async ({ projectId, name }: { projectId: string; name?: string }, { rejectWithValue }) => { try { - const response = await phasesApiService.addPhaseOption(projectId); + const response = await phasesApiService.addPhaseOption(projectId, name); return response; } catch (error) { return rejectWithValue(error); diff --git a/worklenz-frontend/src/pages/projects/projectView/board/board-section/board-section-card/board-create-section-card.tsx b/worklenz-frontend/src/pages/projects/projectView/board/board-section/board-section-card/board-create-section-card.tsx index 5abae368..7e61385e 100644 --- a/worklenz-frontend/src/pages/projects/projectView/board/board-section/board-section-card/board-create-section-card.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/board/board-section/board-section-card/board-create-section-card.tsx @@ -106,13 +106,8 @@ const BoardCreateSectionCard = () => { } if (groupBy === IGroupBy.PHASE && projectId) { - const body = { - name: sectionName, - project_id: projectId, - }; - try { - const response = await phasesApiService.addPhaseOption(projectId); + const response = await phasesApiService.addPhaseOption(projectId, sectionName); if (response.done && response.body) { dispatch(fetchBoardTaskGroups(projectId)); } diff --git a/worklenz-frontend/src/styles/task-management.css b/worklenz-frontend/src/styles/task-management.css index 1cfc2669..c4982dac 100644 --- a/worklenz-frontend/src/styles/task-management.css +++ b/worklenz-frontend/src/styles/task-management.css @@ -4,6 +4,152 @@ width: 100%; } +/* Global Confirmation Modal Styles */ +/* Light mode confirmation modal styling (default) */ +.ant-modal-confirm .ant-modal-content { + background-color: #ffffff; + border: 1px solid #f0f0f0; +} + +.ant-modal-confirm .ant-modal-header { + background-color: #ffffff; + border-bottom: 1px solid #f0f0f0; +} + +.ant-modal-confirm .ant-modal-body { + background-color: #ffffff; + color: #262626; +} + +.ant-modal-confirm .ant-modal-footer { + background-color: #ffffff; + border-top: 1px solid #f0f0f0; +} + +.ant-modal-confirm .ant-modal-confirm-title { + color: #262626; +} + +.ant-modal-confirm .ant-modal-confirm-content { + color: #595959; +} + +/* Dark mode confirmation modal styling */ +.dark .ant-modal-confirm .ant-modal-content, +[data-theme="dark"] .ant-modal-confirm .ant-modal-content, +html.dark .ant-modal-confirm .ant-modal-content { + background-color: #1f1f1f !important; + border: 1px solid #303030 !important; +} + +.dark .ant-modal-confirm .ant-modal-header, +[data-theme="dark"] .ant-modal-confirm .ant-modal-header, +html.dark .ant-modal-confirm .ant-modal-header { + background-color: #1f1f1f !important; + border-bottom: 1px solid #303030 !important; +} + +.dark .ant-modal-confirm .ant-modal-body, +[data-theme="dark"] .ant-modal-confirm .ant-modal-body, +html.dark .ant-modal-confirm .ant-modal-body { + background-color: #1f1f1f !important; + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-modal-footer, +[data-theme="dark"] .ant-modal-confirm .ant-modal-footer, +html.dark .ant-modal-confirm .ant-modal-footer { + background-color: #1f1f1f !important; + border-top: 1px solid #303030 !important; +} + +.dark .ant-modal-confirm .ant-modal-confirm-title, +[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-title, +html.dark .ant-modal-confirm .ant-modal-confirm-title { + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-modal-confirm-content, +[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-content, +html.dark .ant-modal-confirm .ant-modal-confirm-content { + color: #8c8c8c !important; +} + +.dark .ant-modal-confirm .ant-btn-default, +[data-theme="dark"] .ant-modal-confirm .ant-btn-default, +html.dark .ant-modal-confirm .ant-btn-default { + background-color: #141414 !important; + border-color: #303030 !important; + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-btn-default:hover, +[data-theme="dark"] .ant-modal-confirm .ant-btn-default:hover, +html.dark .ant-modal-confirm .ant-btn-default:hover { + background-color: #262626 !important; + border-color: #40a9ff !important; + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-btn-primary, +[data-theme="dark"] .ant-modal-confirm .ant-btn-primary, +html.dark .ant-modal-confirm .ant-btn-primary { + background-color: #1890ff !important; + border-color: #1890ff !important; + color: #ffffff !important; +} + +.dark .ant-modal-confirm .ant-btn-primary:hover, +[data-theme="dark"] .ant-modal-confirm .ant-btn-primary:hover, +html.dark .ant-modal-confirm .ant-btn-primary:hover { + background-color: #40a9ff !important; + border-color: #40a9ff !important; + color: #ffffff !important; +} + +.dark .ant-modal-confirm .ant-btn-dangerous, +[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous, +html.dark .ant-modal-confirm .ant-btn-dangerous { + background-color: #ff4d4f !important; + border-color: #ff4d4f !important; + color: #ffffff !important; +} + +.dark .ant-modal-confirm .ant-btn-dangerous:hover, +[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous:hover, +html.dark .ant-modal-confirm .ant-btn-dangerous:hover { + background-color: #ff7875 !important; + border-color: #ff7875 !important; + color: #ffffff !important; +} + +/* Error modal specific styling */ +.dark .ant-modal-error .ant-modal-content, +[data-theme="dark"] .ant-modal-error .ant-modal-content, +html.dark .ant-modal-error .ant-modal-content { + background-color: #1f1f1f !important; + border: 1px solid #303030 !important; +} + +.dark .ant-modal-error .ant-modal-body, +[data-theme="dark"] .ant-modal-error .ant-modal-body, +html.dark .ant-modal-error .ant-modal-body { + background-color: #1f1f1f !important; + color: #d9d9d9 !important; +} + +.dark .ant-modal-error .ant-modal-confirm-title, +[data-theme="dark"] .ant-modal-error .ant-modal-confirm-title, +html.dark .ant-modal-error .ant-modal-confirm-title { + color: #d9d9d9 !important; +} + +.dark .ant-modal-error .ant-modal-confirm-content, +[data-theme="dark"] .ant-modal-error .ant-modal-content, +html.dark .ant-modal-error .ant-modal-confirm-content { + color: #8c8c8c !important; +} + .task-group { transition: all 0.2s ease; } From 747088e7ccf089fad5c473cb97db17a4db6998d4 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 17:37:22 +0530 Subject: [PATCH 46/49] refactor(task-management): enhance empty state visuals and improve layout - Updated the empty state message styling in VirtualizedTaskList and TaskGroup components for better visibility and user experience. - Adjusted padding and height in various components to create a more consistent and visually appealing layout. - Removed console log statements from TaskPhaseDropdown to clean up the codebase. --- .../enhanced-kanban/VirtualizedTaskList.tsx | 13 +++++++++++-- .../kanban-board-management-v2/kanbanGroup.tsx | 2 +- .../src/components/task-list-v2/TaskListV2Table.tsx | 6 ++++-- .../src/components/task-management/task-group.tsx | 4 ++-- .../task-management/task-phase-dropdown.tsx | 4 ---- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/VirtualizedTaskList.tsx b/worklenz-frontend/src/components/enhanced-kanban/VirtualizedTaskList.tsx index c6486b62..7269bf2e 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/VirtualizedTaskList.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/VirtualizedTaskList.tsx @@ -73,8 +73,17 @@ const VirtualizedTaskList: React.FC = ({ if (tasks.length === 0) { return ( -
-
No tasks in this group
+
+
+ No tasks in this group +
); } diff --git a/worklenz-frontend/src/components/kanban-board-management-v2/kanbanGroup.tsx b/worklenz-frontend/src/components/kanban-board-management-v2/kanbanGroup.tsx index 3409237e..98f57505 100644 --- a/worklenz-frontend/src/components/kanban-board-management-v2/kanbanGroup.tsx +++ b/worklenz-frontend/src/components/kanban-board-management-v2/kanbanGroup.tsx @@ -173,7 +173,7 @@ const KanbanGroup: React.FC = ({ .kanban-group-empty { text-align: center; color: #bfbfbf; - padding: 32px 0; + padding: 48px 16px; } .kanban-group-add-task { padding: 12px; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 8d3c8452..7ef747ed 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -54,6 +54,7 @@ import { setCustomColumnModalAttributes, toggleCustomColumnModalOpen, } from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice'; +import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; // Components import TaskRowWithSubtasks from './TaskRowWithSubtasks'; @@ -212,6 +213,7 @@ const TaskListV2Section: React.FC = () => { if (urlProjectId) { dispatch(fetchTasksV3(urlProjectId)); dispatch(fetchTaskListColumns(urlProjectId)); + dispatch(fetchPhasesByProjectId(urlProjectId)); } }, [dispatch, urlProjectId]); @@ -463,7 +465,7 @@ const TaskListV2Section: React.FC = () => { /> {isGroupEmpty && !isGroupCollapsed && (
-
+
{visibleColumns.map((column, index) => { const emptyColumnStyle = { width: column.width, @@ -482,7 +484,7 @@ const TaskListV2Section: React.FC = () => { })}
-
+
{t('noTasksInGroup')}
diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index 07d0b679..335f2815 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -312,7 +312,7 @@ const TaskGroup: React.FC = React.memo( {groupTasks.length === 0 ? (
-
+
No tasks in this group
@@ -487,7 +487,7 @@ const TaskGroup: React.FC = React.memo( .task-group-empty { display: flex; - height: 80px; + height: 120px; align-items: center; background: var(--task-bg-primary, white); transition: background-color 0.3s ease; diff --git a/worklenz-frontend/src/components/task-management/task-phase-dropdown.tsx b/worklenz-frontend/src/components/task-management/task-phase-dropdown.tsx index 49eacc87..536215e0 100644 --- a/worklenz-frontend/src/components/task-management/task-phase-dropdown.tsx +++ b/worklenz-frontend/src/components/task-management/task-phase-dropdown.tsx @@ -35,8 +35,6 @@ const TaskPhaseDropdown: React.FC = ({ (phaseId: string, phaseName: string) => { if (!task.id || !phaseId || !connected) return; - console.log('🎯 Phase change initiated:', { taskId: task.id, phaseId, phaseName }); - socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), { task_id: task.id, phase_id: phaseId, @@ -51,8 +49,6 @@ const TaskPhaseDropdown: React.FC = ({ const handlePhaseClear = useCallback(() => { if (!task.id || !connected) return; - console.log('🎯 Phase clear initiated:', { taskId: task.id }); - socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), { task_id: task.id, phase_id: null, From a26d8d0f90ab4c654d44022499cd4f5fca58cf61 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 18:09:03 +0530 Subject: [PATCH 47/49] feat(task-management): enhance task localization and progress visualization - Added localization entries for task statuses (To Do, In Progress, Done) across multiple languages including Albanian, German, Spanish, Portuguese, and Chinese. - Updated the GroupProgressBar component to improve visual representation of task progress with distinct color coding for each status. - Enhanced TaskGroupHeader to calculate and display group progress dynamically based on task completion and status distribution. - Integrated a new Convert To Subtask Drawer for improved task management functionality. --- .../public/locales/alb/task-management.json | 19 ++++- .../public/locales/de/task-management.json | 19 ++++- .../public/locales/en/task-management.json | 3 + .../public/locales/es/task-management.json | 19 ++++- .../public/locales/pt/task-management.json | 19 ++++- .../public/locales/zh/task-management.json | 3 + .../task-list-v2/GroupProgressBar.tsx | 18 ++--- .../task-list-v2/TaskGroupHeader.tsx | 81 +++++++++++++++++-- .../task-list-v2/TaskListV2Table.tsx | 4 + .../components/TaskContextMenu.tsx | 44 +++++++++- 10 files changed, 207 insertions(+), 22 deletions(-) diff --git a/worklenz-frontend/public/locales/alb/task-management.json b/worklenz-frontend/public/locales/alb/task-management.json index a156ef3f..d7ae3f13 100644 --- a/worklenz-frontend/public/locales/alb/task-management.json +++ b/worklenz-frontend/public/locales/alb/task-management.json @@ -17,5 +17,22 @@ "renamePhase": "Riemërto Fazën", "changeCategory": "Ndrysho Kategorinë", "clickToEditGroupName": "Kliko për të ndryshuar emrin e grupit", - "enterGroupName": "Shkruani emrin e grupit" + "enterGroupName": "Shkruani emrin e grupit", + "todo": "Për t'u bërë", + "inProgress": "Në progres", + "done": "E kryer", + + "indicators": { + "tooltips": { + "subtasks": "{{count}} nën-detyrë", + "subtasks_plural": "{{count}} nën-detyra", + "comments": "{{count}} koment", + "comments_plural": "{{count}} komente", + "attachments": "{{count}} bashkëngjitje", + "attachments_plural": "{{count}} bashkëngjitje", + "subscribers": "Detyra ka abonentë", + "dependencies": "Detyra ka varësi", + "recurring": "Detyrë përsëritëse" + } + } } diff --git a/worklenz-frontend/public/locales/de/task-management.json b/worklenz-frontend/public/locales/de/task-management.json index b20d94a4..1bbdf7c9 100644 --- a/worklenz-frontend/public/locales/de/task-management.json +++ b/worklenz-frontend/public/locales/de/task-management.json @@ -17,5 +17,22 @@ "renamePhase": "Phase umbenennen", "changeCategory": "Kategorie ändern", "clickToEditGroupName": "Klicken Sie, um den Gruppennamen zu bearbeiten", - "enterGroupName": "Gruppennamen eingeben" + "enterGroupName": "Gruppennamen eingeben", + "todo": "Zu erledigen", + "inProgress": "In Bearbeitung", + "done": "Erledigt", + + "indicators": { + "tooltips": { + "subtasks": "{{count}} Unteraufgabe", + "subtasks_plural": "{{count}} Unteraufgaben", + "comments": "{{count}} Kommentar", + "comments_plural": "{{count}} Kommentare", + "attachments": "{{count}} Anhang", + "attachments_plural": "{{count}} Anhänge", + "subscribers": "Aufgabe hat Abonnenten", + "dependencies": "Aufgabe hat Abhängigkeiten", + "recurring": "Wiederkehrende Aufgabe" + } + } } diff --git a/worklenz-frontend/public/locales/en/task-management.json b/worklenz-frontend/public/locales/en/task-management.json index 2d21c746..99b8b0d5 100644 --- a/worklenz-frontend/public/locales/en/task-management.json +++ b/worklenz-frontend/public/locales/en/task-management.json @@ -18,6 +18,9 @@ "changeCategory": "Change Category", "clickToEditGroupName": "Click to edit group name", "enterGroupName": "Enter group name", + "todo": "To Do", + "inProgress": "Doing", + "done": "Done", "indicators": { "tooltips": { diff --git a/worklenz-frontend/public/locales/es/task-management.json b/worklenz-frontend/public/locales/es/task-management.json index 1c80304c..e28569d1 100644 --- a/worklenz-frontend/public/locales/es/task-management.json +++ b/worklenz-frontend/public/locales/es/task-management.json @@ -17,5 +17,22 @@ "renamePhase": "Renombrar Fase", "changeCategory": "Cambiar Categoría", "clickToEditGroupName": "Haz clic para editar el nombre del grupo", - "enterGroupName": "Ingresa el nombre del grupo" + "enterGroupName": "Ingresa el nombre del grupo", + "todo": "Por Hacer", + "inProgress": "En Progreso", + "done": "Hecho", + + "indicators": { + "tooltips": { + "subtasks": "{{count}} subtarea", + "subtasks_plural": "{{count}} subtareas", + "comments": "{{count}} comentario", + "comments_plural": "{{count}} comentarios", + "attachments": "{{count}} adjunto", + "attachments_plural": "{{count}} adjuntos", + "subscribers": "La tarea tiene suscriptores", + "dependencies": "La tarea tiene dependencias", + "recurring": "Tarea recurrente" + } + } } diff --git a/worklenz-frontend/public/locales/pt/task-management.json b/worklenz-frontend/public/locales/pt/task-management.json index 946b3162..24beb53c 100644 --- a/worklenz-frontend/public/locales/pt/task-management.json +++ b/worklenz-frontend/public/locales/pt/task-management.json @@ -17,5 +17,22 @@ "renamePhase": "Renomear Fase", "changeCategory": "Alterar Categoria", "clickToEditGroupName": "Clique para editar o nome do grupo", - "enterGroupName": "Digite o nome do grupo" + "enterGroupName": "Digite o nome do grupo", + "todo": "A Fazer", + "inProgress": "Em Andamento", + "done": "Concluído", + + "indicators": { + "tooltips": { + "subtasks": "{{count}} subtarefa", + "subtasks_plural": "{{count}} subtarefas", + "comments": "{{count}} comentário", + "comments_plural": "{{count}} comentários", + "attachments": "{{count}} anexo", + "attachments_plural": "{{count}} anexos", + "subscribers": "Tarefa tem assinantes", + "dependencies": "Tarefa tem dependências", + "recurring": "Tarefa recorrente" + } + } } diff --git a/worklenz-frontend/public/locales/zh/task-management.json b/worklenz-frontend/public/locales/zh/task-management.json index 341ecc64..7f185e34 100644 --- a/worklenz-frontend/public/locales/zh/task-management.json +++ b/worklenz-frontend/public/locales/zh/task-management.json @@ -18,6 +18,9 @@ "changeCategory": "更改类别", "clickToEditGroupName": "点击编辑组名称", "enterGroupName": "输入组名称", + "todo": "待办", + "inProgress": "进行中", + "done": "已完成", "indicators": { "tooltips": { diff --git a/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx b/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx index fd280bdf..a8623d27 100644 --- a/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx +++ b/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx @@ -38,26 +38,26 @@ const GroupProgressBar: React.FC = ({ {/* Compact progress bar */}
- {/* Todo section - light gray */} + {/* Todo section - light green */} {todoProgress > 0 && (
)} - {/* Doing section - blue */} + {/* Doing section - medium green */} {doingProgress > 0 && (
)} - {/* Done section - green */} + {/* Done section - dark green */} {doneProgress > 0 && (
@@ -69,19 +69,19 @@ const GroupProgressBar: React.FC = ({
{todoProgress > 0 && (
)} {doingProgress > 0 && (
)} {doneProgress > 0 && (
)} diff --git a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx index 0b25be2e..d3f2e5b7 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx @@ -9,7 +9,7 @@ import { getContrastColor } from '@/utils/colorUtils'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { selectSelectedTaskIds, selectTask, deselectTask } from '@/features/task-management/selection.slice'; -import { selectGroups, fetchTasksV3 } from '@/features/task-management/task-management.slice'; +import { selectGroups, fetchTasksV3, selectAllTasksArray } from '@/features/task-management/task-management.slice'; import { selectCurrentGrouping } from '@/features/task-management/grouping.slice'; import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; @@ -43,8 +43,9 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o const dispatch = useAppDispatch(); const selectedTaskIds = useAppSelector(selectSelectedTaskIds); const groups = useAppSelector(selectGroups); + const allTasks = useAppSelector(selectAllTasksArray); const currentGrouping = useAppSelector(selectCurrentGrouping); - const { statusCategories } = useAppSelector(state => state.taskStatusReducer); + const { statusCategories, status: statusList } = useAppSelector(state => state.taskStatusReducer); const { trackMixpanelEvent } = useMixpanelTracking(); const { isOwnerOrAdmin } = useAuthService(); @@ -67,6 +68,74 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o return currentGroup?.taskIds || []; }, [currentGroup]); + // Calculate group progress values dynamically + const groupProgressValues = useMemo(() => { + if (!currentGroup || !allTasks.length) { + return { todoProgress: 0, doingProgress: 0, doneProgress: 0 }; + } + + const tasksInCurrentGroup = currentGroup.taskIds + .map(taskId => allTasks.find(task => task.id === taskId)) + .filter(task => task !== undefined); + + if (tasksInCurrentGroup.length === 0) { + return { todoProgress: 0, doingProgress: 0, doneProgress: 0 }; + } + + // If we're grouping by status, show progress based on task completion + if (currentGrouping === 'status') { + // For status grouping, calculate based on task progress values + const progressStats = tasksInCurrentGroup.reduce((acc, task) => { + const progress = task.progress || 0; + if (progress === 0) { + acc.todo += 1; + } else if (progress === 100) { + acc.done += 1; + } else { + acc.doing += 1; + } + return acc; + }, { todo: 0, doing: 0, done: 0 }); + + const totalTasks = tasksInCurrentGroup.length; + + return { + todoProgress: totalTasks > 0 ? Math.round((progressStats.todo / totalTasks) * 100) : 0, + doingProgress: totalTasks > 0 ? Math.round((progressStats.doing / totalTasks) * 100) : 0, + doneProgress: totalTasks > 0 ? Math.round((progressStats.done / totalTasks) * 100) : 0, + }; + } else { + // For priority/phase grouping, show progress based on status distribution + // Use a simplified approach based on status names and common patterns + const statusCounts = tasksInCurrentGroup.reduce((acc, task) => { + // Find the status by ID first + const statusInfo = statusList.find(s => s.id === task.status); + const statusName = statusInfo?.name?.toLowerCase() || task.status?.toLowerCase() || ''; + + // Categorize based on common status name patterns + if (statusName.includes('todo') || statusName.includes('to do') || statusName.includes('pending') || statusName.includes('open') || statusName.includes('backlog')) { + acc.todo += 1; + } else if (statusName.includes('doing') || statusName.includes('progress') || statusName.includes('active') || statusName.includes('working') || statusName.includes('development')) { + acc.doing += 1; + } else if (statusName.includes('done') || statusName.includes('completed') || statusName.includes('finished') || statusName.includes('closed') || statusName.includes('resolved')) { + acc.done += 1; + } else { + // Default unknown statuses to "doing" (in progress) + acc.doing += 1; + } + return acc; + }, { todo: 0, doing: 0, done: 0 }); + + const totalTasks = tasksInCurrentGroup.length; + + return { + todoProgress: totalTasks > 0 ? Math.round((statusCounts.todo / totalTasks) * 100) : 0, + doingProgress: totalTasks > 0 ? Math.round((statusCounts.doing / totalTasks) * 100) : 0, + doneProgress: totalTasks > 0 ? Math.round((statusCounts.done / totalTasks) * 100) : 0, + }; + } + }, [currentGroup, allTasks, statusList, currentGrouping]); + // Calculate selection state for this group const { isAllSelected, isPartiallySelected } = useMemo(() => { if (tasksInGroup.length === 0) { @@ -369,7 +438,7 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o {/* Progress Bar - sticky to the right edge during horizontal scroll */} {(currentGrouping === 'priority' || currentGrouping === 'phase') && - (group.todo_progress || group.doing_progress || group.done_progress) && ( + (groupProgressValues.todoProgress || groupProgressValues.doingProgress || groupProgressValues.doneProgress) && (
= ({ group, isCollapsed, o }} >
diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 7ef747ed..624ff623 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -65,6 +65,7 @@ import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-t import AddTaskRow from './components/AddTaskRow'; import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomColumnComponents'; import TaskListSkeleton from './components/TaskListSkeleton'; +import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer'; // Hooks and utilities import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; @@ -766,6 +767,9 @@ const TaskListV2Section: React.FC = () => { {/* Custom Column Modal */} {createPortal(, document.body, 'custom-column-modal')} + + {/* Convert To Subtask Drawer */} + {createPortal(, document.body, 'convert-to-subtask-drawer')}
); diff --git a/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx b/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx index 0982dafa..28b0e119 100644 --- a/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx +++ b/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx @@ -16,8 +16,8 @@ import { toggleTaskExpansion, updateTaskAssignees, } from '@/features/task-management/task-management.slice'; -import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; -import { setConvertToSubtaskDrawerOpen } from '@/features/task-drawer/task-drawer.slice'; +import { deselectAll, selectTasks } from '@/features/projects/bulkActions/bulkActionSlice'; +import { setConvertToSubtaskDrawerOpen } from '@/features/tasks/tasks.slice'; import { useTranslation } from 'react-i18next'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import { @@ -412,7 +412,45 @@ const TaskContextMenu: React.FC = ({ key: 'convertToSubTask', label: (