Merge pull request #301 from Worklenz/fix/reporting-sidebar-style-fix
Fix/reporting sidebar style fix
This commit is contained in:
@@ -2297,3 +2297,60 @@ ALTER TABLE organization_working_days
|
|||||||
ALTER TABLE organization_working_days
|
ALTER TABLE organization_working_days
|
||||||
ADD CONSTRAINT org_organization_id_fk
|
ADD CONSTRAINT org_organization_id_fk
|
||||||
FOREIGN KEY (organization_id) REFERENCES organizations;
|
FOREIGN KEY (organization_id) REFERENCES organizations;
|
||||||
|
|
||||||
|
-- Survey tables for account setup questionnaire
|
||||||
|
CREATE TABLE IF NOT EXISTS surveys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
survey_type VARCHAR(50) DEFAULT 'account_setup' NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_questions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
question_key VARCHAR(100) NOT NULL,
|
||||||
|
question_type VARCHAR(50) NOT NULL,
|
||||||
|
is_required BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||||
|
options JSONB,
|
||||||
|
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_responses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
is_completed BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
started_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_answers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
response_id UUID REFERENCES survey_responses(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
question_id UUID REFERENCES survey_questions(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
answer_text TEXT,
|
||||||
|
answer_json JSONB,
|
||||||
|
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Survey table indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_surveys_type_active ON surveys(survey_type, is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_survey_questions_survey_order ON survey_questions(survey_id, sort_order);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_survey_responses_user_survey ON survey_responses(user_id, survey_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_survey_responses_completed ON survey_responses(survey_id, is_completed);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_survey_answers_response ON survey_answers(response_id);
|
||||||
|
|
||||||
|
-- Survey table constraints
|
||||||
|
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_sort_order_check CHECK (sort_order >= 0);
|
||||||
|
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_type_check CHECK (question_type IN ('single_choice', 'multiple_choice', 'text'));
|
||||||
|
ALTER TABLE survey_responses ADD CONSTRAINT unique_user_survey_response UNIQUE (user_id, survey_id);
|
||||||
|
ALTER TABLE survey_answers ADD CONSTRAINT unique_response_question_answer UNIQUE (response_id, question_id);
|
||||||
|
|||||||
@@ -142,3 +142,25 @@ DROP FUNCTION sys_insert_license_types();
|
|||||||
INSERT INTO timezones (name, abbrev, utc_offset)
|
INSERT INTO timezones (name, abbrev, utc_offset)
|
||||||
SELECT name, abbrev, utc_offset
|
SELECT name, abbrev, utc_offset
|
||||||
FROM pg_timezone_names;
|
FROM pg_timezone_names;
|
||||||
|
|
||||||
|
-- Insert default account setup survey
|
||||||
|
INSERT INTO surveys (name, description, survey_type, is_active) VALUES
|
||||||
|
('Account Setup Survey', 'Initial questionnaire during account setup to understand user needs', 'account_setup', true)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Insert survey questions for account setup survey
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
survey_uuid UUID;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO survey_uuid FROM surveys WHERE survey_type = 'account_setup' AND name = 'Account Setup Survey' LIMIT 1;
|
||||||
|
|
||||||
|
-- Insert survey questions
|
||||||
|
INSERT INTO survey_questions (survey_id, question_key, question_type, is_required, sort_order, options) VALUES
|
||||||
|
(survey_uuid, 'organization_type', 'single_choice', true, 1, '["freelancer", "startup", "small_medium_business", "agency", "enterprise", "other"]'),
|
||||||
|
(survey_uuid, 'user_role', 'single_choice', true, 2, '["founder_ceo", "project_manager", "software_developer", "designer", "operations", "other"]'),
|
||||||
|
(survey_uuid, 'main_use_cases', 'multiple_choice', true, 3, '["task_management", "team_collaboration", "resource_planning", "client_communication", "time_tracking", "other"]'),
|
||||||
|
(survey_uuid, 'previous_tools', 'text', false, 4, null),
|
||||||
|
(survey_uuid, 'how_heard_about', 'single_choice', false, 5, '["google_search", "twitter", "linkedin", "friend_colleague", "blog_article", "other"]')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END $$;
|
||||||
|
|||||||
@@ -81,5 +81,12 @@
|
|||||||
"delete": "Fshi",
|
"delete": "Fshi",
|
||||||
"enterStatusName": "Shkruani emrin e statusit",
|
"enterStatusName": "Shkruani emrin e statusit",
|
||||||
"selectCategory": "Zgjidh kategorinë",
|
"selectCategory": "Zgjidh kategorinë",
|
||||||
"close": "Mbyll"
|
"close": "Mbyll",
|
||||||
|
"clearSort": "Pastro Renditjen",
|
||||||
|
"sortAscending": "Rendit në Rritje",
|
||||||
|
"sortDescending": "Rendit në Zbritje",
|
||||||
|
"sortByField": "Rendit sipas {{field}}",
|
||||||
|
"ascendingOrder": "Rritës",
|
||||||
|
"descendingOrder": "Zbritës",
|
||||||
|
"currentSort": "Renditja aktuale: {{field}} {{order}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,5 +81,12 @@
|
|||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"enterStatusName": "Statusnamen eingeben",
|
"enterStatusName": "Statusnamen eingeben",
|
||||||
"selectCategory": "Kategorie auswählen",
|
"selectCategory": "Kategorie auswählen",
|
||||||
"close": "Schließen"
|
"close": "Schließen",
|
||||||
|
"clearSort": "Sortierung löschen",
|
||||||
|
"sortAscending": "Aufsteigend sortieren",
|
||||||
|
"sortDescending": "Absteigend sortieren",
|
||||||
|
"sortByField": "Sortieren nach {{field}}",
|
||||||
|
"ascendingOrder": "Aufsteigend",
|
||||||
|
"descendingOrder": "Absteigend",
|
||||||
|
"currentSort": "Aktuelle Sortierung: {{field}} {{order}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,5 +81,12 @@
|
|||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"enterStatusName": "Enter status name",
|
"enterStatusName": "Enter status name",
|
||||||
"selectCategory": "Select category",
|
"selectCategory": "Select category",
|
||||||
"close": "Close"
|
"close": "Close",
|
||||||
|
"clearSort": "Clear Sort",
|
||||||
|
"sortAscending": "Sort Ascending",
|
||||||
|
"sortDescending": "Sort Descending",
|
||||||
|
"sortByField": "Sort by {{field}}",
|
||||||
|
"ascendingOrder": "Ascending",
|
||||||
|
"descendingOrder": "Descending",
|
||||||
|
"currentSort": "Current sort: {{field}} {{order}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,5 +77,12 @@
|
|||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"enterStatusName": "Introducir nombre del estado",
|
"enterStatusName": "Introducir nombre del estado",
|
||||||
"selectCategory": "Seleccionar categoría",
|
"selectCategory": "Seleccionar categoría",
|
||||||
"close": "Cerrar"
|
"close": "Cerrar",
|
||||||
|
"clearSort": "Limpiar Ordenamiento",
|
||||||
|
"sortAscending": "Ordenar Ascendente",
|
||||||
|
"sortDescending": "Ordenar Descendente",
|
||||||
|
"sortByField": "Ordenar por {{field}}",
|
||||||
|
"ascendingOrder": "Ascendente",
|
||||||
|
"descendingOrder": "Descendente",
|
||||||
|
"currentSort": "Ordenamiento actual: {{field}} {{order}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,5 +78,12 @@
|
|||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"enterStatusName": "Digite o nome do status",
|
"enterStatusName": "Digite o nome do status",
|
||||||
"selectCategory": "Selecionar categoria",
|
"selectCategory": "Selecionar categoria",
|
||||||
"close": "Fechar"
|
"close": "Fechar",
|
||||||
|
"clearSort": "Limpar Ordenação",
|
||||||
|
"sortAscending": "Ordenar Crescente",
|
||||||
|
"sortDescending": "Ordenar Decrescente",
|
||||||
|
"sortByField": "Ordenar por {{field}}",
|
||||||
|
"ascendingOrder": "Crescente",
|
||||||
|
"descendingOrder": "Decrescente",
|
||||||
|
"currentSort": "Ordenação atual: {{field}} {{order}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,5 +75,12 @@
|
|||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"enterStatusName": "输入状态名称",
|
"enterStatusName": "输入状态名称",
|
||||||
"selectCategory": "选择类别",
|
"selectCategory": "选择类别",
|
||||||
"close": "关闭"
|
"close": "关闭",
|
||||||
|
"clearSort": "清除排序",
|
||||||
|
"sortAscending": "升序排列",
|
||||||
|
"sortDescending": "降序排列",
|
||||||
|
"sortByField": "按{{field}}排序",
|
||||||
|
"ascendingOrder": "升序",
|
||||||
|
"descendingOrder": "降序",
|
||||||
|
"currentSort": "当前排序:{{field}} {{order}}"
|
||||||
}
|
}
|
||||||
@@ -84,5 +84,12 @@
|
|||||||
"close": "Mbyll",
|
"close": "Mbyll",
|
||||||
"cannotMoveStatus": "Nuk mund të lëvizet statusi",
|
"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.",
|
"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"
|
"ok": "OK",
|
||||||
|
"clearSort": "Pastro Renditjen",
|
||||||
|
"sortAscending": "Rendit në Rritje",
|
||||||
|
"sortDescending": "Rendit në Zbritje",
|
||||||
|
"sortByField": "Rendit sipas {{field}}",
|
||||||
|
"ascendingOrder": "Rritës",
|
||||||
|
"descendingOrder": "Zbritës",
|
||||||
|
"currentSort": "Renditja aktuale: {{field}} {{order}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,5 +84,12 @@
|
|||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"cannotMoveStatus": "Status kann nicht verschoben werden",
|
"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.",
|
"cannotMoveStatusMessage": "Dieser Status kann nicht verschoben werden, da die Kategorie '{{categoryName}}' leer bleiben würde. Jede Kategorie muss mindestens einen Status haben.",
|
||||||
"ok": "OK"
|
"ok": "OK",
|
||||||
|
"clearSort": "Sortierung löschen",
|
||||||
|
"sortAscending": "Aufsteigend sortieren",
|
||||||
|
"sortDescending": "Absteigend sortieren",
|
||||||
|
"sortByField": "Sortieren nach {{field}}",
|
||||||
|
"ascendingOrder": "Aufsteigend",
|
||||||
|
"descendingOrder": "Absteigend",
|
||||||
|
"currentSort": "Aktuelle Sortierung: {{field}} {{order}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,5 +84,12 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"cannotMoveStatus": "Cannot Move Status",
|
"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.",
|
"cannotMoveStatusMessage": "Cannot move this status because it would leave the '{{categoryName}}' category empty. Each category must have at least one status.",
|
||||||
"ok": "OK"
|
"ok": "OK",
|
||||||
|
"clearSort": "Clear Sort",
|
||||||
|
"sortAscending": "Sort Ascending",
|
||||||
|
"sortDescending": "Sort Descending",
|
||||||
|
"sortByField": "Sort by {{field}}",
|
||||||
|
"ascendingOrder": "Ascending",
|
||||||
|
"descendingOrder": "Descending",
|
||||||
|
"currentSort": "Current sort: {{field}} {{order}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,5 +84,12 @@
|
|||||||
"close": "Cerrar",
|
"close": "Cerrar",
|
||||||
"cannotMoveStatus": "No se puede mover el estado",
|
"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.",
|
"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"
|
"ok": "OK",
|
||||||
|
"clearSort": "Limpiar Ordenamiento",
|
||||||
|
"sortAscending": "Ordenar Ascendente",
|
||||||
|
"sortDescending": "Ordenar Descendente",
|
||||||
|
"sortByField": "Ordenar por {{field}}",
|
||||||
|
"ascendingOrder": "Ascendente",
|
||||||
|
"descendingOrder": "Descendente",
|
||||||
|
"currentSort": "Ordenamiento actual: {{field}} {{order}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,5 +84,12 @@
|
|||||||
"close": "Fechar",
|
"close": "Fechar",
|
||||||
"cannotMoveStatus": "Não é possível mover o status",
|
"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.",
|
"cannotMoveStatusMessage": "Não é possível mover este status porque deixaria a categoria '{{categoryName}}' vazia. Cada categoria deve ter pelo menos um status.",
|
||||||
"ok": "OK"
|
"ok": "OK",
|
||||||
|
"clearSort": "Limpar Ordenação",
|
||||||
|
"sortAscending": "Ordenar Crescente",
|
||||||
|
"sortDescending": "Ordenar Decrescente",
|
||||||
|
"sortByField": "Ordenar por {{field}}",
|
||||||
|
"ascendingOrder": "Crescente",
|
||||||
|
"descendingOrder": "Decrescente",
|
||||||
|
"currentSort": "Ordenação atual: {{field}} {{order}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,5 +79,12 @@
|
|||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
"cannotMoveStatus": "无法移动状态",
|
"cannotMoveStatus": "无法移动状态",
|
||||||
"cannotMoveStatusMessage": "无法移动此状态,因为这会使\"{{categoryName}}\"类别为空。每个类别必须至少有一个状态。",
|
"cannotMoveStatusMessage": "无法移动此状态,因为这会使\"{{categoryName}}\"类别为空。每个类别必须至少有一个状态。",
|
||||||
"ok": "确定"
|
"ok": "确定",
|
||||||
|
"clearSort": "清除排序",
|
||||||
|
"sortAscending": "升序排列",
|
||||||
|
"sortDescending": "降序排列",
|
||||||
|
"sortByField": "按{{field}}排序",
|
||||||
|
"ascendingOrder": "升序",
|
||||||
|
"descendingOrder": "降序",
|
||||||
|
"currentSort": "当前排序:{{field}} {{order}}"
|
||||||
}
|
}
|
||||||
@@ -364,7 +364,7 @@ interface ReporterColumnProps {
|
|||||||
export const ReporterColumn: React.FC<ReporterColumnProps> = memo(({ width, reporter }) => (
|
export const ReporterColumn: React.FC<ReporterColumnProps> = memo(({ width, reporter }) => (
|
||||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||||
{reporter ? (
|
{reporter ? (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{reporter}</span>
|
<span className="text-sm text-gray-500 dark:text-gray-400 truncate" title={reporter}>{reporter}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">-</span>
|
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">-</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
InboxOutlined,
|
InboxOutlined,
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
|
SortAscendingOutlined,
|
||||||
|
SortDescendingOutlined,
|
||||||
} from '@/shared/antd-imports';
|
} from '@/shared/antd-imports';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
@@ -30,6 +32,12 @@ import {
|
|||||||
setArchived as setTaskManagementArchived,
|
setArchived as setTaskManagementArchived,
|
||||||
toggleArchived as toggleTaskManagementArchived,
|
toggleArchived as toggleTaskManagementArchived,
|
||||||
selectArchived,
|
selectArchived,
|
||||||
|
setSort,
|
||||||
|
setSortField,
|
||||||
|
setSortOrder,
|
||||||
|
selectSort,
|
||||||
|
selectSortField,
|
||||||
|
selectSortOrder,
|
||||||
} from '@/features/task-management/task-management.slice';
|
} from '@/features/task-management/task-management.slice';
|
||||||
import {
|
import {
|
||||||
setCurrentGrouping,
|
setCurrentGrouping,
|
||||||
@@ -44,11 +52,13 @@ import {
|
|||||||
setLabels,
|
setLabels,
|
||||||
setSearch,
|
setSearch,
|
||||||
setPriorities,
|
setPriorities,
|
||||||
|
setFields,
|
||||||
} from '@/features/tasks/tasks.slice';
|
} from '@/features/tasks/tasks.slice';
|
||||||
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||||
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
||||||
import { ITaskListColumn } from '@/types/tasks/taskList.types';
|
import { ITaskListColumn } from '@/types/tasks/taskList.types';
|
||||||
import { IGroupBy } from '@/features/tasks/tasks.slice';
|
import { IGroupBy } from '@/features/tasks/tasks.slice';
|
||||||
|
import { ITaskListSortableColumn } from '@/types/tasks/taskListFilters.types';
|
||||||
// --- Enhanced Kanban imports ---
|
// --- Enhanced Kanban imports ---
|
||||||
import {
|
import {
|
||||||
setGroupBy as setKanbanGroupBy,
|
setGroupBy as setKanbanGroupBy,
|
||||||
@@ -84,6 +94,12 @@ const FILTER_DEBOUNCE_DELAY = 300; // ms
|
|||||||
const SEARCH_DEBOUNCE_DELAY = 500; // ms
|
const SEARCH_DEBOUNCE_DELAY = 500; // ms
|
||||||
const MAX_FILTER_OPTIONS = 100;
|
const MAX_FILTER_OPTIONS = 100;
|
||||||
|
|
||||||
|
// Sort order enum
|
||||||
|
enum SORT_ORDER {
|
||||||
|
ASCEND = 'ascend',
|
||||||
|
DESCEND = 'descend',
|
||||||
|
}
|
||||||
|
|
||||||
// Limit options to prevent UI lag
|
// Limit options to prevent UI lag
|
||||||
|
|
||||||
// Optimized selectors with proper transformation logic
|
// Optimized selectors with proper transformation logic
|
||||||
@@ -740,6 +756,192 @@ const SearchFilter: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sort Dropdown Component - Simplified version using task-management slice
|
||||||
|
const SortDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||||
|
themeClasses,
|
||||||
|
isDarkMode,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('task-list-filters');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||||
|
|
||||||
|
// Get current sort state from task-management slice
|
||||||
|
const currentSortField = useAppSelector(selectSortField);
|
||||||
|
const currentSortOrder = useAppSelector(selectSortOrder);
|
||||||
|
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const sortFieldsList = [
|
||||||
|
{ label: t('taskText'), key: 'name' },
|
||||||
|
{ label: t('statusText'), key: 'status' },
|
||||||
|
{ label: t('priorityText'), key: 'priority' },
|
||||||
|
{ label: t('startDateText'), key: 'start_date' },
|
||||||
|
{ label: t('endDateText'), key: 'end_date' },
|
||||||
|
{ label: t('completedDateText'), key: 'completed_at' },
|
||||||
|
{ label: t('createdDateText'), key: 'created_at' },
|
||||||
|
{ label: t('lastUpdatedText'), key: 'updated_at' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSortFieldChange = (fieldKey: string) => {
|
||||||
|
// If clicking the same field, toggle order, otherwise set new field with ASC
|
||||||
|
if (currentSortField === fieldKey) {
|
||||||
|
const newOrder = currentSortOrder === 'ASC' ? 'DESC' : 'ASC';
|
||||||
|
dispatch(setSort({ field: fieldKey, order: newOrder }));
|
||||||
|
} else {
|
||||||
|
dispatch(setSort({ field: fieldKey, order: 'ASC' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch updated tasks
|
||||||
|
if (projectId) {
|
||||||
|
dispatch(fetchTasksV3(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSort = () => {
|
||||||
|
dispatch(setSort({ field: '', order: 'ASC' }));
|
||||||
|
if (projectId) {
|
||||||
|
dispatch(fetchTasksV3(projectId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActive = currentSortField !== '';
|
||||||
|
const currentFieldLabel = sortFieldsList.find(f => f.key === currentSortField)?.label;
|
||||||
|
const orderText = currentSortOrder === 'ASC' ? t('ascendingOrder') : t('descendingOrder');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
{/* Trigger Button - matching FilterDropdown style */}
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
title={
|
||||||
|
isActive
|
||||||
|
? t('currentSort', { field: currentFieldLabel, order: orderText })
|
||||||
|
: t('sortText')
|
||||||
|
}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md
|
||||||
|
border transition-all duration-200 ease-in-out
|
||||||
|
${
|
||||||
|
isActive
|
||||||
|
? isDarkMode
|
||||||
|
? 'bg-gray-600 text-white border-gray-500'
|
||||||
|
: 'bg-gray-200 text-gray-800 border-gray-300 font-semibold'
|
||||||
|
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
|
||||||
|
}
|
||||||
|
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2
|
||||||
|
${isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
|
||||||
|
`}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
{currentSortOrder === 'ASC' ? (
|
||||||
|
<SortAscendingOutlined className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<SortDescendingOutlined className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">{t('sortText')}</span>
|
||||||
|
{isActive && currentFieldLabel && (
|
||||||
|
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-600'} max-w-16 truncate hidden md:inline`}>
|
||||||
|
{currentFieldLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<DownOutlined
|
||||||
|
className={`w-3.5 h-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Panel - matching FilterDropdown style */}
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className={`absolute top-full left-0 z-50 mt-1 w-64 ${themeClasses.dropdownBg} rounded-md shadow-sm border ${themeClasses.dropdownBorder}`}
|
||||||
|
>
|
||||||
|
{/* Clear Sort Option */}
|
||||||
|
{isActive && (
|
||||||
|
<div className={`p-2 border-b ${themeClasses.dividerBorder}`}>
|
||||||
|
<button
|
||||||
|
onClick={clearSort}
|
||||||
|
className={`w-full text-left px-2 py-1.5 text-xs rounded transition-colors duration-150 ${themeClasses.optionText} ${themeClasses.optionHover}`}
|
||||||
|
>
|
||||||
|
{t('clearSort')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Options List */}
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
<div className="p-0.5">
|
||||||
|
{sortFieldsList.map(sortField => {
|
||||||
|
const isSelected = currentSortField === sortField.key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={sortField.key}
|
||||||
|
onClick={() => handleSortFieldChange(sortField.key)}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center justify-between gap-2 px-2 py-1.5 text-xs rounded
|
||||||
|
transition-colors duration-150 text-left
|
||||||
|
${
|
||||||
|
isSelected
|
||||||
|
? isDarkMode
|
||||||
|
? 'bg-gray-600 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-800 font-semibold'
|
||||||
|
: `${themeClasses.optionText} ${themeClasses.optionHover}`
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title={
|
||||||
|
isSelected
|
||||||
|
? t('currentSort', {
|
||||||
|
field: sortField.label,
|
||||||
|
order: orderText
|
||||||
|
}) + ` - ${t('sortDescending')}`
|
||||||
|
: t('sortByField', { field: sortField.label }) + ` - ${t('sortAscending')}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate">{sortField.label}</span>
|
||||||
|
{isSelected && (
|
||||||
|
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||||
|
({orderText})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{isSelected ? (
|
||||||
|
currentSortOrder === 'ASC' ? (
|
||||||
|
<SortAscendingOutlined className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<SortDescendingOutlined className="w-3.5 h-3.5" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<SortAscendingOutlined className="w-3.5 h-3.5 opacity-50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const LOCAL_STORAGE_KEY = 'worklenz.taskManagement.fields';
|
const LOCAL_STORAGE_KEY = 'worklenz.taskManagement.fields';
|
||||||
|
|
||||||
const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||||
@@ -1050,14 +1252,20 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
};
|
};
|
||||||
}, [dispatch, projectView]);
|
}, [dispatch, projectView]);
|
||||||
|
|
||||||
|
// Get sort fields for active count calculation
|
||||||
|
const sortFields = useAppSelector(state => state.taskReducer.fields);
|
||||||
|
const taskManagementSortField = useAppSelector(selectSortField);
|
||||||
|
|
||||||
// Calculate active filters count - memoized to prevent unnecessary recalculations
|
// Calculate active filters count - memoized to prevent unnecessary recalculations
|
||||||
const calculatedActiveFiltersCount = useMemo(() => {
|
const calculatedActiveFiltersCount = useMemo(() => {
|
||||||
const count = filterSections.reduce(
|
const count = filterSections.reduce(
|
||||||
(acc, section) => (section.id === 'groupBy' ? acc : acc + section.selectedValues.length),
|
(acc, section) => (section.id === 'groupBy' ? acc : acc + section.selectedValues.length),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
return count + (searchValue ? 1 : 0);
|
const sortFieldsCount = position === 'list' ? sortFields.length : 0;
|
||||||
}, [filterSections, searchValue]);
|
const taskManagementSortCount = position === 'list' && taskManagementSortField ? 1 : 0;
|
||||||
|
return count + (searchValue ? 1 : 0) + sortFieldsCount + taskManagementSortCount;
|
||||||
|
}, [filterSections, searchValue, sortFields, taskManagementSortField, position]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeFiltersCount !== calculatedActiveFiltersCount) {
|
if (activeFiltersCount !== calculatedActiveFiltersCount) {
|
||||||
@@ -1231,6 +1439,12 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
// Clear priority filters
|
// Clear priority filters
|
||||||
dispatch(setPriorities([]));
|
dispatch(setPriorities([]));
|
||||||
|
|
||||||
|
// Clear sort fields
|
||||||
|
dispatch(setFields([]));
|
||||||
|
|
||||||
|
// Clear sort from task-management slice
|
||||||
|
dispatch(setSort({ field: '', order: 'ASC' }));
|
||||||
|
|
||||||
// Clear archived state based on position
|
// Clear archived state based on position
|
||||||
if (position === 'list') {
|
if (position === 'list') {
|
||||||
dispatch(setTaskManagementArchived(false));
|
dispatch(setTaskManagementArchived(false));
|
||||||
@@ -1276,9 +1490,9 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
<div
|
<div
|
||||||
className={`${themeClasses.containerBg} border ${themeClasses.containerBorder} rounded-md p-1.5 shadow-sm ${className}`}
|
className={`${themeClasses.containerBg} border ${themeClasses.containerBorder} rounded-md p-1.5 shadow-sm ${className}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2 min-h-[36px]">
|
||||||
{/* Left Section - Main Filters */}
|
{/* Left Section - Main Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2 flex-1 min-w-0">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<SearchFilter
|
<SearchFilter
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
@@ -1287,6 +1501,11 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
themeClasses={themeClasses}
|
themeClasses={themeClasses}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Sort Filter Button (for list view) - appears after search */}
|
||||||
|
{position === 'list' && (
|
||||||
|
<SortDropdown themeClasses={themeClasses} isDarkMode={isDarkMode} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filter Dropdowns - Only render when data is loaded */}
|
{/* Filter Dropdowns - Only render when data is loaded */}
|
||||||
{isDataLoaded ? (
|
{isDataLoaded ? (
|
||||||
filterSectionsData.map(section => (
|
filterSectionsData.map(section => (
|
||||||
@@ -1316,7 +1535,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Section - Additional Controls */}
|
{/* Right Section - Additional Controls */}
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
<div className="flex flex-wrap items-center gap-2 ml-auto min-w-0 shrink-0">
|
||||||
{/* Active Filters Indicator */}
|
{/* Active Filters Indicator */}
|
||||||
{activeFiltersCount > 0 && (
|
{activeFiltersCount > 0 && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ const initialState: TaskManagementState = {
|
|||||||
loadingColumns: false,
|
loadingColumns: false,
|
||||||
columns: [],
|
columns: [],
|
||||||
customColumns: [],
|
customColumns: [],
|
||||||
|
// Add sort-related state
|
||||||
|
sortField: '',
|
||||||
|
sortOrder: 'ASC',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Async thunk to fetch tasks from API
|
// Async thunk to fetch tasks from API
|
||||||
@@ -233,12 +236,16 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
// Get archived state from task management slice
|
// Get archived state from task management slice
|
||||||
const archivedState = state.taskManagement.archived;
|
const archivedState = state.taskManagement.archived;
|
||||||
|
|
||||||
|
// Get sort state from task management slice
|
||||||
|
const sortField = state.taskManagement.sortField;
|
||||||
|
const sortOrder = state.taskManagement.sortOrder;
|
||||||
|
|
||||||
const config: ITaskListConfigV2 = {
|
const config: ITaskListConfigV2 = {
|
||||||
id: projectId,
|
id: projectId,
|
||||||
archived: archivedState,
|
archived: archivedState,
|
||||||
group: currentGrouping || '',
|
group: currentGrouping || '',
|
||||||
field: '',
|
field: sortField,
|
||||||
order: '',
|
order: sortOrder,
|
||||||
search: searchValue,
|
search: searchValue,
|
||||||
statuses: '',
|
statuses: '',
|
||||||
members: selectedAssignees,
|
members: selectedAssignees,
|
||||||
@@ -737,6 +744,16 @@ const taskManagementSlice = createSlice({
|
|||||||
toggleArchived: (state) => {
|
toggleArchived: (state) => {
|
||||||
state.archived = !state.archived;
|
state.archived = !state.archived;
|
||||||
},
|
},
|
||||||
|
setSortField: (state, action: PayloadAction<string>) => {
|
||||||
|
state.sortField = action.payload;
|
||||||
|
},
|
||||||
|
setSortOrder: (state, action: PayloadAction<'ASC' | 'DESC'>) => {
|
||||||
|
state.sortOrder = action.payload;
|
||||||
|
},
|
||||||
|
setSort: (state, action: PayloadAction<{ field: string; order: 'ASC' | 'DESC' }>) => {
|
||||||
|
state.sortField = action.payload.field;
|
||||||
|
state.sortOrder = action.payload.order;
|
||||||
|
},
|
||||||
resetTaskManagement: state => {
|
resetTaskManagement: state => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
@@ -745,6 +762,8 @@ const taskManagementSlice = createSlice({
|
|||||||
state.selectedPriorities = [];
|
state.selectedPriorities = [];
|
||||||
state.search = '';
|
state.search = '';
|
||||||
state.archived = false;
|
state.archived = false;
|
||||||
|
state.sortField = '';
|
||||||
|
state.sortOrder = 'ASC';
|
||||||
state.ids = [];
|
state.ids = [];
|
||||||
state.entities = {};
|
state.entities = {};
|
||||||
},
|
},
|
||||||
@@ -1129,6 +1148,9 @@ export const {
|
|||||||
setSearch,
|
setSearch,
|
||||||
setArchived,
|
setArchived,
|
||||||
toggleArchived,
|
toggleArchived,
|
||||||
|
setSortField,
|
||||||
|
setSortOrder,
|
||||||
|
setSort,
|
||||||
resetTaskManagement,
|
resetTaskManagement,
|
||||||
toggleTaskExpansion,
|
toggleTaskExpansion,
|
||||||
addSubtaskToParent,
|
addSubtaskToParent,
|
||||||
@@ -1160,6 +1182,9 @@ export const selectLoading = (state: RootState) => state.taskManagement.loading;
|
|||||||
export const selectError = (state: RootState) => state.taskManagement.error;
|
export const selectError = (state: RootState) => state.taskManagement.error;
|
||||||
export const selectSelectedPriorities = (state: RootState) => state.taskManagement.selectedPriorities;
|
export const selectSelectedPriorities = (state: RootState) => state.taskManagement.selectedPriorities;
|
||||||
export const selectSearch = (state: RootState) => state.taskManagement.search;
|
export const selectSearch = (state: RootState) => state.taskManagement.search;
|
||||||
|
export const selectSortField = (state: RootState) => state.taskManagement.sortField;
|
||||||
|
export const selectSortOrder = (state: RootState) => state.taskManagement.sortOrder;
|
||||||
|
export const selectSort = (state: RootState) => ({ field: state.taskManagement.sortField, order: state.taskManagement.sortOrder });
|
||||||
export const selectSubtaskLoading = (state: RootState, taskId: string) => state.taskManagement.loadingSubtasks[taskId] || false;
|
export const selectSubtaskLoading = (state: RootState, taskId: string) => state.taskManagement.loadingSubtasks[taskId] || false;
|
||||||
|
|
||||||
// Memoized selectors to prevent unnecessary re-renders
|
// Memoized selectors to prevent unnecessary re-renders
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ export interface TaskManagementState {
|
|||||||
loadingColumns: boolean;
|
loadingColumns: boolean;
|
||||||
columns: ITaskListColumn[];
|
columns: ITaskListColumn[];
|
||||||
customColumns: ITaskListColumn[];
|
customColumns: ITaskListColumn[];
|
||||||
|
// Add sort-related state
|
||||||
|
sortField: string;
|
||||||
|
sortOrder: 'ASC' | 'DESC';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskGroupsState {
|
export interface TaskGroupsState {
|
||||||
|
|||||||
Reference in New Issue
Block a user