Cleanup statuses (#724)
* Cleanup statuses * Add more statuses * Hide buttons * fixup! Hide buttons * Show subtasks on click * Fix status button in taskdetails page * fixup! Fix status button in taskdetails page * fixup! fixup! Fix status button in taskdetails page * Fix today planned query
This commit is contained in:
parent
1e51cff18c
commit
4d2ea4212c
18 changed files with 1471 additions and 1822 deletions
|
|
@ -58,7 +58,7 @@ module.exports = (sequelize) => {
|
|||
defaultValue: 0,
|
||||
validate: {
|
||||
min: 0,
|
||||
max: 4,
|
||||
max: 6,
|
||||
},
|
||||
},
|
||||
note: {
|
||||
|
|
@ -262,6 +262,8 @@ module.exports = (sequelize) => {
|
|||
DONE: 2,
|
||||
ARCHIVED: 3,
|
||||
WAITING: 4,
|
||||
CANCELLED: 5,
|
||||
PLANNED: 6,
|
||||
};
|
||||
|
||||
Task.RECURRENCE_TYPE = {
|
||||
|
|
@ -301,6 +303,8 @@ module.exports = (sequelize) => {
|
|||
'done',
|
||||
'archived',
|
||||
'waiting',
|
||||
'cancelled',
|
||||
'planned',
|
||||
];
|
||||
return statuses[statusValue] || 'not_started';
|
||||
};
|
||||
|
|
@ -319,6 +323,8 @@ module.exports = (sequelize) => {
|
|||
done: 2,
|
||||
archived: 3,
|
||||
waiting: 4,
|
||||
cancelled: 5,
|
||||
planned: 6,
|
||||
};
|
||||
return statuses[statusName] !== undefined ? statuses[statusName] : 0;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -49,22 +49,55 @@ async function fetchTasksInProgress(visibleTasksWhere) {
|
|||
}
|
||||
|
||||
async function fetchTodayPlanTasks(visibleTasksWhere) {
|
||||
const todayPlanStatuses = [
|
||||
Task.STATUS.IN_PROGRESS,
|
||||
Task.STATUS.WAITING,
|
||||
Task.STATUS.PLANNED,
|
||||
'in_progress',
|
||||
'waiting',
|
||||
'planned',
|
||||
];
|
||||
|
||||
const excludedStatuses = [
|
||||
Task.STATUS.NOT_STARTED,
|
||||
Task.STATUS.DONE,
|
||||
Task.STATUS.ARCHIVED,
|
||||
Task.STATUS.CANCELLED,
|
||||
'not_started',
|
||||
'done',
|
||||
'archived',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
return await Task.findAll({
|
||||
where: {
|
||||
[Op.and]: [
|
||||
visibleTasksWhere,
|
||||
{
|
||||
today: true,
|
||||
status: {
|
||||
[Op.notIn]: [
|
||||
Task.STATUS.DONE,
|
||||
Task.STATUS.ARCHIVED,
|
||||
'done',
|
||||
'archived',
|
||||
],
|
||||
[Op.in]: todayPlanStatuses,
|
||||
[Op.notIn]: excludedStatuses,
|
||||
},
|
||||
parent_task_id: null,
|
||||
recurring_parent_id: null,
|
||||
// Exclude recurring parent tasks - only include non-recurring tasks or recurring instances
|
||||
[Op.or]: [
|
||||
{
|
||||
// Non-recurring tasks
|
||||
[Op.and]: [
|
||||
{
|
||||
[Op.or]: [
|
||||
{ recurrence_type: 'none' },
|
||||
{ recurrence_type: null },
|
||||
],
|
||||
},
|
||||
{ recurring_parent_id: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Recurring instances (not parents)
|
||||
recurring_parent_id: { [Op.ne]: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -45,25 +45,8 @@ async function main() {
|
|||
await seedDatabase();
|
||||
console.log(' ✅ Basic data seeded\n');
|
||||
|
||||
// Step 4: Seed notification test data
|
||||
console.log('4️⃣ Seeding notification test data...');
|
||||
const {
|
||||
seedNotificationTestData,
|
||||
} = require('./seed-notification-test-data');
|
||||
|
||||
// Override process.exit to prevent the seeder from exiting
|
||||
const originalExit = process.exit;
|
||||
process.exit = () => {}; // No-op
|
||||
|
||||
await seedNotificationTestData();
|
||||
|
||||
// Restore original process.exit
|
||||
process.exit = originalExit;
|
||||
|
||||
console.log(' ✅ Notification test data seeded\n');
|
||||
|
||||
// Step 5: Generate notifications
|
||||
console.log('5️⃣ Generating notifications...');
|
||||
// Step 4: Generate notifications
|
||||
console.log('4️⃣ Generating notifications...');
|
||||
|
||||
const { checkDueTasks } = require('../services/dueTaskService');
|
||||
const {
|
||||
|
|
|
|||
262
backend/tests/integration/tasks-today-plan.test.js
Normal file
262
backend/tests/integration/tasks-today-plan.test.js
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
const request = require('supertest');
|
||||
const app = require('../../app');
|
||||
const { Task } = require('../../models');
|
||||
const { createTestUser } = require('../helpers/testUtils');
|
||||
|
||||
describe('Tasks Today Plan - Status-Based Filtering', () => {
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'todayplan@example.com',
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent.post('/api/login').send({
|
||||
email: 'todayplan@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/tasks?type=today&include_lists=true - tasks_today_plan', () => {
|
||||
it('should return tasks with status in_progress, planned, and waiting', async () => {
|
||||
// Create tasks with different statuses
|
||||
const inProgressTask = await Task.create({
|
||||
name: 'In Progress Task',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.IN_PROGRESS,
|
||||
today: false, // Deliberately false to verify it doesn't depend on 'today' field
|
||||
});
|
||||
|
||||
const plannedTask = await Task.create({
|
||||
name: 'Planned Task',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.PLANNED,
|
||||
today: false,
|
||||
});
|
||||
|
||||
const waitingTask = await Task.create({
|
||||
name: 'Waiting Task',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.WAITING,
|
||||
today: false,
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.get('/api/tasks?type=today&include_lists=true')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.tasks_today_plan).toBeDefined();
|
||||
expect(response.body.tasks_today_plan).toHaveLength(3);
|
||||
|
||||
const taskIds = response.body.tasks_today_plan.map((t) => t.id);
|
||||
expect(taskIds).toContain(inProgressTask.id);
|
||||
expect(taskIds).toContain(plannedTask.id);
|
||||
expect(taskIds).toContain(waitingTask.id);
|
||||
});
|
||||
|
||||
it('should exclude tasks with status not_started', async () => {
|
||||
await Task.create({
|
||||
name: 'Not Started Task',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.NOT_STARTED,
|
||||
today: true, // Even with today=true, should not appear in tasks_today_plan
|
||||
});
|
||||
|
||||
await Task.create({
|
||||
name: 'In Progress Task',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.IN_PROGRESS,
|
||||
today: false,
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.get('/api/tasks?type=today&include_lists=true')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.tasks_today_plan).toBeDefined();
|
||||
expect(response.body.tasks_today_plan).toHaveLength(1);
|
||||
expect(response.body.tasks_today_plan[0].name).toBe(
|
||||
'In Progress Task'
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude tasks with status done, archived, and cancelled', async () => {
|
||||
await Task.create({
|
||||
name: 'Done Task',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.DONE,
|
||||
today: true,
|
||||
});
|
||||
|
||||
await Task.create({
|
||||
name: 'Archived Task',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.ARCHIVED,
|
||||
today: true,
|
||||
});
|
||||
|
||||
await Task.create({
|
||||
name: 'Cancelled Task',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.CANCELLED,
|
||||
today: true,
|
||||
});
|
||||
|
||||
await Task.create({
|
||||
name: 'Planned Task',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.PLANNED,
|
||||
today: false,
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.get('/api/tasks?type=today&include_lists=true')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.tasks_today_plan).toBeDefined();
|
||||
expect(response.body.tasks_today_plan).toHaveLength(1);
|
||||
expect(response.body.tasks_today_plan[0].name).toBe('Planned Task');
|
||||
});
|
||||
|
||||
it('should exclude subtasks from tasks_today_plan', async () => {
|
||||
const parentTask = await Task.create({
|
||||
name: 'Parent Task',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.IN_PROGRESS,
|
||||
});
|
||||
|
||||
await Task.create({
|
||||
name: 'Subtask',
|
||||
user_id: user.id,
|
||||
parent_task_id: parentTask.id,
|
||||
status: Task.STATUS.IN_PROGRESS,
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.get('/api/tasks?type=today&include_lists=true')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.tasks_today_plan).toBeDefined();
|
||||
expect(response.body.tasks_today_plan).toHaveLength(1);
|
||||
expect(response.body.tasks_today_plan[0].id).toBe(parentTask.id);
|
||||
});
|
||||
|
||||
it('should exclude recurring parent tasks from tasks_today_plan', async () => {
|
||||
const recurringParent = await Task.create({
|
||||
name: 'Recurring Parent',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.PLANNED,
|
||||
recurrence_type: 'daily',
|
||||
});
|
||||
|
||||
await Task.create({
|
||||
name: 'Recurring Instance',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.PLANNED,
|
||||
recurring_parent_id: recurringParent.id,
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.get('/api/tasks?type=today&include_lists=true')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.tasks_today_plan).toBeDefined();
|
||||
// Should only include the instance, not the parent
|
||||
const taskNames = response.body.tasks_today_plan.map((t) => t.name);
|
||||
expect(taskNames).not.toContain('Recurring Parent');
|
||||
expect(taskNames).toContain('Recurring Instance');
|
||||
});
|
||||
|
||||
it('should work independently of the today field', async () => {
|
||||
// Task with status PLANNED but today=false
|
||||
const plannedTask = await Task.create({
|
||||
name: 'Planned Not Today',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.PLANNED,
|
||||
today: false,
|
||||
});
|
||||
|
||||
// Task with status NOT_STARTED but today=true
|
||||
await Task.create({
|
||||
name: 'Not Started Today',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.NOT_STARTED,
|
||||
today: true,
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.get('/api/tasks?type=today&include_lists=true')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.tasks_today_plan).toBeDefined();
|
||||
expect(response.body.tasks_today_plan).toHaveLength(1);
|
||||
expect(response.body.tasks_today_plan[0].id).toBe(plannedTask.id);
|
||||
});
|
||||
|
||||
it('should return empty array when no planned tasks exist', async () => {
|
||||
await Task.create({
|
||||
name: 'Not Started Task',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.NOT_STARTED,
|
||||
});
|
||||
|
||||
await Task.create({
|
||||
name: 'Done Task',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.DONE,
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.get('/api/tasks?type=today&include_lists=true')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.tasks_today_plan).toBeDefined();
|
||||
expect(response.body.tasks_today_plan).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should order tasks by priority DESC, due_date ASC, project_id ASC', async () => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const nextWeek = new Date();
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
|
||||
await Task.create({
|
||||
name: 'Low Priority',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.PLANNED,
|
||||
priority: Task.PRIORITY.LOW,
|
||||
});
|
||||
|
||||
await Task.create({
|
||||
name: 'High Priority Due Later',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.PLANNED,
|
||||
priority: Task.PRIORITY.HIGH,
|
||||
due_date: nextWeek,
|
||||
});
|
||||
|
||||
await Task.create({
|
||||
name: 'High Priority Due Soon',
|
||||
user_id: user.id,
|
||||
status: Task.STATUS.PLANNED,
|
||||
priority: Task.PRIORITY.HIGH,
|
||||
due_date: tomorrow,
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.get('/api/tasks?type=today&include_lists=true')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.tasks_today_plan).toBeDefined();
|
||||
expect(response.body.tasks_today_plan).toHaveLength(3);
|
||||
|
||||
const taskNames = response.body.tasks_today_plan.map((t) => t.name);
|
||||
// High priority tasks should come first, ordered by due date
|
||||
expect(taskNames[0]).toBe('High Priority Due Soon');
|
||||
expect(taskNames[1]).toBe('High Priority Due Later');
|
||||
expect(taskNames[2]).toBe('Low Priority');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -19,7 +19,7 @@ test.describe('Today', () => {
|
|||
await page.waitForURL(/\/(dashboard|today)/, { timeout: 10000 });
|
||||
}
|
||||
|
||||
test('Planned: only today=true tasks', async ({
|
||||
test('Planned: shows tasks with status in_progress, planned, or waiting', async ({
|
||||
page,
|
||||
context,
|
||||
baseURL,
|
||||
|
|
@ -32,17 +32,26 @@ test.describe('Today', () => {
|
|||
const timestamp = Date.now();
|
||||
|
||||
// Create tasks via API using the logged-in context
|
||||
// Status values: 0=NOT_STARTED, 1=IN_PROGRESS, 4=WAITING, 6=PLANNED
|
||||
const tasksToCreate = [
|
||||
{
|
||||
name: `High Priority Planned ${timestamp}`,
|
||||
today: true,
|
||||
name: `In Progress Task ${timestamp}`,
|
||||
status: 1, // IN_PROGRESS
|
||||
priority: 2,
|
||||
}, // 2 = HIGH
|
||||
today: false, // Verify it works independently of today field
|
||||
},
|
||||
{
|
||||
name: `Task Without Today Flag ${timestamp}`,
|
||||
today: false,
|
||||
name: `Planned Task ${timestamp}`,
|
||||
status: 6, // PLANNED
|
||||
priority: 2,
|
||||
}, // 2 = HIGH
|
||||
today: false,
|
||||
},
|
||||
{
|
||||
name: `Not Started Task ${timestamp}`,
|
||||
status: 0, // NOT_STARTED
|
||||
priority: 2,
|
||||
today: true, // Even with today=true, should NOT appear in planned
|
||||
},
|
||||
];
|
||||
|
||||
const taskIds: string[] = [];
|
||||
|
|
@ -67,17 +76,23 @@ test.describe('Today', () => {
|
|||
const plannedSection = page.getByTestId('planned-section');
|
||||
await expect(plannedSection).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify task with today flag is visible in the Planned section
|
||||
const withTodayFlagTask = plannedSection.getByTestId(
|
||||
// Verify in_progress task is visible in the Planned section
|
||||
const inProgressTask = plannedSection.getByTestId(
|
||||
`task-item-${taskIds[0]}`
|
||||
);
|
||||
await expect(withTodayFlagTask).toBeVisible({ timeout: 10000 });
|
||||
await expect(inProgressTask).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify task without today flag is NOT visible in Planned section
|
||||
const withoutFlagTask = plannedSection.getByTestId(
|
||||
// Verify planned task is visible in the Planned section
|
||||
const plannedTask = plannedSection.getByTestId(
|
||||
`task-item-${taskIds[1]}`
|
||||
);
|
||||
await expect(withoutFlagTask).not.toBeVisible();
|
||||
await expect(plannedTask).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify not_started task is NOT visible in Planned section
|
||||
const notStartedTask = plannedSection.getByTestId(
|
||||
`task-item-${taskIds[2]}`
|
||||
);
|
||||
await expect(notStartedTask).not.toBeVisible();
|
||||
|
||||
// Clean up created tasks
|
||||
for (const taskId of taskIds) {
|
||||
|
|
|
|||
|
|
@ -5,45 +5,71 @@ import {
|
|||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
ArchiveBoxIcon,
|
||||
CalendarIcon,
|
||||
PlayIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { StatusType } from '../../entities/Task';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getStatusString } from '../../constants/taskStatus';
|
||||
|
||||
interface StatusDropdownProps {
|
||||
value: StatusType;
|
||||
value: StatusType | number;
|
||||
onChange: (value: StatusType) => void;
|
||||
}
|
||||
|
||||
const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const statusString = getStatusString(value);
|
||||
|
||||
const statuses = [
|
||||
{
|
||||
value: 'not_started',
|
||||
label: t('status.notStarted', 'Not Started'),
|
||||
icon: (
|
||||
<MinusIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
<MinusIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'planned',
|
||||
label: t('status.planned', 'Planned'),
|
||||
icon: (
|
||||
<CalendarIcon className="w-5 h-5 text-purple-500 dark:text-purple-400" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'in_progress',
|
||||
label: t('status.inProgress', 'In Progress'),
|
||||
icon: (
|
||||
<ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
<PlayIcon className="w-5 h-5 text-blue-500 dark:text-blue-400" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'waiting',
|
||||
label: t('status.waiting', 'Waiting'),
|
||||
icon: (
|
||||
<ClockIcon className="w-5 h-5 text-yellow-500 dark:text-yellow-400" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'done',
|
||||
label: t('status.done', 'Done'),
|
||||
icon: (
|
||||
<CheckCircleIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500 dark:text-green-400" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'cancelled',
|
||||
label: t('status.cancelled', 'Cancelled'),
|
||||
icon: (
|
||||
<XCircleIcon className="w-5 h-5 text-red-500 dark:text-red-400" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'archived',
|
||||
label: t('status.archived', 'Archived'),
|
||||
icon: (
|
||||
<ArchiveBoxIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
<ArchiveBoxIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -80,7 +106,7 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
|
|||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedStatus = statuses.find((s) => s.value === value);
|
||||
const selectedStatus = statuses.find((s) => s.value === statusString);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
fetchTaskByUid,
|
||||
fetchTaskNextIterations,
|
||||
TaskIteration,
|
||||
toggleTaskToday,
|
||||
toggleTaskCompletion,
|
||||
} from '../../utils/tasksService';
|
||||
import { createProject } from '../../utils/projectsService';
|
||||
|
|
@ -804,43 +803,6 @@ const TaskDetails: React.FC = () => {
|
|||
[parentTask?.id, parentTask?.recurrence_type]
|
||||
);
|
||||
|
||||
const handleToggleTodayPlan = async () => {
|
||||
if (!task?.id || !task?.uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedTask = await toggleTaskToday(task.id, task);
|
||||
let latestTaskData: Task | null = updatedTask;
|
||||
|
||||
if (uid) {
|
||||
const refreshedTask = await fetchTaskByUid(uid);
|
||||
latestTaskData = refreshedTask;
|
||||
const existingIndex = tasksStore.tasks.findIndex(
|
||||
(t: Task) => t.uid === uid
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
const updatedTasks = [...tasksStore.tasks];
|
||||
updatedTasks[existingIndex] = refreshedTask;
|
||||
tasksStore.setTasks(updatedTasks);
|
||||
}
|
||||
}
|
||||
|
||||
await refreshRecurringSetup(latestTaskData);
|
||||
setTimelineRefreshKey((prev) => prev + 1);
|
||||
showSuccessToast(
|
||||
updatedTask.today
|
||||
? t('tasks.addToToday', 'Add to today plan')
|
||||
: t('tasks.removeFromToday', 'Remove from today plan')
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error toggling today plan:', error);
|
||||
showErrorToast(
|
||||
t('task.statusUpdateError', 'Failed to update status')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickStatusToggle = async () => {
|
||||
if (!task?.uid) {
|
||||
return;
|
||||
|
|
@ -1245,7 +1207,6 @@ const TaskDetails: React.FC = () => {
|
|||
onOverdueIconClick={handleOverdueIconClick}
|
||||
isOverdueAlertVisible={isOverdue && isOverdueBubbleVisible}
|
||||
onDismissOverdueAlert={handleDismissOverdueAlert}
|
||||
onToggleTodayPlan={handleToggleTodayPlan}
|
||||
onQuickStatusToggle={handleQuickStatusToggle}
|
||||
attachmentCount={attachmentCount}
|
||||
subtasksCount={subtasks.length}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,7 @@ import {
|
|||
FolderIcon,
|
||||
TagIcon,
|
||||
ChevronDownIcon,
|
||||
PauseCircleIcon,
|
||||
PlayCircleIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CalendarDaysIcon,
|
||||
CalendarIcon,
|
||||
PlayIcon,
|
||||
FireIcon,
|
||||
ArrowUpIcon,
|
||||
ArrowDownIcon,
|
||||
|
|
@ -20,6 +14,8 @@ import {
|
|||
import { Link } from 'react-router-dom';
|
||||
import { Task, PriorityType } from '../../../entities/Task';
|
||||
import { formatDateTime } from '../../../utils/dateUtils';
|
||||
import TaskStatusControl from '../TaskStatusControl';
|
||||
import { getStatusValue } from '../../../constants/taskStatus';
|
||||
|
||||
interface TaskDetailsHeaderProps {
|
||||
task: Task;
|
||||
|
|
@ -37,7 +33,6 @@ interface TaskDetailsHeaderProps {
|
|||
onOverdueIconClick?: () => void;
|
||||
isOverdueAlertVisible?: boolean;
|
||||
onDismissOverdueAlert?: () => void;
|
||||
onToggleTodayPlan?: () => void;
|
||||
onQuickStatusToggle?: () => void;
|
||||
attachmentCount?: number;
|
||||
subtasksCount?: number;
|
||||
|
|
@ -59,7 +54,6 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
|||
onOverdueIconClick,
|
||||
isOverdueAlertVisible = false,
|
||||
onDismissOverdueAlert,
|
||||
onToggleTodayPlan,
|
||||
onQuickStatusToggle,
|
||||
attachmentCount = 0,
|
||||
subtasksCount = 0,
|
||||
|
|
@ -68,11 +62,9 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
|||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [editedTitle, setEditedTitle] = useState(task.name);
|
||||
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
|
||||
const [statusDropdownOpen, setStatusDropdownOpen] = useState(false);
|
||||
const [priorityDropdownOpen, setPriorityDropdownOpen] = useState(false);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
const actionsMenuRef = useRef<HTMLDivElement>(null);
|
||||
const statusDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const priorityDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -95,13 +87,6 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
|||
) {
|
||||
setActionsMenuOpen(false);
|
||||
}
|
||||
if (
|
||||
statusDropdownOpen &&
|
||||
statusDropdownRef.current &&
|
||||
!statusDropdownRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setStatusDropdownOpen(false);
|
||||
}
|
||||
if (
|
||||
priorityDropdownOpen &&
|
||||
priorityDropdownRef.current &&
|
||||
|
|
@ -111,12 +96,12 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
if (actionsMenuOpen || statusDropdownOpen || priorityDropdownOpen) {
|
||||
if (actionsMenuOpen || priorityDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [actionsMenuOpen, statusDropdownOpen, priorityDropdownOpen]);
|
||||
}, [actionsMenuOpen, priorityDropdownOpen]);
|
||||
|
||||
const handleStartTitleEdit = () => {
|
||||
setIsEditingTitle(true);
|
||||
|
|
@ -142,79 +127,13 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = () => {
|
||||
const status = task.status;
|
||||
if (status === 'not_started' || status === 0) {
|
||||
return t('task.status.notStarted', 'Not started');
|
||||
} else if (status === 'in_progress' || status === 1) {
|
||||
return t('task.status.inProgress', 'In progress');
|
||||
} else if (status === 'done' || status === 2) {
|
||||
return t('task.status.done', 'Done');
|
||||
} else if (status === 'archived' || status === 3) {
|
||||
return t('task.status.archived', 'Archived');
|
||||
} else if (status === 'waiting' || status === 4) {
|
||||
return t('task.status.waiting', 'Waiting');
|
||||
const handleStatusControlUpdate = async (updatedTask: Task) => {
|
||||
const currentStatusValue = getStatusValue(task.status);
|
||||
const nextStatusValue = getStatusValue(updatedTask.status);
|
||||
|
||||
if (currentStatusValue !== nextStatusValue) {
|
||||
await onStatusUpdate(nextStatusValue);
|
||||
}
|
||||
return t('task.status.notStarted', 'Not started');
|
||||
};
|
||||
|
||||
const getStatusButtonClass = () => {
|
||||
const status = task.status;
|
||||
|
||||
if (status === 'not_started' || status === 0) {
|
||||
return 'px-2 sm:px-2.5 py-1 rounded-md text-xs font-medium transition-colors flex items-center gap-1 sm:gap-2 sm:ml-2 border border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800/60';
|
||||
}
|
||||
|
||||
const baseClass =
|
||||
'px-2 sm:px-2.5 py-1 rounded-md text-xs font-medium transition-colors flex items-center gap-1 sm:gap-2 sm:ml-2 border';
|
||||
|
||||
if (status === 'in_progress' || status === 1) {
|
||||
return `${baseClass} border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/30`;
|
||||
} else if (status === 'done' || status === 2) {
|
||||
return `${baseClass} border-green-500 text-green-600 dark:border-green-400 dark:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/30`;
|
||||
} else if (status === 'archived' || status === 3) {
|
||||
return `${baseClass} border-purple-500 text-purple-600 dark:border-purple-400 dark:text-purple-300 hover:bg-purple-50 dark:hover:bg-purple-900/30`;
|
||||
} else if (status === 'waiting' || status === 4) {
|
||||
return `${baseClass} border-yellow-500 text-yellow-600 dark:border-yellow-400 dark:text-yellow-300 hover:bg-yellow-50 dark:hover:bg-yellow-900/30`;
|
||||
}
|
||||
return `${baseClass} border-gray-300 text-gray-700 dark:border-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800/60`;
|
||||
};
|
||||
|
||||
const handleStatusChange = async (newStatus: number | string) => {
|
||||
setStatusDropdownOpen(false);
|
||||
const statusNum =
|
||||
typeof newStatus === 'string' ? parseInt(newStatus) : newStatus;
|
||||
await onStatusUpdate(statusNum);
|
||||
};
|
||||
|
||||
const getStatusIcon = (
|
||||
statusOverride?: number | string
|
||||
): React.ElementType => {
|
||||
const status =
|
||||
typeof statusOverride !== 'undefined'
|
||||
? statusOverride
|
||||
: task.status;
|
||||
|
||||
if (status === 'in_progress' || status === 1) {
|
||||
return PlayCircleIcon;
|
||||
} else if (status === 'done' || status === 2) {
|
||||
return CheckCircleIcon;
|
||||
}
|
||||
return PauseCircleIcon;
|
||||
};
|
||||
|
||||
const getStatusIconClass = (statusOverride?: number | string) => {
|
||||
const status =
|
||||
typeof statusOverride !== 'undefined'
|
||||
? statusOverride
|
||||
: task.status;
|
||||
|
||||
if (status === 'in_progress' || status === 1) {
|
||||
return 'text-blue-500 dark:text-blue-400';
|
||||
} else if (status === 'done' || status === 2) {
|
||||
return 'text-green-500 dark:text-green-400';
|
||||
}
|
||||
return 'text-gray-500 dark:text-gray-400';
|
||||
};
|
||||
|
||||
const getPriorityLabel = (priorityOverride?: PriorityType) => {
|
||||
|
|
@ -347,135 +266,20 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
|||
{task.name}
|
||||
</h2>
|
||||
|
||||
{/* Status Dropdown Button - Next to title */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div
|
||||
className="relative flex-shrink-0"
|
||||
ref={statusDropdownRef}
|
||||
>
|
||||
<button
|
||||
className={getStatusButtonClass()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setStatusDropdownOpen(
|
||||
!statusDropdownOpen
|
||||
);
|
||||
}}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={
|
||||
statusDropdownOpen
|
||||
}
|
||||
>
|
||||
{React.createElement(
|
||||
getStatusIcon(),
|
||||
{
|
||||
className: `h-4 w-4 ${getStatusIconClass()}`,
|
||||
}
|
||||
)}
|
||||
<span className="capitalize hidden sm:inline">
|
||||
{getStatusLabel()}
|
||||
</span>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</button>
|
||||
{statusDropdownOpen && (
|
||||
<div className="absolute left-0 sm:right-0 sm:left-auto mt-2 w-48 rounded-lg shadow-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 z-20">
|
||||
<button
|
||||
className={`w-full text-left px-3 py-2 text-sm rounded-t-lg flex items-center gap-2 ${
|
||||
task.status === 0 ||
|
||||
task.status ===
|
||||
'not_started'
|
||||
? 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-medium'
|
||||
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleStatusChange(
|
||||
0
|
||||
);
|
||||
}}
|
||||
>
|
||||
<PauseCircleIcon
|
||||
className={`h-4 w-4 ${getStatusIconClass(0)}`}
|
||||
/>
|
||||
<span className="capitalize flex-1">
|
||||
{t(
|
||||
'task.status.notStarted',
|
||||
'Not started'
|
||||
)}
|
||||
</span>
|
||||
{(task.status === 0 ||
|
||||
task.status ===
|
||||
'not_started') && (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 ${
|
||||
task.status === 1 ||
|
||||
task.status ===
|
||||
'in_progress'
|
||||
? 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-medium'
|
||||
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleStatusChange(
|
||||
1
|
||||
);
|
||||
}}
|
||||
>
|
||||
<PlayCircleIcon
|
||||
className={`h-4 w-4 ${getStatusIconClass(1)}`}
|
||||
/>
|
||||
<span className="capitalize flex-1">
|
||||
{t(
|
||||
'task.status.inProgress',
|
||||
'In progress'
|
||||
)}
|
||||
</span>
|
||||
{(task.status === 1 ||
|
||||
task.status ===
|
||||
'in_progress') && (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`w-full text-left px-3 py-2 text-sm rounded-b-lg flex items-center gap-2 ${
|
||||
task.status === 2 ||
|
||||
task.status ===
|
||||
'done'
|
||||
? 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-medium'
|
||||
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleStatusChange(
|
||||
2
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckCircleIcon
|
||||
className={`h-4 w-4 ${getStatusIconClass(2)}`}
|
||||
/>
|
||||
<span className="capitalize flex-1">
|
||||
{t(
|
||||
'task.status.setAsDone',
|
||||
'Set as done'
|
||||
)}
|
||||
</span>
|
||||
{(task.status === 2 ||
|
||||
task.status ===
|
||||
'done') && (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TaskStatusControl
|
||||
task={task}
|
||||
onToggleCompletion={
|
||||
onQuickStatusToggle
|
||||
}
|
||||
onTaskUpdate={
|
||||
handleStatusControlUpdate
|
||||
}
|
||||
hoverRevealQuickActions={false}
|
||||
showMobileVariant={false}
|
||||
variant="square"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
{/* Priority Dropdown Button - Next to status */}
|
||||
<div
|
||||
|
|
@ -812,9 +616,7 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
|||
{t('task.activity', 'Activity')}
|
||||
</button>
|
||||
</div>
|
||||
{(showOverdueIcon ||
|
||||
onToggleTodayPlan ||
|
||||
onQuickStatusToggle) && (
|
||||
{(showOverdueIcon || onQuickStatusToggle) && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{showOverdueIcon && (
|
||||
<div
|
||||
|
|
@ -886,81 +688,6 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{onToggleTodayPlan && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleTodayPlan();
|
||||
}}
|
||||
className={`inline-flex items-center justify-center rounded-full transition-all duration-200 ${
|
||||
Number(task.today_move_count || 0) > 1
|
||||
? 'px-3 h-8'
|
||||
: 'w-8 h-8'
|
||||
} ${
|
||||
task.today
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
title={
|
||||
task.today
|
||||
? t(
|
||||
'tasks.removeFromToday',
|
||||
'Remove from today plan'
|
||||
)
|
||||
: t(
|
||||
'tasks.addToToday',
|
||||
'Add to today plan'
|
||||
)
|
||||
}
|
||||
>
|
||||
{task.today ? (
|
||||
<CalendarDaysIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
)}
|
||||
{Number(task.today_move_count || 0) > 1 && (
|
||||
<span className="ml-1 text-xs font-medium">
|
||||
{Number(task.today_move_count || 0)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{onQuickStatusToggle &&
|
||||
(task.status === 'not_started' ||
|
||||
task.status === 'in_progress' ||
|
||||
task.status === 0 ||
|
||||
task.status === 1) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQuickStatusToggle();
|
||||
}}
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 ${
|
||||
task.status === 'in_progress' ||
|
||||
task.status === 1
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 animate-pulse'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
title={
|
||||
task.status === 'in_progress' ||
|
||||
task.status === 1
|
||||
? t(
|
||||
'tasks.setNotStarted',
|
||||
'Set to not started'
|
||||
)
|
||||
: t(
|
||||
'tasks.setInProgress',
|
||||
'Set in progress'
|
||||
)
|
||||
}
|
||||
>
|
||||
<PlayIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className="relative flex items-center"
|
||||
ref={actionsMenuRef}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +1,47 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
import TaskHeader from './TaskHeader';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import TaskPriorityIcon from './TaskPriorityIcon';
|
||||
import { isTaskCompleted } from '../../constants/taskStatus';
|
||||
|
||||
// Import SubtasksDisplay component from TaskHeader
|
||||
interface SubtasksDisplayProps {
|
||||
showSubtasks: boolean;
|
||||
loadingSubtasks: boolean;
|
||||
subtasks: Task[];
|
||||
onTaskClick: (e: React.MouseEvent, task: Task) => void;
|
||||
onTaskUpdate: (task: Task) => Promise<void>;
|
||||
loadSubtasks: () => Promise<void>;
|
||||
onSubtaskUpdate: (updatedSubtask: Task) => void;
|
||||
}
|
||||
|
||||
const getPriorityBorderClassName = (
|
||||
priority?: Task['priority'] | number
|
||||
): string => {
|
||||
let normalizedPriority = priority;
|
||||
if (typeof normalizedPriority === 'number') {
|
||||
const priorityNames: Array<'low' | 'medium' | 'high'> = [
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
];
|
||||
normalizedPriority = priorityNames[normalizedPriority] || undefined;
|
||||
}
|
||||
|
||||
switch (normalizedPriority) {
|
||||
case 'high':
|
||||
return 'border-l-4 border-l-red-500';
|
||||
case 'medium':
|
||||
return 'border-l-4 border-l-yellow-400';
|
||||
case 'low':
|
||||
return 'border-l-4 border-l-blue-400';
|
||||
default:
|
||||
return 'border-l-4 border-l-transparent';
|
||||
}
|
||||
};
|
||||
|
||||
const SubtasksDisplay: React.FC<SubtasksDisplayProps> = ({
|
||||
showSubtasks,
|
||||
loadingSubtasks,
|
||||
subtasks,
|
||||
onTaskClick,
|
||||
|
|
@ -27,102 +50,93 @@ const SubtasksDisplay: React.FC<SubtasksDisplayProps> = ({
|
|||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!showSubtasks) return null;
|
||||
if (loadingSubtasks) {
|
||||
return (
|
||||
<div className="ml-[10%] text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('loading.subtasks', 'Loading subtasks...')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (subtasks.length === 0) {
|
||||
return (
|
||||
<div className="ml-[10%] text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('subtasks.noSubtasks', 'No subtasks found')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-1 space-y-1">
|
||||
{loadingSubtasks ? (
|
||||
<div className="ml-12 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('loading.subtasks', 'Loading subtasks...')}
|
||||
</div>
|
||||
) : subtasks.length > 0 ? (
|
||||
subtasks.map((subtask, index) => (
|
||||
<div
|
||||
key={subtask.id || `subtask-${index}`}
|
||||
className="ml-12 group"
|
||||
>
|
||||
{subtasks.map((subtask) => {
|
||||
const borderClass = isTaskCompleted(subtask.status)
|
||||
? 'border-l-4 border-l-green-500'
|
||||
: getPriorityBorderClassName(subtask.priority);
|
||||
return (
|
||||
<div key={subtask.id} className="ml-[10%]">
|
||||
<div
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 cursor-pointer transition-all duration-200 ${
|
||||
subtask.status === 'in_progress' ||
|
||||
subtask.status === 1
|
||||
? 'border border-blue-500/60 dark:border-blue-600/60'
|
||||
: ''
|
||||
}`}
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 relative overflow-visible transition-colors duration-200 ease-in-out hover:ring-1 hover:ring-gray-200 dark:hover:ring-gray-700 cursor-pointer ${borderClass}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskClick(e, subtask);
|
||||
}}
|
||||
>
|
||||
<div className="px-4 py-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">
|
||||
<TaskPriorityIcon
|
||||
priority={subtask.priority || 'low'}
|
||||
status={
|
||||
subtask.status || 'not_started'
|
||||
}
|
||||
onToggleCompletion={async () => {
|
||||
if (subtask.uid) {
|
||||
try {
|
||||
const updatedSubtask =
|
||||
await toggleTaskCompletion(
|
||||
subtask.uid,
|
||||
subtask
|
||||
);
|
||||
|
||||
// Check if parent-child logic was executed
|
||||
if (
|
||||
updatedSubtask.parent_child_logic_executed
|
||||
) {
|
||||
// For subtasks, we need a full page refresh because the parent task
|
||||
// might be displayed in multiple places (task list, today view, etc.)
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the subtask in local state immediately
|
||||
onSubtaskUpdate(
|
||||
updatedSubtask
|
||||
<div className="px-3 py-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||
<TaskPriorityIcon
|
||||
priority={subtask.priority || 'low'}
|
||||
status={subtask.status || 'not_started'}
|
||||
onToggleCompletion={async () => {
|
||||
if (subtask.uid) {
|
||||
try {
|
||||
const updatedSubtask =
|
||||
await toggleTaskCompletion(
|
||||
subtask.uid,
|
||||
subtask
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error toggling subtask completion:',
|
||||
error
|
||||
);
|
||||
// Refresh subtasks on error
|
||||
await loadSubtasks();
|
||||
|
||||
if (
|
||||
updatedSubtask.parent_child_logic_executed
|
||||
) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubtaskUpdate(
|
||||
updatedSubtask
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error toggling subtask completion:',
|
||||
error
|
||||
);
|
||||
await loadSubtasks();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`text-base font-medium flex-1 truncate ${
|
||||
subtask.status === 'done' ||
|
||||
subtask.status === 2 ||
|
||||
subtask.status === 'archived' ||
|
||||
subtask.status === 3
|
||||
? 'text-gray-500 dark:text-gray-400'
|
||||
className={`text-sm flex-1 truncate ${
|
||||
isTaskCompleted(subtask.status)
|
||||
? 'text-gray-500 dark:text-gray-400 line-through'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{subtask.name}
|
||||
{subtask.original_name || subtask.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* Right side status indicators removed */}
|
||||
</div>
|
||||
{isTaskCompleted(subtask.status) && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="ml-12 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('subtasks.noSubtasks', 'No subtasks found')}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -171,16 +185,30 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
|
||||
|
||||
// Subtasks state
|
||||
const [showSubtasks, setShowSubtasks] = useState(false);
|
||||
const [subtasks, setSubtasks] = useState<Task[]>([]);
|
||||
const [loadingSubtasks, setLoadingSubtasks] = useState(false);
|
||||
const [hasSubtasks, setHasSubtasks] = useState(false);
|
||||
const [showSubtasks, setShowSubtasks] = useState(false);
|
||||
|
||||
// Update projectList when projects prop changes
|
||||
useEffect(() => {
|
||||
setProjectList(projects);
|
||||
}, [projects]);
|
||||
|
||||
const loadSubtasks = useCallback(async () => {
|
||||
if (!task.id) return;
|
||||
|
||||
setLoadingSubtasks(true);
|
||||
try {
|
||||
const subtasksData = await fetchSubtasks(task.id);
|
||||
setSubtasks(subtasksData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load subtasks:', error);
|
||||
setSubtasks([]);
|
||||
} finally {
|
||||
setLoadingSubtasks(false);
|
||||
}
|
||||
}, [task.id]);
|
||||
|
||||
// Calculate completion percentage
|
||||
const calculateCompletionPercentage = () => {
|
||||
if (subtasks.length === 0) return 0;
|
||||
|
|
@ -195,58 +223,22 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
};
|
||||
|
||||
const completionPercentage = calculateCompletionPercentage();
|
||||
const hasInitialSubtasks =
|
||||
(task.subtasks && task.subtasks.length > 0) ||
|
||||
(task.Subtasks && task.Subtasks.length > 0);
|
||||
const shouldShowSubtasksIcon =
|
||||
hasInitialSubtasks || subtasks.length > 0 || loadingSubtasks;
|
||||
|
||||
// Check if task has subtasks using the included subtasks data
|
||||
useEffect(() => {
|
||||
// Handle both 'subtasks' and 'Subtasks' property names (case sensitivity)
|
||||
const subtasksData = task.subtasks || task.Subtasks || [];
|
||||
const hasSubtasksFromData = subtasksData.length > 0;
|
||||
|
||||
// Update subtasks and hasSubtasks state based on task data
|
||||
setHasSubtasks(hasSubtasksFromData);
|
||||
setSubtasks(subtasksData);
|
||||
}, [task.id, task.subtasks, task.Subtasks]); // Removed task.updated_at which was causing frequent re-renders
|
||||
}, [task.id, task.subtasks, task.Subtasks]);
|
||||
|
||||
const loadSubtasks = async () => {
|
||||
if (!task.id) return;
|
||||
|
||||
// If subtasks are already included in the task data, use them (handle case sensitivity)
|
||||
const subtasksData = task.subtasks || task.Subtasks || [];
|
||||
if (subtasksData.length > 0) {
|
||||
setSubtasks(subtasksData);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only fetch if not already included (fallback for older API responses)
|
||||
setLoadingSubtasks(true);
|
||||
try {
|
||||
const subtasksData = await fetchSubtasks(task.id);
|
||||
setSubtasks(subtasksData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load subtasks:', error);
|
||||
setSubtasks([]);
|
||||
} finally {
|
||||
setLoadingSubtasks(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reload subtasks when showSubtasks changes to true
|
||||
useEffect(() => {
|
||||
if (showSubtasks && subtasks.length === 0) {
|
||||
loadSubtasks();
|
||||
}
|
||||
}, [showSubtasks, subtasks.length]);
|
||||
|
||||
const handleSubtasksToggle = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!showSubtasks && subtasks.length === 0) {
|
||||
await loadSubtasks();
|
||||
}
|
||||
|
||||
setShowSubtasks(!showSubtasks);
|
||||
};
|
||||
|
||||
setShowSubtasks(false);
|
||||
}, [task.id]);
|
||||
const handleTaskClick = () => {
|
||||
if (task.uid) {
|
||||
if (task.habit_mode) {
|
||||
|
|
@ -270,6 +262,19 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
setSelectedSubtask(null);
|
||||
};
|
||||
|
||||
const handleSubtasksToggle = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!showSubtasks) {
|
||||
if (subtasks.length === 0) {
|
||||
await loadSubtasks();
|
||||
}
|
||||
setShowSubtasks(true);
|
||||
} else {
|
||||
setShowSubtasks(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubtaskDelete = async () => {
|
||||
if (selectedSubtask && selectedSubtask.uid) {
|
||||
await onTaskDelete(selectedSubtask.uid);
|
||||
|
|
@ -445,45 +450,14 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
// Check if task is overdue (created yesterday or earlier and not completed)
|
||||
const isOverdue = isTaskOverdue(task);
|
||||
|
||||
const getPriorityBorderClass = () => {
|
||||
// Show green border for completed tasks
|
||||
if (
|
||||
task.status === 'done' ||
|
||||
task.status === 2 ||
|
||||
task.status === 'archived' ||
|
||||
task.status === 3
|
||||
) {
|
||||
return 'border-l-4 border-l-green-500';
|
||||
}
|
||||
|
||||
let priority = task.priority;
|
||||
if (typeof priority === 'number') {
|
||||
const priorityNames: Array<'low' | 'medium' | 'high'> = [
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
];
|
||||
priority = priorityNames[priority] || undefined;
|
||||
}
|
||||
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'border-l-4 border-l-red-500';
|
||||
case 'medium':
|
||||
return 'border-l-4 border-l-yellow-400';
|
||||
case 'low':
|
||||
return 'border-l-4 border-l-blue-400';
|
||||
default:
|
||||
return 'border-l-4 border-l-transparent';
|
||||
}
|
||||
};
|
||||
|
||||
const priorityBorderClass = getPriorityBorderClass();
|
||||
const priorityBorderClass = isTaskCompleted(task.status)
|
||||
? 'border-l-4 border-l-green-500'
|
||||
: getPriorityBorderClassName(task.priority);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 relative overflow-visible transition-opacity duration-300 ease-in-out ${priorityBorderClass} ${
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 relative overflow-visible transition-colors duration-200 ease-in-out hover:ring-1 hover:ring-gray-200 dark:hover:ring-gray-700 ${priorityBorderClass} ${
|
||||
isInProgress
|
||||
? 'ring-1 ring-blue-500/60 dark:ring-blue-600/60'
|
||||
: ''
|
||||
|
|
@ -499,8 +473,12 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
onTaskUpdate={onTaskUpdate}
|
||||
isOverdue={isOverdue}
|
||||
showSubtasks={showSubtasks}
|
||||
hasSubtasks={hasSubtasks}
|
||||
onSubtasksToggle={handleSubtasksToggle}
|
||||
hasSubtasks={shouldShowSubtasksIcon}
|
||||
onSubtasksToggle={
|
||||
shouldShowSubtasksIcon
|
||||
? handleSubtasksToggle
|
||||
: undefined
|
||||
}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDeleteClick}
|
||||
isUpcomingView={isUpcomingView}
|
||||
|
|
@ -508,14 +486,8 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
|
||||
{/* Progress bar at bottom of parent task */}
|
||||
{subtasks.length > 0 && (
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 h-0.5 transition-all duration-300 ease-in-out overflow-hidden rounded-b-lg ${
|
||||
showSubtasks
|
||||
? 'opacity-100 transform translate-y-0'
|
||||
: 'opacity-0 transform translate-y-2'
|
||||
}`}
|
||||
>
|
||||
<div className="w-full h-full bg-gray-200 dark:bg-gray-700">
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 opacity-100">
|
||||
<div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 ml-1 rounded-r-lg overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-green-400 via-green-500 to-green-600 transition-all duration-500 ease-out"
|
||||
style={{ width: `${completionPercentage}%` }}
|
||||
|
|
@ -526,28 +498,28 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Hide subtasks display for archived tasks */}
|
||||
{!(task.status === 'archived' || task.status === 3) && (
|
||||
<SubtasksDisplay
|
||||
showSubtasks={showSubtasks}
|
||||
loadingSubtasks={loadingSubtasks}
|
||||
subtasks={subtasks}
|
||||
onTaskClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSubtaskClick();
|
||||
}}
|
||||
onTaskUpdate={onTaskUpdate}
|
||||
loadSubtasks={loadSubtasks}
|
||||
onSubtaskUpdate={(updatedSubtask) => {
|
||||
setSubtasks((prev) =>
|
||||
prev.map((st) =>
|
||||
st.id === updatedSubtask.id
|
||||
? updatedSubtask
|
||||
: st
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showSubtasks &&
|
||||
(subtasks.length > 0 || loadingSubtasks) &&
|
||||
!(task.status === 'archived' || task.status === 3) && (
|
||||
<SubtasksDisplay
|
||||
loadingSubtasks={loadingSubtasks}
|
||||
subtasks={subtasks}
|
||||
onTaskClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSubtaskClick();
|
||||
}}
|
||||
loadSubtasks={loadSubtasks}
|
||||
onSubtaskUpdate={(updatedSubtask) => {
|
||||
setSubtasks((prev) =>
|
||||
prev.map((st) =>
|
||||
st.id === updatedSubtask.id
|
||||
? updatedSubtask
|
||||
: st
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TaskModal
|
||||
isOpen={isModalOpen}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@ import {
|
|||
CheckCircleIcon,
|
||||
ArchiveBoxIcon,
|
||||
ArrowPathIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
CalendarIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { StatusType } from '../../entities/Task';
|
||||
import { getStatusString } from '../../constants/taskStatus';
|
||||
|
||||
interface TaskStatusBadgeProps {
|
||||
status: StatusType | number;
|
||||
|
|
@ -16,20 +20,6 @@ const TaskStatusBadge: React.FC<TaskStatusBadgeProps> = ({
|
|||
status,
|
||||
className,
|
||||
}) => {
|
||||
// Convert numeric status to string
|
||||
const getStatusString = (status: StatusType | number): StatusType => {
|
||||
if (typeof status === 'number') {
|
||||
const statusNames: StatusType[] = [
|
||||
'not_started',
|
||||
'in_progress',
|
||||
'done',
|
||||
'archived',
|
||||
];
|
||||
return statusNames[status] || 'not_started';
|
||||
}
|
||||
return status;
|
||||
};
|
||||
|
||||
const statusString = getStatusString(status);
|
||||
let statusIcon;
|
||||
|
||||
|
|
@ -37,12 +27,21 @@ const TaskStatusBadge: React.FC<TaskStatusBadgeProps> = ({
|
|||
case 'not_started':
|
||||
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
|
||||
break;
|
||||
case 'planned':
|
||||
statusIcon = <CalendarIcon className="h-4 w-4 text-purple-400" />;
|
||||
break;
|
||||
case 'in_progress':
|
||||
statusIcon = <ArrowPathIcon className="h-4 w-4 text-blue-400" />;
|
||||
break;
|
||||
case 'waiting':
|
||||
statusIcon = <ClockIcon className="h-4 w-4 text-yellow-400" />;
|
||||
break;
|
||||
case 'done':
|
||||
statusIcon = <CheckCircleIcon className="h-4 w-4 text-green-400" />;
|
||||
break;
|
||||
case 'cancelled':
|
||||
statusIcon = <XCircleIcon className="h-4 w-4 text-red-400" />;
|
||||
break;
|
||||
case 'archived':
|
||||
statusIcon = <ArchiveBoxIcon className="h-4 w-4 text-gray-400" />;
|
||||
break;
|
||||
|
|
|
|||
522
frontend/components/Task/TaskStatusControl.tsx
Normal file
522
frontend/components/Task/TaskStatusControl.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
PlayIcon,
|
||||
PauseCircleIcon,
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
CalendarIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Task, StatusType } from '../../entities/Task';
|
||||
import {
|
||||
isTaskCompleted,
|
||||
isTaskInProgress,
|
||||
isTaskNotStarted,
|
||||
getStatusString,
|
||||
} from '../../constants/taskStatus';
|
||||
import {
|
||||
getStatusBorderColorClasses,
|
||||
getStatusButtonColorClasses,
|
||||
} from './statusStyles';
|
||||
|
||||
type CompletionMenuTarget = 'desktop' | 'mobile';
|
||||
|
||||
interface TaskStatusControlProps {
|
||||
task: Task;
|
||||
onToggleCompletion?: () => void;
|
||||
onTaskUpdate?: (task: Task) => Promise<void>;
|
||||
hoverRevealQuickActions?: boolean;
|
||||
showMobileVariant?: boolean;
|
||||
className?: string;
|
||||
variant?: 'pill' | 'square';
|
||||
showQuickActions?: boolean;
|
||||
}
|
||||
|
||||
const quickStartStatuses = new Set([
|
||||
'not_started',
|
||||
'planned',
|
||||
'waiting',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
const TaskStatusControl: React.FC<TaskStatusControlProps> = ({
|
||||
task,
|
||||
onToggleCompletion,
|
||||
onTaskUpdate,
|
||||
hoverRevealQuickActions = true,
|
||||
showMobileVariant = true,
|
||||
className = '',
|
||||
variant = 'square',
|
||||
showQuickActions = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [completionMenuOpen, setCompletionMenuOpen] =
|
||||
useState<CompletionMenuTarget | null>(null);
|
||||
const [isCompletingTask, setIsCompletingTask] = useState(false);
|
||||
const desktopCompletionMenuRef = useRef<HTMLDivElement>(null);
|
||||
const mobileCompletionMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!completionMenuOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
const activeRef =
|
||||
completionMenuOpen === 'desktop'
|
||||
? desktopCompletionMenuRef.current
|
||||
: mobileCompletionMenuRef.current;
|
||||
|
||||
if (activeRef && activeRef.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCompletionMenuOpen(null);
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}, [completionMenuOpen]);
|
||||
|
||||
const taskCompleted = isTaskCompleted(task.status);
|
||||
const taskInProgress = isTaskInProgress(task.status);
|
||||
const currentStatusString = getStatusString(task.status);
|
||||
|
||||
const completionButtonTextClass = taskCompleted
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: taskInProgress
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400';
|
||||
|
||||
const completionButtonHoverClass = taskCompleted
|
||||
? 'hover:bg-green-50 dark:hover:bg-green-900/40'
|
||||
: taskInProgress
|
||||
? 'hover:bg-blue-50 dark:hover:bg-blue-900/40'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800';
|
||||
|
||||
const completionButtonMainBgClass = taskCompleted
|
||||
? 'bg-green-100 dark:bg-green-900/50'
|
||||
: taskInProgress
|
||||
? 'bg-blue-100 dark:bg-blue-900/50'
|
||||
: 'bg-gray-200 dark:bg-gray-700';
|
||||
|
||||
const completionButtonMainTextClass = taskCompleted
|
||||
? 'text-green-900 dark:text-green-100 font-semibold'
|
||||
: taskInProgress
|
||||
? 'text-blue-900 dark:text-blue-100 font-semibold'
|
||||
: 'text-gray-900 dark:text-gray-100 font-semibold';
|
||||
|
||||
const isSquareVariant = variant === 'square';
|
||||
const textSizeClass = isSquareVariant ? 'text-xs' : 'text-sm';
|
||||
const gapClass = isSquareVariant ? 'gap-1.5' : 'gap-2';
|
||||
const iconSizeClass = isSquareVariant ? 'h-3.5 w-3.5' : 'h-4 w-4';
|
||||
const containerRoundedClass = isSquareVariant
|
||||
? 'rounded-lg'
|
||||
: 'rounded-full';
|
||||
const completionButtonPaddingClass = isSquareVariant
|
||||
? 'px-2.5 py-1'
|
||||
: 'px-3 py-1';
|
||||
const quickButtonPaddingClass = isSquareVariant ? 'px-1.5' : 'px-2';
|
||||
const hoverPaddingClass = isSquareVariant
|
||||
? 'md:group-hover:px-1.5'
|
||||
: 'md:group-hover:px-2';
|
||||
|
||||
const completionButtonMainClasses = `inline-flex items-center ${gapClass} ${textSizeClass} transition ${completionButtonMainTextClass} ${completionButtonMainBgClass} ${completionButtonHoverClass} focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500`;
|
||||
|
||||
const completionButtonChevronClasses = `inline-flex items-center justify-center transition ${completionButtonTextClass} ${completionButtonHoverClass} focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500`;
|
||||
|
||||
const statusButtonColorClasses = getStatusButtonColorClasses(task.status);
|
||||
const statusBorderColorClass = getStatusBorderColorClasses(task.status);
|
||||
|
||||
const showQuickStartButton =
|
||||
showQuickActions && quickStartStatuses.has(currentStatusString);
|
||||
const showQuickCompleteButton =
|
||||
showQuickActions && currentStatusString !== 'done';
|
||||
|
||||
const handleCompletionClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCompletionMenuOpen(null);
|
||||
|
||||
if (onToggleCompletion) {
|
||||
if (!taskCompleted) {
|
||||
setIsCompletingTask(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
}
|
||||
|
||||
onToggleCompletion();
|
||||
|
||||
setTimeout(() => {
|
||||
setIsCompletingTask(false);
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusSelection = async (
|
||||
e: React.MouseEvent,
|
||||
statusValue: StatusType
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCompletionMenuOpen(null);
|
||||
if (onTaskUpdate && task.id) {
|
||||
const updatedTask = {
|
||||
...task,
|
||||
status: statusValue,
|
||||
};
|
||||
await onTaskUpdate(updatedTask);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStatusMenuOptions = (menuType: CompletionMenuTarget) => {
|
||||
const options: StatusDropdownOption[] = [
|
||||
{
|
||||
value: 'not_started',
|
||||
label: t('task.status.notStarted', 'Not started'),
|
||||
Icon: PauseCircleIcon,
|
||||
activeClasses:
|
||||
'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 font-semibold border-l-2 border-gray-500 dark:border-gray-400',
|
||||
inactiveClasses:
|
||||
'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
activeIconClass: 'text-gray-600 dark:text-gray-300',
|
||||
inactiveIconClass: 'text-gray-500 dark:text-gray-400',
|
||||
},
|
||||
{
|
||||
value: 'planned',
|
||||
label: t('task.status.planned', 'Planned'),
|
||||
Icon: ClockIcon,
|
||||
activeClasses:
|
||||
'bg-purple-100 dark:bg-purple-900/50 text-purple-900 dark:text-purple-100 font-semibold border-l-2 border-purple-500 dark:border-purple-400',
|
||||
inactiveClasses:
|
||||
'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
activeIconClass: 'text-purple-600 dark:text-purple-300',
|
||||
inactiveIconClass: 'text-purple-500 dark:text-purple-400',
|
||||
},
|
||||
{
|
||||
value: 'in_progress',
|
||||
label: t('task.status.inProgress', 'In progress'),
|
||||
Icon: PlayIcon,
|
||||
activeClasses:
|
||||
'bg-blue-100 dark:bg-blue-900/50 text-blue-900 dark:text-blue-100 font-semibold border-l-2 border-blue-500 dark:border-blue-400',
|
||||
inactiveClasses:
|
||||
'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
activeIconClass: 'text-blue-600 dark:text-blue-300',
|
||||
inactiveIconClass: 'text-blue-500 dark:text-blue-400',
|
||||
},
|
||||
{
|
||||
value: 'waiting',
|
||||
label: t('task.status.waiting', 'Waiting'),
|
||||
Icon: ClockIcon,
|
||||
activeClasses:
|
||||
'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-900 dark:text-yellow-100 font-semibold border-l-2 border-yellow-500 dark:border-yellow-400',
|
||||
inactiveClasses:
|
||||
'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
activeIconClass: 'text-yellow-600 dark:text-yellow-300',
|
||||
inactiveIconClass: 'text-yellow-500 dark:text-yellow-400',
|
||||
},
|
||||
{
|
||||
value: 'cancelled',
|
||||
label: t('task.status.cancelled', 'Cancelled'),
|
||||
Icon: XCircleIcon,
|
||||
activeClasses:
|
||||
'bg-red-100 dark:bg-red-900/50 text-red-900 dark:text-red-100 font-semibold border-l-2 border-red-500 dark:border-red-400',
|
||||
inactiveClasses:
|
||||
'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
activeIconClass: 'text-red-600 dark:text-red-300',
|
||||
inactiveIconClass: 'text-red-500 dark:text-red-400',
|
||||
},
|
||||
{
|
||||
value: 'done',
|
||||
label: t('task.status.setAsDone', 'Set as done'),
|
||||
Icon: CheckIcon,
|
||||
activeClasses:
|
||||
'bg-green-100 dark:bg-green-900/50 text-green-900 dark:text-green-100 font-semibold border-l-2 border-green-500 dark:border-green-400',
|
||||
inactiveClasses:
|
||||
'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
activeIconClass: 'text-green-600 dark:text-green-300',
|
||||
inactiveIconClass: 'text-green-500 dark:text-green-400',
|
||||
completion: true,
|
||||
},
|
||||
];
|
||||
|
||||
const currentStatus = getStatusString(task.status);
|
||||
|
||||
return options.map((option, index) => {
|
||||
const isActive = currentStatus === option.value;
|
||||
const roundedClass =
|
||||
index === 0
|
||||
? 'rounded-t-lg'
|
||||
: index === options.length - 1
|
||||
? 'rounded-b-lg'
|
||||
: '';
|
||||
const iconClass = isActive
|
||||
? option.activeIconClass
|
||||
: option.inactiveIconClass;
|
||||
const stateClasses = isActive
|
||||
? option.activeClasses
|
||||
: option.inactiveClasses;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${menuType}-${option.value}`}
|
||||
type="button"
|
||||
onClick={async (event) => {
|
||||
if (option.completion) {
|
||||
await handleCompletionClick(event);
|
||||
} else {
|
||||
await handleStatusSelection(event, option.value);
|
||||
}
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${roundedClass} ${stateClasses}`}
|
||||
disabled={option.completion ? isCompletingTask : false}
|
||||
>
|
||||
<option.Icon className={`h-4 w-4 ${iconClass}`} />
|
||||
<span className="flex-1">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const quickButtonBaseClasses = `${completionButtonChevronClasses} ${statusButtonColorClasses} border-l ${statusBorderColorClass} flex transition-all duration-200`;
|
||||
const quickButtonClasses = hoverRevealQuickActions
|
||||
? `${quickButtonBaseClasses} ${quickButtonPaddingClass} md:px-0 md:w-0 md:opacity-0 md:pointer-events-none md:border-l-0 ${hoverPaddingClass} md:group-hover:w-auto md:group-hover:opacity-100 md:group-hover:pointer-events-auto md:group-hover:border-l`
|
||||
: `${quickButtonBaseClasses} ${quickButtonPaddingClass}`;
|
||||
|
||||
const quickCompleteClasses = hoverRevealQuickActions
|
||||
? `${completionButtonChevronClasses} ${statusButtonColorClasses} border-l ${statusBorderColorClass} flex transition-all duration-200 ${quickButtonPaddingClass} md:px-0 md:w-0 md:opacity-0 md:pointer-events-none md:border-l-0 ${hoverPaddingClass} md:group-hover:w-auto md:group-hover:opacity-100 md:group-hover:pointer-events-auto md:group-hover:border-l`
|
||||
: `${completionButtonChevronClasses} ${statusButtonColorClasses} border-l ${statusBorderColorClass} flex transition-all duration-200 ${quickButtonPaddingClass}`;
|
||||
|
||||
const statusDisplayConfig: Record<
|
||||
ReturnType<typeof getStatusString>,
|
||||
{
|
||||
label: string;
|
||||
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
}
|
||||
> = {
|
||||
not_started: {
|
||||
label: t('task.status.notStarted', 'Not started'),
|
||||
Icon: PauseCircleIcon,
|
||||
},
|
||||
planned: {
|
||||
label: t('task.status.planned', 'Planned'),
|
||||
Icon: CalendarIcon,
|
||||
},
|
||||
in_progress: {
|
||||
label: t('task.status.inProgress', 'In progress'),
|
||||
Icon: PlayIcon,
|
||||
},
|
||||
waiting: {
|
||||
label: t('task.status.waiting', 'Waiting'),
|
||||
Icon: ClockIcon,
|
||||
},
|
||||
cancelled: {
|
||||
label: t('task.status.cancelled', 'Cancelled'),
|
||||
Icon: XCircleIcon,
|
||||
},
|
||||
done: {
|
||||
label: t('tasks.done', 'Done'),
|
||||
Icon: CheckIcon,
|
||||
},
|
||||
archived: {
|
||||
label: t('task.status.archived', 'Archived'),
|
||||
Icon: CheckIcon,
|
||||
},
|
||||
};
|
||||
|
||||
const statusDisplay =
|
||||
statusDisplayConfig[currentStatusString] ||
|
||||
statusDisplayConfig.not_started;
|
||||
const CompletionIcon = statusDisplay.Icon;
|
||||
const completionButtonLabel = statusDisplay.label;
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div
|
||||
className={`inline-flex items-stretch ${containerRoundedClass} border ${statusBorderColorClass} overflow-hidden ${hoverRevealQuickActions ? 'group' : ''}`}
|
||||
ref={desktopCompletionMenuRef}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={
|
||||
taskInProgress ||
|
||||
(!taskCompleted &&
|
||||
(task.status === 'not_started' ||
|
||||
isTaskNotStarted(task.status)))
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
: handleCompletionClick
|
||||
}
|
||||
className={`${completionButtonMainClasses} ${completionButtonPaddingClass} ${statusButtonColorClasses}`}
|
||||
title={
|
||||
taskCompleted
|
||||
? t('common.undo', 'Undo')
|
||||
: taskInProgress
|
||||
? t('tasks.inProgress', 'In Progress')
|
||||
: t('tasks.notStarted', 'Not Started')
|
||||
}
|
||||
>
|
||||
<CompletionIcon className={iconSizeClass} />
|
||||
{completionButtonLabel}
|
||||
</button>
|
||||
{showQuickStartButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async (e) => {
|
||||
await handleStatusSelection(e, 'in_progress');
|
||||
}}
|
||||
className={quickButtonClasses}
|
||||
title={t('tasks.setInProgress', 'Set in progress')}
|
||||
>
|
||||
<PlayIcon className={iconSizeClass} />
|
||||
</button>
|
||||
)}
|
||||
{showQuickCompleteButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCompletionClick(e);
|
||||
}}
|
||||
className={quickCompleteClasses}
|
||||
title={t('tasks.markAsDone', 'Mark as done')}
|
||||
disabled={isCompletingTask}
|
||||
>
|
||||
<CheckIcon
|
||||
className={`${iconSizeClass} transition-all duration-300 ${isCompletingTask ? 'scale-125 text-green-600 dark:text-green-400' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCompletionMenuOpen((prev) =>
|
||||
prev === 'desktop' ? null : 'desktop'
|
||||
);
|
||||
}}
|
||||
className={`${completionButtonChevronClasses} ${quickButtonPaddingClass} border-l ${statusBorderColorClass}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={completionMenuOpen === 'desktop'}
|
||||
>
|
||||
<ChevronDownIcon className={iconSizeClass} />
|
||||
</button>
|
||||
</div>
|
||||
{completionMenuOpen === 'desktop' && (
|
||||
<div
|
||||
className={`absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-900 border ${statusBorderColorClass} rounded-lg shadow-lg z-[9999]`}
|
||||
>
|
||||
{renderStatusMenuOptions('desktop')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMobileVariant && (
|
||||
<div
|
||||
className="mt-2 relative block md:hidden"
|
||||
ref={mobileCompletionMenuRef}
|
||||
>
|
||||
<div
|
||||
className={`inline-flex items-stretch ${containerRoundedClass} border ${statusBorderColorClass} overflow-hidden text-xs`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={
|
||||
taskInProgress ||
|
||||
(!taskCompleted &&
|
||||
(task.status === 'not_started' ||
|
||||
isTaskNotStarted(task.status)))
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
: handleCompletionClick
|
||||
}
|
||||
className={`${completionButtonMainClasses} px-2 py-1 ${statusButtonColorClasses}`}
|
||||
>
|
||||
<CompletionIcon className="h-3.5 w-3.5" />
|
||||
<span className="ml-1">
|
||||
{completionButtonLabel}
|
||||
</span>
|
||||
</button>
|
||||
{showQuickStartButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async (e) => {
|
||||
await handleStatusSelection(
|
||||
e,
|
||||
'in_progress'
|
||||
);
|
||||
}}
|
||||
className={`${completionButtonChevronClasses} ${statusButtonColorClasses} px-2 border-l ${statusBorderColorClass}`}
|
||||
title={t(
|
||||
'tasks.setInProgress',
|
||||
'Set in progress'
|
||||
)}
|
||||
>
|
||||
<PlayIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{showQuickCompleteButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCompletionClick(e);
|
||||
}}
|
||||
className={`${isCompletingTask ? 'bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400' : `${completionButtonChevronClasses} ${statusButtonColorClasses}`} px-2 border-l ${statusBorderColorClass} transition-all duration-300`}
|
||||
title={t('tasks.markAsDone', 'Mark as done')}
|
||||
disabled={isCompletingTask}
|
||||
>
|
||||
<CheckIcon
|
||||
className={`h-3.5 w-3.5 transition-all duration-300 ${isCompletingTask ? 'scale-125 text-green-600 dark:text-green-400' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCompletionMenuOpen((prev) =>
|
||||
prev === 'mobile' ? null : 'mobile'
|
||||
);
|
||||
}}
|
||||
className={`${completionButtonChevronClasses} px-2 border-l ${statusBorderColorClass}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={completionMenuOpen === 'mobile'}
|
||||
>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{completionMenuOpen === 'mobile' && (
|
||||
<div
|
||||
className={`absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-900 border ${statusBorderColorClass} rounded-lg shadow-lg z-[9999]`}
|
||||
>
|
||||
{renderStatusMenuOptions('mobile')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StatusDropdownOption {
|
||||
value: StatusType;
|
||||
label: string;
|
||||
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
activeClasses: string;
|
||||
inactiveClasses: string;
|
||||
activeIconClass: string;
|
||||
inactiveIconClass: string;
|
||||
completion?: boolean;
|
||||
}
|
||||
|
||||
export default TaskStatusControl;
|
||||
|
|
@ -26,6 +26,11 @@ import {
|
|||
deleteTask,
|
||||
toggleTaskToday,
|
||||
} from '../../utils/tasksService';
|
||||
import {
|
||||
isTaskDone,
|
||||
isTaskActive,
|
||||
isHabitArchived,
|
||||
} from '../../constants/taskStatus';
|
||||
import { fetchProjects } from '../../utils/projectsService';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { useStore } from '../../store/useStore';
|
||||
|
|
@ -150,17 +155,12 @@ const TasksToday: React.FC = () => {
|
|||
tasks_completed_today: [],
|
||||
});
|
||||
|
||||
// Pagination state for Today Plan tasks
|
||||
const [pagination, setPagination] = useState({
|
||||
total: 0,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
// Client-side pagination for Due Today tasks (since backend returns all)
|
||||
const [dueTodayDisplayLimit, setDueTodayDisplayLimit] = useState(20);
|
||||
|
||||
// Client-side pagination for Planned tasks (since backend returns all)
|
||||
const [plannedDisplayLimit, setPlannedDisplayLimit] = useState(20);
|
||||
|
||||
// Client-side pagination for Overdue tasks (since backend returns all)
|
||||
const [overdueDisplayLimit, setOverdueDisplayLimit] = useState(20);
|
||||
|
||||
|
|
@ -172,10 +172,11 @@ const TasksToday: React.FC = () => {
|
|||
useState(20);
|
||||
const [habitActionUid, setHabitActionUid] = useState<string | null>(null);
|
||||
|
||||
const plannedTasks = useMemo(
|
||||
() => filterNonHabitTasks(metrics.today_plan_tasks || []),
|
||||
[metrics.today_plan_tasks]
|
||||
);
|
||||
const plannedTasks = useMemo(() => {
|
||||
// Only use today_plan_tasks from backend - it already filters by status
|
||||
// (in_progress, planned, waiting) regardless of the 'today' field
|
||||
return filterNonHabitTasks(metrics.today_plan_tasks || []);
|
||||
}, [metrics.today_plan_tasks]);
|
||||
const completedTasksList = useMemo(
|
||||
() => filterNonHabitTasks(metrics.tasks_completed_today || []),
|
||||
[metrics.tasks_completed_today]
|
||||
|
|
@ -297,9 +298,6 @@ const TasksToday: React.FC = () => {
|
|||
localStorage.setItem('dueTodayTasksCollapsed', newState.toString());
|
||||
};
|
||||
|
||||
const isHabitArchived = (habit: Task) =>
|
||||
habit.status === 3 || habit.status === 'archived';
|
||||
|
||||
const isHabitCompletedToday = useCallback((habit: Task) => {
|
||||
if (!habit.habit_last_completion_at) {
|
||||
return false;
|
||||
|
|
@ -317,7 +315,8 @@ const TasksToday: React.FC = () => {
|
|||
() =>
|
||||
todayHabits.filter(
|
||||
(habit) =>
|
||||
!isHabitArchived(habit) && !isHabitCompletedToday(habit)
|
||||
!isHabitArchived(habit.status) &&
|
||||
!isHabitCompletedToday(habit)
|
||||
),
|
||||
[todayHabits, isHabitCompletedToday]
|
||||
);
|
||||
|
|
@ -326,7 +325,8 @@ const TasksToday: React.FC = () => {
|
|||
() =>
|
||||
todayHabits.filter(
|
||||
(habit) =>
|
||||
!isHabitArchived(habit) && isHabitCompletedToday(habit)
|
||||
!isHabitArchived(habit.status) &&
|
||||
isHabitCompletedToday(habit)
|
||||
),
|
||||
[todayHabits, isHabitCompletedToday]
|
||||
);
|
||||
|
|
@ -493,17 +493,12 @@ const TasksToday: React.FC = () => {
|
|||
tasks_in_progress: result.tasks_in_progress || [],
|
||||
tasks_due_today: result.tasks_due_today || [],
|
||||
tasks_overdue: result.tasks_overdue || [],
|
||||
today_plan_tasks: result.tasks || [], // Main tasks array is today plan
|
||||
today_plan_tasks: result.tasks_today_plan || [],
|
||||
suggested_tasks: result.suggested_tasks || [],
|
||||
tasks_completed_today:
|
||||
result.tasks_completed_today || [],
|
||||
} as any);
|
||||
|
||||
// Update pagination state if pagination metadata is present
|
||||
if (result.pagination) {
|
||||
setPagination(result.pagination);
|
||||
}
|
||||
|
||||
useStore.getState().tasksStore.setTasks(result.tasks);
|
||||
setIsError(false);
|
||||
}
|
||||
|
|
@ -774,7 +769,7 @@ const TasksToday: React.FC = () => {
|
|||
); // Always remove from completed first
|
||||
|
||||
// Now, add the task to the appropriate list(s) based on its new status
|
||||
if (updatedTask.status === 'done' || updatedTask.status === 2) {
|
||||
if (isTaskDone(updatedTask.status)) {
|
||||
// If completed, add to tasks_completed_today if it was completed today
|
||||
if (updatedTask.completed_at) {
|
||||
const completedDate = new Date(
|
||||
|
|
@ -795,7 +790,7 @@ const TasksToday: React.FC = () => {
|
|||
// If not completed, add to relevant active lists
|
||||
if (
|
||||
updatedTask.today &&
|
||||
updatedTask.status !== 'archived'
|
||||
updatedTask.status !== 'cancelled'
|
||||
) {
|
||||
newMetrics.today_plan_tasks = updateOrAddTask(
|
||||
newMetrics.today_plan_tasks,
|
||||
|
|
@ -811,7 +806,7 @@ const TasksToday: React.FC = () => {
|
|||
// Check if task has a due date (and not already in today_plan_tasks or in_progress)
|
||||
if (
|
||||
updatedTask.due_date &&
|
||||
updatedTask.status !== 'archived' &&
|
||||
updatedTask.status !== 'cancelled' &&
|
||||
!newMetrics.today_plan_tasks.some(
|
||||
(t) => t.id === updatedTask.id
|
||||
) &&
|
||||
|
|
@ -840,22 +835,15 @@ const TasksToday: React.FC = () => {
|
|||
);
|
||||
}
|
||||
}
|
||||
// Check for suggested tasks (and not already in other active lists)
|
||||
const isSuggested =
|
||||
!updatedTask.today &&
|
||||
!updatedTask.project_id &&
|
||||
!updatedTask.due_date;
|
||||
// Check if task is not completed (can be string or number)
|
||||
const taskStatus = updatedTask.status as string | number;
|
||||
const isNotCompleted =
|
||||
taskStatus !== 'archived' &&
|
||||
taskStatus !== 'done' &&
|
||||
taskStatus !== 2 &&
|
||||
taskStatus !== 3;
|
||||
const isActive = isTaskActive(updatedTask.status);
|
||||
|
||||
if (
|
||||
isSuggested &&
|
||||
isNotCompleted &&
|
||||
isActive &&
|
||||
!newMetrics.today_plan_tasks.some(
|
||||
(t) => t.id === updatedTask.id
|
||||
) &&
|
||||
|
|
@ -884,15 +872,6 @@ const TasksToday: React.FC = () => {
|
|||
return newMetrics;
|
||||
});
|
||||
|
||||
// Update pagination total to match the actual count of today_plan_tasks
|
||||
setMetrics((prevMetrics) => {
|
||||
setPagination((prevPagination) => ({
|
||||
...prevPagination,
|
||||
total: prevMetrics.today_plan_tasks?.length || 0,
|
||||
}));
|
||||
return prevMetrics;
|
||||
});
|
||||
|
||||
// Update the store with the updated task
|
||||
useStore.getState().tasksStore.updateTaskInStore(updatedTask);
|
||||
|
||||
|
|
@ -966,15 +945,6 @@ const TasksToday: React.FC = () => {
|
|||
return newMetrics;
|
||||
});
|
||||
|
||||
// Update pagination total after server response
|
||||
setMetrics((prevMetrics) => {
|
||||
setPagination((prevPagination) => ({
|
||||
...prevPagination,
|
||||
total: prevMetrics.today_plan_tasks?.length || 0,
|
||||
}));
|
||||
return prevMetrics;
|
||||
});
|
||||
|
||||
// Also update the store with server response
|
||||
useStore
|
||||
.getState()
|
||||
|
|
@ -1004,17 +974,11 @@ const TasksToday: React.FC = () => {
|
|||
tasks_in_progress: result.tasks_in_progress || [],
|
||||
tasks_due_today: result.tasks_due_today || [],
|
||||
tasks_overdue: result.tasks_overdue || [],
|
||||
today_plan_tasks: result.tasks || [],
|
||||
today_plan_tasks: result.tasks_today_plan || [],
|
||||
suggested_tasks: result.suggested_tasks || [],
|
||||
tasks_completed_today:
|
||||
result.tasks_completed_today || [],
|
||||
} as any);
|
||||
// Update pagination to match the reloaded tasks
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
...(result.pagination || {}),
|
||||
total: result.tasks?.length || 0, // Use actual task count
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting task:', error);
|
||||
|
|
@ -1039,17 +1003,11 @@ const TasksToday: React.FC = () => {
|
|||
tasks_in_progress: result.tasks_in_progress || [],
|
||||
tasks_due_today: result.tasks_due_today || [],
|
||||
tasks_overdue: result.tasks_overdue || [],
|
||||
today_plan_tasks: result.tasks || [],
|
||||
today_plan_tasks: result.tasks_today_plan || [],
|
||||
suggested_tasks: result.suggested_tasks || [],
|
||||
tasks_completed_today:
|
||||
result.tasks_completed_today || [],
|
||||
} as any);
|
||||
// Update pagination to match the reloaded tasks
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
...(result.pagination || {}),
|
||||
total: result.tasks?.length || 0, // Use actual task count
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling task today status:', error);
|
||||
|
|
@ -1073,106 +1031,6 @@ const TasksToday: React.FC = () => {
|
|||
[handleTaskUpdate]
|
||||
);
|
||||
|
||||
// Load more tasks (pagination)
|
||||
const handleLoadMore = useCallback(
|
||||
async (all: boolean = false) => {
|
||||
if (!isMounted.current || isLoading) return;
|
||||
if (!all && !pagination.hasMore) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
let limit: number, offset: number;
|
||||
if (all) {
|
||||
// Load all remaining tasks
|
||||
limit = pagination.total > 0 ? pagination.total : 10000;
|
||||
offset = 0;
|
||||
} else {
|
||||
// Load next page
|
||||
limit = pagination.limit;
|
||||
offset = pagination.offset + pagination.limit;
|
||||
}
|
||||
|
||||
const result = await fetchTasks(
|
||||
`?type=today&limit=${limit}&offset=${offset}`
|
||||
);
|
||||
|
||||
if (isMounted.current) {
|
||||
if (all) {
|
||||
// Replace all tasks when loading all
|
||||
setMetrics({
|
||||
...result.metrics,
|
||||
tasks_in_progress: result.tasks_in_progress || [],
|
||||
tasks_due_today: result.tasks_due_today || [],
|
||||
tasks_overdue: result.tasks_overdue || [],
|
||||
today_plan_tasks: result.tasks || [],
|
||||
suggested_tasks: result.suggested_tasks || [],
|
||||
tasks_completed_today:
|
||||
result.tasks_completed_today || [],
|
||||
} as any);
|
||||
|
||||
useStore.getState().tasksStore.setTasks(result.tasks);
|
||||
} else {
|
||||
// Append new tasks to existing ones
|
||||
setMetrics((prevMetrics) => ({
|
||||
...result.metrics,
|
||||
tasks_in_progress: [
|
||||
...(prevMetrics.tasks_in_progress || []),
|
||||
...(result.tasks_in_progress || []),
|
||||
],
|
||||
tasks_due_today: [
|
||||
...(prevMetrics.tasks_due_today || []),
|
||||
...(result.tasks_due_today || []),
|
||||
],
|
||||
tasks_overdue: [
|
||||
...(prevMetrics.tasks_overdue || []),
|
||||
...(result.tasks_overdue || []),
|
||||
],
|
||||
today_plan_tasks: [
|
||||
...(prevMetrics.today_plan_tasks || []),
|
||||
...(result.tasks || []),
|
||||
],
|
||||
suggested_tasks: [
|
||||
...(prevMetrics.suggested_tasks || []),
|
||||
...(result.suggested_tasks || []),
|
||||
],
|
||||
tasks_completed_today: [
|
||||
...(prevMetrics.tasks_completed_today || []),
|
||||
...(result.tasks_completed_today || []),
|
||||
],
|
||||
}));
|
||||
|
||||
// Append tasks to store
|
||||
const currentTasks =
|
||||
useStore.getState().tasksStore.tasks;
|
||||
useStore
|
||||
.getState()
|
||||
.tasksStore.setTasks([
|
||||
...currentTasks,
|
||||
...result.tasks,
|
||||
]);
|
||||
}
|
||||
|
||||
// Update pagination state
|
||||
if (result.pagination) {
|
||||
setPagination(result.pagination);
|
||||
}
|
||||
|
||||
// If loading all, mark hasMore as false
|
||||
if (all) {
|
||||
setPagination((prev) => ({ ...prev, hasMore: false }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading more tasks:', error);
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[pagination, isLoading]
|
||||
);
|
||||
|
||||
// Calculate today's progress for the progress bar
|
||||
const getTodayProgress = () => {
|
||||
const todayTasks = plannedTasks;
|
||||
|
|
@ -1652,7 +1510,10 @@ const TasksToday: React.FC = () => {
|
|||
{plannedTasks.length > 0 && (
|
||||
<>
|
||||
<TodayPlan
|
||||
todayPlanTasks={plannedTasks}
|
||||
todayPlanTasks={plannedTasks.slice(
|
||||
0,
|
||||
plannedDisplayLimit
|
||||
)}
|
||||
projects={localProjects}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
|
|
@ -1662,59 +1523,31 @@ const TasksToday: React.FC = () => {
|
|||
}
|
||||
/>
|
||||
|
||||
{/* Load More Buttons for Today Plan Tasks */}
|
||||
{pagination.hasMore && (
|
||||
{/* Load More Buttons for Planned Tasks */}
|
||||
{plannedDisplayLimit <
|
||||
plannedTasks.length && (
|
||||
<div className="flex justify-center pt-4 pb-2 gap-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
handleLoadMore(false)
|
||||
setPlannedDisplayLimit(
|
||||
(prev) => prev + 20
|
||||
)
|
||||
}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t(
|
||||
'common.loading',
|
||||
'Loading...'
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<QueueListIcon className="h-4 w-4 mr-2" />
|
||||
{t(
|
||||
'common.loadMore',
|
||||
'Load More'
|
||||
)}
|
||||
</>
|
||||
<QueueListIcon className="h-4 w-4 mr-2" />
|
||||
{t(
|
||||
'common.loadMore',
|
||||
'Load More'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleLoadMore(true)
|
||||
setPlannedDisplayLimit(
|
||||
plannedTasks.length
|
||||
)
|
||||
}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
{t(
|
||||
'common.showAll',
|
||||
|
|
@ -1724,15 +1557,17 @@ const TasksToday: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination info for Today Plan tasks */}
|
||||
{/* Pagination info for Planned tasks */}
|
||||
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2 pb-4">
|
||||
{t(
|
||||
'tasks.showingItems',
|
||||
'Showing {{current}} of {{total}} tasks',
|
||||
{
|
||||
current:
|
||||
plannedTasks.length,
|
||||
total: pagination.total,
|
||||
current: Math.min(
|
||||
plannedDisplayLimit,
|
||||
plannedTasks.length
|
||||
),
|
||||
total: plannedTasks.length,
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
70
frontend/components/Task/statusStyles.ts
Normal file
70
frontend/components/Task/statusStyles.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { StatusType } from '../../entities/Task';
|
||||
|
||||
type StatusKey =
|
||||
| 'not_started'
|
||||
| 'in_progress'
|
||||
| 'done'
|
||||
| 'archived'
|
||||
| 'waiting'
|
||||
| 'cancelled'
|
||||
| 'planned';
|
||||
|
||||
interface StatusStyle {
|
||||
button: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<StatusKey, StatusStyle> = {
|
||||
planned: {
|
||||
button: 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300',
|
||||
border: 'border-purple-200 dark:border-purple-800',
|
||||
},
|
||||
in_progress: {
|
||||
button: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300',
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
},
|
||||
waiting: {
|
||||
button: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300',
|
||||
border: 'border-yellow-200 dark:border-yellow-800',
|
||||
},
|
||||
done: {
|
||||
button: 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300',
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
},
|
||||
cancelled: {
|
||||
button: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300',
|
||||
border: 'border-red-200 dark:border-red-800',
|
||||
},
|
||||
archived: {
|
||||
button: 'bg-gray-50 dark:bg-gray-900/20 text-gray-700 dark:text-gray-300',
|
||||
border: 'border-gray-200 dark:border-gray-800',
|
||||
},
|
||||
not_started: {
|
||||
button: 'bg-gray-50 dark:bg-gray-900/20 text-gray-700 dark:text-gray-300',
|
||||
border: 'border-gray-200 dark:border-gray-700',
|
||||
},
|
||||
};
|
||||
|
||||
const resolveStatusKey = (status?: StatusType | number | null): StatusKey => {
|
||||
if (status === 'planned' || status === 6) return 'planned';
|
||||
if (status === 'in_progress' || status === 1) return 'in_progress';
|
||||
if (status === 'done' || status === 2) return 'done';
|
||||
if (status === 'archived' || status === 3) return 'archived';
|
||||
if (status === 'waiting' || status === 4) return 'waiting';
|
||||
if (status === 'cancelled' || status === 5) return 'cancelled';
|
||||
return 'not_started';
|
||||
};
|
||||
|
||||
export const getStatusButtonColorClasses = (
|
||||
status?: StatusType | number | null
|
||||
) => {
|
||||
const style = STATUS_STYLES[resolveStatusKey(status)];
|
||||
return style.button;
|
||||
};
|
||||
|
||||
export const getStatusBorderColorClasses = (
|
||||
status?: StatusType | number | null
|
||||
) => {
|
||||
const { border } = STATUS_STYLES[resolveStatusKey(status)];
|
||||
return border;
|
||||
};
|
||||
162
frontend/constants/taskStatus.ts
Normal file
162
frontend/constants/taskStatus.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { StatusType } from '../entities/Task';
|
||||
|
||||
export const TASK_STATUS = {
|
||||
NOT_STARTED: 0,
|
||||
IN_PROGRESS: 1,
|
||||
DONE: 2,
|
||||
ARCHIVED: 3,
|
||||
WAITING: 4,
|
||||
CANCELLED: 5,
|
||||
PLANNED: 6,
|
||||
} as const;
|
||||
|
||||
export const TASK_STATUS_STRINGS = {
|
||||
NOT_STARTED: 'not_started',
|
||||
IN_PROGRESS: 'in_progress',
|
||||
DONE: 'done',
|
||||
ARCHIVED: 'archived',
|
||||
WAITING: 'waiting',
|
||||
CANCELLED: 'cancelled',
|
||||
PLANNED: 'planned',
|
||||
} as const;
|
||||
|
||||
export const HABIT_STATUS_CANCELLED = 5;
|
||||
export const HABIT_STATUS_CANCELLED_STRING = 'cancelled';
|
||||
|
||||
export type TaskStatusValue = (typeof TASK_STATUS)[keyof typeof TASK_STATUS];
|
||||
export type TaskStatusString =
|
||||
(typeof TASK_STATUS_STRINGS)[keyof typeof TASK_STATUS_STRINGS];
|
||||
|
||||
export function getStatusString(status: StatusType | number): TaskStatusString {
|
||||
if (typeof status === 'string') {
|
||||
return status as TaskStatusString;
|
||||
}
|
||||
|
||||
const statusNames: TaskStatusString[] = [
|
||||
'not_started',
|
||||
'in_progress',
|
||||
'done',
|
||||
'archived',
|
||||
'waiting',
|
||||
'cancelled',
|
||||
'planned',
|
||||
];
|
||||
|
||||
return statusNames[status] || 'not_started';
|
||||
}
|
||||
|
||||
export function getStatusValue(status: StatusType | number): TaskStatusValue {
|
||||
if (typeof status === 'number') {
|
||||
return status as TaskStatusValue;
|
||||
}
|
||||
|
||||
const statusMap: Record<string, TaskStatusValue> = {
|
||||
not_started: TASK_STATUS.NOT_STARTED,
|
||||
in_progress: TASK_STATUS.IN_PROGRESS,
|
||||
done: TASK_STATUS.DONE,
|
||||
archived: TASK_STATUS.ARCHIVED,
|
||||
waiting: TASK_STATUS.WAITING,
|
||||
cancelled: TASK_STATUS.CANCELLED,
|
||||
planned: TASK_STATUS.PLANNED,
|
||||
};
|
||||
|
||||
return statusMap[status] ?? TASK_STATUS.NOT_STARTED;
|
||||
}
|
||||
|
||||
export function getStatusLabel(status: StatusType | number): string {
|
||||
const statusString = getStatusString(status);
|
||||
|
||||
const labels: Record<TaskStatusString, string> = {
|
||||
not_started: 'Not Started',
|
||||
in_progress: 'In Progress',
|
||||
done: 'Completed',
|
||||
archived: 'Archived',
|
||||
waiting: 'Waiting',
|
||||
cancelled: 'Cancelled',
|
||||
planned: 'Planned',
|
||||
};
|
||||
|
||||
return labels[statusString] || 'Unknown';
|
||||
}
|
||||
|
||||
export function isTaskDone(
|
||||
status: StatusType | number | undefined | null
|
||||
): boolean {
|
||||
if (status === undefined || status === null) return false;
|
||||
return status === TASK_STATUS.DONE || status === 'done';
|
||||
}
|
||||
|
||||
export function isTaskInProgress(
|
||||
status: StatusType | number | undefined | null
|
||||
): boolean {
|
||||
if (status === undefined || status === null) return false;
|
||||
return status === TASK_STATUS.IN_PROGRESS || status === 'in_progress';
|
||||
}
|
||||
|
||||
export function isTaskNotStarted(
|
||||
status: StatusType | number | undefined | null
|
||||
): boolean {
|
||||
if (status === undefined || status === null) return false;
|
||||
return status === TASK_STATUS.NOT_STARTED || status === 'not_started';
|
||||
}
|
||||
|
||||
export function isTaskArchived(
|
||||
status: StatusType | number | undefined | null
|
||||
): boolean {
|
||||
if (status === undefined || status === null) return false;
|
||||
return status === TASK_STATUS.ARCHIVED || status === 'archived';
|
||||
}
|
||||
|
||||
export function isTaskWaiting(
|
||||
status: StatusType | number | undefined | null
|
||||
): boolean {
|
||||
if (status === undefined || status === null) return false;
|
||||
return status === TASK_STATUS.WAITING || status === 'waiting';
|
||||
}
|
||||
|
||||
export function isTaskCancelled(
|
||||
status: StatusType | string | number | undefined | null
|
||||
): boolean {
|
||||
if (status === undefined || status === null) return false;
|
||||
return status === TASK_STATUS.CANCELLED || status === 'cancelled';
|
||||
}
|
||||
|
||||
export function isTaskPlanned(
|
||||
status: StatusType | number | undefined | null
|
||||
): boolean {
|
||||
if (status === undefined || status === null) return false;
|
||||
return status === TASK_STATUS.PLANNED || status === 'planned';
|
||||
}
|
||||
|
||||
export function isTaskActive(
|
||||
status: StatusType | number | undefined | null
|
||||
): boolean {
|
||||
return (
|
||||
!isTaskDone(status) &&
|
||||
!isTaskArchived(status) &&
|
||||
!isTaskCancelled(status)
|
||||
);
|
||||
}
|
||||
|
||||
export function isTaskCompleted(
|
||||
status: StatusType | number | undefined | null
|
||||
): boolean {
|
||||
return isTaskDone(status) || isTaskArchived(status);
|
||||
}
|
||||
|
||||
export function isTaskActionable(
|
||||
status: StatusType | number | undefined | null
|
||||
): boolean {
|
||||
return (
|
||||
!isTaskDone(status) &&
|
||||
!isTaskArchived(status) &&
|
||||
!isTaskCancelled(status) &&
|
||||
!isTaskWaiting(status)
|
||||
);
|
||||
}
|
||||
|
||||
export function isHabitArchived(
|
||||
status: StatusType | number | undefined | null
|
||||
): boolean {
|
||||
return isTaskArchived(status) || isTaskCancelled(status);
|
||||
}
|
||||
|
|
@ -54,7 +54,9 @@ export type StatusType =
|
|||
| 'in_progress'
|
||||
| 'done'
|
||||
| 'archived'
|
||||
| 'waiting';
|
||||
| 'waiting'
|
||||
| 'cancelled'
|
||||
| 'planned';
|
||||
export type PriorityType = 'low' | 'medium' | 'high' | null | undefined;
|
||||
export type RecurrenceType =
|
||||
| 'none'
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getPostHeaders,
|
||||
} from './authUtils';
|
||||
import { getApiPath } from '../config/paths';
|
||||
import { isTaskDone, TASK_STATUS } from '../constants/taskStatus';
|
||||
|
||||
export interface GroupedTasks {
|
||||
[groupName: string]: Task[];
|
||||
|
|
@ -18,6 +19,7 @@ export const fetchTasks = async (
|
|||
metrics: Metrics;
|
||||
groupedTasks?: GroupedTasks;
|
||||
tasks_in_progress?: Task[];
|
||||
tasks_today_plan?: Task[];
|
||||
tasks_due_today?: Task[];
|
||||
tasks_overdue?: Task[];
|
||||
suggested_tasks?: Task[];
|
||||
|
|
@ -64,6 +66,7 @@ export const fetchTasks = async (
|
|||
groupedTasks: tasksResult.groupedTasks,
|
||||
// Dashboard task lists (only present when include_lists=true)
|
||||
tasks_in_progress: tasksResult.tasks_in_progress,
|
||||
tasks_today_plan: tasksResult.tasks_today_plan,
|
||||
tasks_due_today: tasksResult.tasks_due_today,
|
||||
tasks_overdue: tasksResult.tasks_overdue,
|
||||
suggested_tasks: tasksResult.suggested_tasks,
|
||||
|
|
@ -116,8 +119,11 @@ export const toggleTaskCompletion = async (
|
|||
return result.task;
|
||||
}
|
||||
|
||||
const newStatus =
|
||||
task.status === 2 || task.status === 'done' ? (task.note ? 1 : 0) : 2;
|
||||
const newStatus = isTaskDone(task.status)
|
||||
? task.note
|
||||
? TASK_STATUS.IN_PROGRESS
|
||||
: TASK_STATUS.NOT_STARTED
|
||||
: TASK_STATUS.DONE;
|
||||
|
||||
return await updateTask(taskUid, { status: newStatus });
|
||||
};
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "tududi",
|
||||
"version": "v0.88.0-dev.1",
|
||||
"version": "v0.88.2-dev.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tududi",
|
||||
"version": "v0.88.0-dev.1",
|
||||
"version": "v0.88.2-dev.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue