Merge pull request #236 from shancds/test/row-kanban-board-v1.1.2
Test/row kanban board v1.1.2
This commit is contained in:
@@ -21,5 +21,10 @@
|
|||||||
"newTaskNamePlaceholder": "Shkruaj emrin e detyrës",
|
"newTaskNamePlaceholder": "Shkruaj emrin e detyrës",
|
||||||
"newSubtaskNamePlaceholder": "Shkruaj emrin e nëndetyrës",
|
"newSubtaskNamePlaceholder": "Shkruaj emrin e nëndetyrës",
|
||||||
"untitledSection": "Seksion pa titull",
|
"untitledSection": "Seksion pa titull",
|
||||||
"unmapped": "Pa hartë"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,10 @@
|
|||||||
"newTaskNamePlaceholder": "Aufgabenname eingeben",
|
"newTaskNamePlaceholder": "Aufgabenname eingeben",
|
||||||
"newSubtaskNamePlaceholder": "Unteraufgabenname eingeben",
|
"newSubtaskNamePlaceholder": "Unteraufgabenname eingeben",
|
||||||
"untitledSection": "Unbenannter Abschnitt",
|
"untitledSection": "Unbenannter Abschnitt",
|
||||||
"unmapped": "Nicht zugeordnet"
|
"unmapped": "Nicht zugeordnet",
|
||||||
|
"clickToChangeDate": "Klicken Sie, um das Datum zu ändern",
|
||||||
|
"noDueDate": "Kein Fälligkeitsdatum",
|
||||||
|
"save": "Speichern",
|
||||||
|
"clear": "Löschen",
|
||||||
|
"nextWeek": "Nächste Woche"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,10 @@
|
|||||||
"newTaskNamePlaceholder": "Write a task Name",
|
"newTaskNamePlaceholder": "Write a task Name",
|
||||||
"newSubtaskNamePlaceholder": "Write a subtask Name",
|
"newSubtaskNamePlaceholder": "Write a subtask Name",
|
||||||
"untitledSection": "Untitled section",
|
"untitledSection": "Untitled section",
|
||||||
"unmapped": "Unmapped"
|
"unmapped": "Unmapped",
|
||||||
|
"clickToChangeDate": "Click to change date",
|
||||||
|
"noDueDate": "No due date",
|
||||||
|
"save": "Save",
|
||||||
|
"clear": "Clear",
|
||||||
|
"nextWeek": "Next week"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,10 @@
|
|||||||
"newTaskNamePlaceholder": "Escribe un nombre de tarea",
|
"newTaskNamePlaceholder": "Escribe un nombre de tarea",
|
||||||
"newSubtaskNamePlaceholder": "Escribe un nombre de subtarea",
|
"newSubtaskNamePlaceholder": "Escribe un nombre de subtarea",
|
||||||
"untitledSection": "Sección sin título",
|
"untitledSection": "Sección sin título",
|
||||||
"unmapped": "Sin asignar"
|
"unmapped": "Sin asignar",
|
||||||
|
"clickToChangeDate": "Haz clic para cambiar la fecha",
|
||||||
|
"noDueDate": "Sin fecha de vencimiento",
|
||||||
|
"save": "Guardar",
|
||||||
|
"clear": "Limpiar",
|
||||||
|
"nextWeek": "Próxima semana"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,10 @@
|
|||||||
"newTaskNamePlaceholder": "Escreva um nome de tarefa",
|
"newTaskNamePlaceholder": "Escreva um nome de tarefa",
|
||||||
"newSubtaskNamePlaceholder": "Escreva um nome de subtarefa",
|
"newSubtaskNamePlaceholder": "Escreva um nome de subtarefa",
|
||||||
"untitledSection": "Seção sem título",
|
"untitledSection": "Seção sem título",
|
||||||
"unmapped": "Não mapeado"
|
"unmapped": "Não mapeado",
|
||||||
|
"clickToChangeDate": "Clique para alterar a data",
|
||||||
|
"noDueDate": "Sem data de vencimento",
|
||||||
|
"save": "Salvar",
|
||||||
|
"clear": "Limpar",
|
||||||
|
"nextWeek": "Próxima semana"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ interface AssigneeSelectorProps {
|
|||||||
task: IProjectTask;
|
task: IProjectTask;
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
isDarkMode?: boolean;
|
isDarkMode?: boolean;
|
||||||
|
kanbanMode?: boolean; // <-- Add this prop
|
||||||
}
|
}
|
||||||
|
|
||||||
const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||||
task,
|
task,
|
||||||
groupId = null,
|
groupId = null,
|
||||||
isDarkMode = false,
|
isDarkMode = false,
|
||||||
|
kanbanMode = false, // <-- Default to false
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -212,6 +214,9 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
assigneeIds: newAssigneeIds,
|
assigneeIds: newAssigneeIds,
|
||||||
assigneeNames: updatedAssigneeNames,
|
assigneeNames: updatedAssigneeNames,
|
||||||
}));
|
}));
|
||||||
|
if (kanbanMode) {
|
||||||
|
dispatch(updateEnhancedKanbanTaskAssignees(data));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove from pending changes after a short delay (optimistic)
|
// Remove from pending changes after a short delay (optimistic)
|
||||||
|
|||||||
@@ -216,18 +216,17 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
|||||||
toSortOrder = -1;
|
toSortOrder = -1;
|
||||||
toLastIndex = true;
|
toLastIndex = true;
|
||||||
} else if (targetGroup.tasks[insertIdx]) {
|
} else if (targetGroup.tasks[insertIdx]) {
|
||||||
toSortOrder = typeof targetGroup.tasks[insertIdx].sort_order === 'number'
|
const sortOrder = targetGroup.tasks[insertIdx].sort_order;
|
||||||
? targetGroup.tasks[insertIdx].sort_order
|
toSortOrder = typeof sortOrder === 'number' ? sortOrder : 0;
|
||||||
: -1;
|
|
||||||
toLastIndex = false;
|
toLastIndex = false;
|
||||||
} else if (targetGroup.tasks.length > 0) {
|
} else if (targetGroup.tasks.length > 0) {
|
||||||
const lastSortOrder = targetGroup.tasks[targetGroup.tasks.length - 1].sort_order;
|
const lastSortOrder = targetGroup.tasks[targetGroup.tasks.length - 1].sort_order;
|
||||||
toSortOrder = typeof lastSortOrder === 'number' ? lastSortOrder : -1;
|
toSortOrder = typeof lastSortOrder === 'number' ? lastSortOrder : 0;
|
||||||
toLastIndex = false;
|
toLastIndex = false;
|
||||||
}
|
}
|
||||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
from_index: movedTask.sort_order,
|
from_index: movedTask.sort_order ?? 0,
|
||||||
to_index: toSortOrder,
|
to_index: toSortOrder,
|
||||||
to_last_index: toLastIndex,
|
to_last_index: toLastIndex,
|
||||||
from_group: sourceGroup.id,
|
from_group: sourceGroup.id,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { memo, useCallback } from 'react';
|
import React, { memo, useCallback, useState, useRef, useEffect } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
@@ -8,6 +8,19 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import AvatarGroup from '@/components/AvatarGroup';
|
import AvatarGroup from '@/components/AvatarGroup';
|
||||||
import LazyAssigneeSelectorWrapper from '@/components/task-management/lazy-assignee-selector';
|
import LazyAssigneeSelectorWrapper from '@/components/task-management/lazy-assignee-selector';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { getUserSession } from '@/utils/session-helper';
|
||||||
|
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||||
|
import { toggleTaskExpansion, fetchBoardSubTasks } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||||
|
|
||||||
|
// Simple Portal component
|
||||||
|
const Portal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const portalRoot = document.getElementById('portal-root') || document.body;
|
||||||
|
return createPortal(children, portalRoot);
|
||||||
|
};
|
||||||
|
|
||||||
interface TaskCardProps {
|
interface TaskCardProps {
|
||||||
task: IProjectTask;
|
task: IProjectTask;
|
||||||
@@ -19,6 +32,14 @@ interface TaskCardProps {
|
|||||||
idx: number;
|
idx: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDaysInMonth(year: number, month: number) {
|
||||||
|
return new Date(year, month + 1, 0).getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirstDayOfWeek(year: number, month: number) {
|
||||||
|
return new Date(year, month, 1).getDay();
|
||||||
|
}
|
||||||
|
|
||||||
const TaskCard: React.FC<TaskCardProps> = memo(({
|
const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||||
task,
|
task,
|
||||||
onTaskDragStart,
|
onTaskDragStart,
|
||||||
@@ -28,19 +49,156 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
|||||||
isDropIndicator,
|
isDropIndicator,
|
||||||
idx
|
idx
|
||||||
}) => {
|
}) => {
|
||||||
|
const { socket } = useSocket();
|
||||||
const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
|
const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
|
||||||
|
const { projectId } = useSelector((state: RootState) => state.projectReducer);
|
||||||
const background = themeMode === 'dark' ? '#23272f' : '#fff';
|
const background = themeMode === 'dark' ? '#23272f' : '#fff';
|
||||||
const color = themeMode === 'dark' ? '#fff' : '#23272f';
|
const color = themeMode === 'dark' ? '#fff' : '#23272f';
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { t } = useTranslation('kanban-board');
|
const { t } = useTranslation('kanban-board');
|
||||||
|
|
||||||
|
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | null>(
|
||||||
|
task.end_date ? new Date(task.end_date) : null
|
||||||
|
);
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
const datePickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dateButtonRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const [calendarMonth, setCalendarMonth] = useState(() => {
|
||||||
|
const d = selectedDate || new Date();
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||||
|
});
|
||||||
|
const [showSubtasks, setShowSubtasks] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedDate(task.end_date ? new Date(task.end_date) : null);
|
||||||
|
}, [task.end_date]);
|
||||||
|
|
||||||
|
// Close date picker when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowDatePicker(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showDatePicker) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [showDatePicker]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showDatePicker && dateButtonRef.current) {
|
||||||
|
const rect = dateButtonRef.current.getBoundingClientRect();
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + window.scrollY,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [showDatePicker]);
|
||||||
|
|
||||||
const handleCardClick = useCallback((e: React.MouseEvent, id: string) => {
|
const handleCardClick = useCallback((e: React.MouseEvent, id: string) => {
|
||||||
// Prevent the event from propagating to parent elements
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dispatch(setSelectedTaskId(id));
|
dispatch(setSelectedTaskId(id));
|
||||||
dispatch(setShowTaskDrawer(true));
|
dispatch(setShowTaskDrawer(true));
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleDateClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowDatePicker(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDateChange = useCallback(
|
||||||
|
(date: Date | null) => {
|
||||||
|
if (!task.id || !projectId) return;
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
setSelectedDate(date);
|
||||||
|
socket?.emit(
|
||||||
|
SocketEvents.TASK_END_DATE_CHANGE.toString(),
|
||||||
|
JSON.stringify({
|
||||||
|
task_id: task.id,
|
||||||
|
end_date: date,
|
||||||
|
parent_task: task.parent_task_id,
|
||||||
|
time_zone: getUserSession()?.timezone_name
|
||||||
|
? getUserSession()?.timezone_name
|
||||||
|
: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to update due date:', error);
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
setShowDatePicker(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[task.id, projectId, socket]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClearDate = useCallback(() => {
|
||||||
|
handleDateChange(null);
|
||||||
|
}, [handleDateChange]);
|
||||||
|
|
||||||
|
const handleToday = useCallback(() => {
|
||||||
|
handleDateChange(new Date());
|
||||||
|
}, [handleDateChange]);
|
||||||
|
|
||||||
|
const handleTomorrow = useCallback(() => {
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
handleDateChange(tomorrow);
|
||||||
|
}, [handleDateChange]);
|
||||||
|
|
||||||
|
const handleNextWeek = useCallback(() => {
|
||||||
|
const nextWeek = new Date();
|
||||||
|
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||||
|
handleDateChange(nextWeek);
|
||||||
|
}, [handleDateChange]);
|
||||||
|
|
||||||
|
const handleSubTaskExpand = useCallback(() => {
|
||||||
|
if (task && task.id && projectId) {
|
||||||
|
if (task.sub_tasks && task.sub_tasks.length > 0 && task.sub_tasks_count && task.sub_tasks_count > 0) {
|
||||||
|
dispatch(toggleTaskExpansion(task.id));
|
||||||
|
} else if (task.sub_tasks_count && task.sub_tasks_count > 0) {
|
||||||
|
dispatch(toggleTaskExpansion(task.id));
|
||||||
|
dispatch(fetchBoardSubTasks({ taskId: task.id, projectId }));
|
||||||
|
} else {
|
||||||
|
dispatch(toggleTaskExpansion(task.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [task, projectId, dispatch]);
|
||||||
|
|
||||||
|
const handleSubtaskButtonClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSubTaskExpand();
|
||||||
|
}, [handleSubTaskExpand]);
|
||||||
|
|
||||||
|
// Calendar rendering helpers
|
||||||
|
const year = calendarMonth.getFullYear();
|
||||||
|
const month = calendarMonth.getMonth();
|
||||||
|
const daysInMonth = getDaysInMonth(year, month);
|
||||||
|
const firstDayOfWeek = (getFirstDayOfWeek(year, month) + 6) % 7; // Make Monday first
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
const weeks: (Date | null)[][] = [];
|
||||||
|
let week: (Date | null)[] = Array(firstDayOfWeek).fill(null);
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
week.push(new Date(year, month, day));
|
||||||
|
if (week.length === 7) {
|
||||||
|
weeks.push(week);
|
||||||
|
week = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (week.length > 0) {
|
||||||
|
while (week.length < 7) week.push(null);
|
||||||
|
weeks.push(week);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isDropIndicator && (
|
{isDropIndicator && (
|
||||||
@@ -56,60 +214,262 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
|||||||
onDrop={e => onTaskDrop(e, groupId, idx)}
|
onDrop={e => onTaskDrop(e, groupId, idx)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="enhanced-kanban-task-card" style={{ background, color, display: 'block' }} >
|
||||||
className="enhanced-kanban-task-card"
|
<div
|
||||||
draggable
|
draggable
|
||||||
onDragStart={e => onTaskDragStart(e, task.id!, groupId)}
|
onDragStart={e => onTaskDragStart(e, task.id!, groupId)}
|
||||||
onDragOver={e => onTaskDragOver(e, groupId, idx)}
|
onDragOver={e => onTaskDragOver(e, groupId, idx)}
|
||||||
onDrop={e => onTaskDrop(e, groupId, idx)}
|
onDrop={e => onTaskDrop(e, groupId, idx)}
|
||||||
style={{ background, color }}
|
|
||||||
onClick={e => handleCardClick(e, task.id!)}
|
|
||||||
>
|
|
||||||
<div className="task-content">
|
|
||||||
<div className="task_labels" style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
|
||||||
{task.labels?.map(label => (
|
|
||||||
<div
|
|
||||||
key={label.id}
|
|
||||||
className="task-label"
|
|
||||||
style={{
|
|
||||||
backgroundColor: label.color_code,
|
|
||||||
display: 'inline-block',
|
|
||||||
borderRadius: '4px',
|
|
||||||
padding: '2px 8px',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 8,
|
|
||||||
marginRight: 4,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
minWidth: 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="task-content" style={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
<div
|
|
||||||
className="w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: task.priority_color || '#d9d9d9' }}
|
|
||||||
/>
|
|
||||||
<div className="task-title" style={{ marginLeft: 8 }}>{task.name}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="task-assignees-row" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
onClick={e => handleCardClick(e, task.id!)}
|
||||||
<div className="task-due-date" style={{ fontSize: 10, color: '#888', marginRight: 8, whiteSpace: 'nowrap' }}>
|
>
|
||||||
{task.end_date ? format(new Date(task.end_date), 'MMM d, yyyy') : ''}
|
<div className="task-content">
|
||||||
|
<div className="task_labels" style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||||
|
{task.labels?.map(label => (
|
||||||
|
<div
|
||||||
|
key={label.id}
|
||||||
|
className="task-label"
|
||||||
|
style={{
|
||||||
|
backgroundColor: label.color_code,
|
||||||
|
display: 'inline-block',
|
||||||
|
borderRadius: '2px',
|
||||||
|
padding: '0px 4px',
|
||||||
|
color: themeMode === 'dark' ? '#181818' : '#fff',
|
||||||
|
fontSize: 10,
|
||||||
|
marginRight: 4,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
minWidth: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="task-assignees" style={{ display: 'flex', alignItems: 'center' }}>
|
<div className="task-content" style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<AvatarGroup
|
<span
|
||||||
members={task.names || []}
|
className="w-2 h-2 rounded-full inline-block"
|
||||||
maxCount={3}
|
style={{ backgroundColor: themeMode === 'dark' ? (task.priority_color_dark || task.priority_color || '#d9d9d9') : (task.priority_color || '#d9d9d9') }}
|
||||||
isDarkMode={themeMode === 'dark'}
|
></span>
|
||||||
size={24}
|
<div className="task-title" style={{ marginLeft: 8 }}>{task.name}</div>
|
||||||
/>
|
</div>
|
||||||
<LazyAssigneeSelectorWrapper task={task} groupId={groupId} isDarkMode={themeMode === 'dark'} />
|
|
||||||
|
<div className="task-assignees-row" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
ref={dateButtonRef}
|
||||||
|
className="task-due-date cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-1 py-0.5 transition-colors"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#888',
|
||||||
|
marginRight: 8,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
onClick={handleDateClick}
|
||||||
|
title={t('clickToChangeDate')}
|
||||||
|
>
|
||||||
|
{isUpdating ? (
|
||||||
|
<div className="w-3 h-3 border border-gray-300 border-t-blue-600 rounded-full animate-spin"></div>
|
||||||
|
) : (
|
||||||
|
selectedDate ? format(selectedDate, 'MMM d, yyyy') : t('noDueDate')
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Custom Calendar Popup */}
|
||||||
|
{showDatePicker && dropdownPosition && (
|
||||||
|
<Portal>
|
||||||
|
<div
|
||||||
|
className="w-52 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-[9999] p-1"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: dropdownPosition.top,
|
||||||
|
left: dropdownPosition.left,
|
||||||
|
}}
|
||||||
|
ref={datePickerRef}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-0.5">
|
||||||
|
<button
|
||||||
|
className="px-0.5 py-0.5 text-[10px] rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
onClick={() => setCalendarMonth(new Date(year, month - 1, 1))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</button>
|
||||||
|
<span className="font-semibold text-xs text-gray-800 dark:text-gray-100">
|
||||||
|
{calendarMonth.toLocaleString('default', { month: 'long' })} {year}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="px-0.5 py-0.5 text-[10px] rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
onClick={() => setCalendarMonth(new Date(year, month + 1, 1))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-0.5 mb-0.5 text-[10px] text-center">
|
||||||
|
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
|
||||||
|
<div key={d} className="font-medium text-gray-500 dark:text-gray-400">{d}</div>
|
||||||
|
))}
|
||||||
|
{weeks.map((week, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{week.map((date, j) => {
|
||||||
|
const isSelected = date && selectedDate && date.toDateString() === selectedDate.toDateString();
|
||||||
|
const isToday = date && date.toDateString() === today.toDateString();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={j}
|
||||||
|
className={
|
||||||
|
'w-5 h-5 rounded-full flex items-center justify-center text-[10px] ' +
|
||||||
|
(isSelected
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: isToday
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-200'
|
||||||
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100')
|
||||||
|
}
|
||||||
|
style={{ outline: 'none' }}
|
||||||
|
disabled={!date}
|
||||||
|
onClick={() => date && handleDateChange(date)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{date ? date.getDate() : ''}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-0.5 mt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 px-0.5 py-0.5 text-[10px] bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||||
|
onClick={handleToday}
|
||||||
|
>
|
||||||
|
{t('today')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-1 py-0.5 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||||||
|
onClick={handleClearDate}
|
||||||
|
>
|
||||||
|
{t('clear')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 px-1 py-0.5 text-xs bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
onClick={handleTomorrow}
|
||||||
|
>
|
||||||
|
{t('tomorrow')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 px-1 py-0.5 text-xs bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
onClick={handleNextWeek}
|
||||||
|
>
|
||||||
|
{t('nextWeek')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="task-assignees" style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<AvatarGroup
|
||||||
|
members={task.names || []}
|
||||||
|
maxCount={3}
|
||||||
|
isDarkMode={themeMode === 'dark'}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
<LazyAssigneeSelectorWrapper task={task} groupId={groupId} isDarkMode={themeMode === 'dark'} kanbanMode={true} />
|
||||||
|
{(task.sub_tasks_count ?? 0) > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
"ml-2 px-2 py-0.5 rounded-full flex items-center gap-1 text-xs font-medium transition-colors " +
|
||||||
|
(task.show_sub_tasks
|
||||||
|
? "bg-gray-100 dark:bg-gray-800"
|
||||||
|
: "bg-white dark:bg-[#1e1e1e] hover:bg-gray-50 dark:hover:bg-gray-700")
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
onClick={handleSubtaskButtonClick}
|
||||||
|
title={task.show_sub_tasks ? t('hideSubtasks') || 'Hide Subtasks' : t('showSubtasks') || 'Show Subtasks'}
|
||||||
|
>
|
||||||
|
{/* Fork/branch icon */}
|
||||||
|
<svg style={{ color: '#888' }} className="w-2 h-2" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 20 20">
|
||||||
|
<path d="M6 3v2a2 2 0 002 2h4a2 2 0 012 2v2" strokeLinecap="round" />
|
||||||
|
<circle cx="6" cy="3" r="2" fill="currentColor" />
|
||||||
|
<circle cx="16" cy="9" r="2" fill="currentColor" />
|
||||||
|
<circle cx="6" cy="17" r="2" fill="currentColor" />
|
||||||
|
<path d="M6 5v10" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#888',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
display: 'inline-block',
|
||||||
|
}}>{task.sub_tasks_count ?? 0}</span>
|
||||||
|
{/* Caret icon */}
|
||||||
|
{task.show_sub_tasks ? (
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 20 20">
|
||||||
|
<path d="M6 8l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 20 20">
|
||||||
|
<path d="M8 6l4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{task.show_sub_tasks && (
|
||||||
|
<div className="mt-2 border-t border-gray-100 dark:border-gray-700 pt-2">
|
||||||
|
{/* Loading state */}
|
||||||
|
{task.sub_tasks_loading && (
|
||||||
|
<div className="py-2 text-xs text-gray-400 dark:text-gray-500">Loading...</div>
|
||||||
|
)}
|
||||||
|
{/* Loaded subtasks */}
|
||||||
|
{!task.sub_tasks_loading && Array.isArray(task.sub_tasks) && task.sub_tasks.length > 0 && (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{task.sub_tasks.map(sub => (
|
||||||
|
<li key={sub.id} onClick={e => handleCardClick(e, sub.id!)} className="flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||||
|
{sub.priority_color || sub.priority_color_dark ? (
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full inline-block"
|
||||||
|
style={{ backgroundColor: themeMode === 'dark' ? (sub.priority_color_dark || sub.priority_color || '#d9d9d9') : (sub.priority_color || '#d9d9d9') }}
|
||||||
|
></span>
|
||||||
|
) : null}
|
||||||
|
<span className="flex-1 truncate text-xs text-gray-800 dark:text-gray-100">{sub.name}</span>
|
||||||
|
<span
|
||||||
|
className="task-due-date ml-2 text-[10px] text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{sub.end_date ? format(new Date(sub.end_date), 'MMM d, yyyy') : ''}
|
||||||
|
</span>
|
||||||
|
{sub.names && sub.names.length > 0 && (
|
||||||
|
<AvatarGroup
|
||||||
|
members={sub.names}
|
||||||
|
maxCount={2}
|
||||||
|
isDarkMode={themeMode === 'dark'}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{/* Empty state */}
|
||||||
|
{!task.sub_tasks_loading && (!Array.isArray(task.sub_tasks) || task.sub_tasks.length === 0) && (
|
||||||
|
<div className="py-2 text-xs text-gray-400 dark:text-gray-500">{t('noSubtasks', 'No subtasks')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -182,11 +182,7 @@ const EnhancedKanbanTaskCard: React.FC<EnhancedKanbanTaskCardProps> = React.memo
|
|||||||
isDarkMode={themeMode === 'dark'}
|
isDarkMode={themeMode === 'dark'}
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
<LazyAssigneeSelectorWrapper
|
<LazyAssigneeSelectorWrapper task={task} groupId={sectionId} isDarkMode={themeMode === 'dark'} kanbanMode={true} />
|
||||||
task={task}
|
|
||||||
groupId={sectionId}
|
|
||||||
isDarkMode={themeMode === 'dark'}
|
|
||||||
/>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex gap={4} align="center">
|
<Flex gap={4} align="center">
|
||||||
<CustomDueDatePicker task={task} onDateChange={setDueDate} />
|
<CustomDueDatePicker task={task} onDateChange={setDueDate} />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface LazyAssigneeSelectorProps {
|
|||||||
task: IProjectTask;
|
task: IProjectTask;
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
isDarkMode?: boolean;
|
isDarkMode?: boolean;
|
||||||
|
kanbanMode?: boolean; // <-- Add this prop
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightweight loading placeholder
|
// Lightweight loading placeholder
|
||||||
@@ -34,6 +35,7 @@ const LazyAssigneeSelectorWrapper: React.FC<LazyAssigneeSelectorProps> = ({
|
|||||||
task,
|
task,
|
||||||
groupId = null,
|
groupId = null,
|
||||||
isDarkMode = false,
|
isDarkMode = false,
|
||||||
|
kanbanMode = false, // <-- Default to false
|
||||||
}) => {
|
}) => {
|
||||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||||
const [showComponent, setShowComponent] = useState(false);
|
const [showComponent, setShowComponent] = useState(false);
|
||||||
@@ -74,7 +76,7 @@ const LazyAssigneeSelectorWrapper: React.FC<LazyAssigneeSelectorProps> = ({
|
|||||||
// Once loaded, show the full component
|
// Once loaded, show the full component
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingPlaceholder isDarkMode={isDarkMode} />}>
|
<Suspense fallback={<LoadingPlaceholder isDarkMode={isDarkMode} />}>
|
||||||
<LazyAssigneeSelector task={task} groupId={groupId} isDarkMode={isDarkMode} />
|
<LazyAssigneeSelector task={task} groupId={groupId} isDarkMode={isDarkMode} kanbanMode={kanbanMode} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user