Fix redesign recurring tasks (#582)
* Break down TaskModal * Fix an issue with counting past times * Add daily reccurence on week * fixup! Add daily reccurence on week * fixup! fixup! Add daily reccurence on week * Improve recurring widget on task page * fixup! Improve recurring widget on task page
This commit is contained in:
parent
c3bf5f5522
commit
eb10ea8355
17 changed files with 1534 additions and 940 deletions
26
backend/migrations/20251119000001-add-recurrence-weekdays.js
Normal file
26
backend/migrations/20251119000001-add-recurrence-weekdays.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
'use strict';
|
||||
|
||||
const {
|
||||
safeAddColumns,
|
||||
safeRemoveColumn,
|
||||
} = require('../utils/migration-utils');
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await safeAddColumns(queryInterface, 'tasks', [
|
||||
{
|
||||
name: 'recurrence_weekdays',
|
||||
definition: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
comment:
|
||||
'JSON array of weekday numbers for weekly recurrence (e.g., "[1,3,5]" for Mon, Wed, Fri)',
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await safeRemoveColumn(queryInterface, 'tasks', 'recurrence_weekdays');
|
||||
},
|
||||
};
|
||||
|
|
@ -86,6 +86,20 @@ module.exports = (sequelize) => {
|
|||
max: 6,
|
||||
},
|
||||
},
|
||||
recurrence_weekdays: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('recurrence_weekdays');
|
||||
return rawValue ? JSON.parse(rawValue) : null;
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue(
|
||||
'recurrence_weekdays',
|
||||
value ? JSON.stringify(value) : null
|
||||
);
|
||||
},
|
||||
},
|
||||
recurrence_month_day: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
|
|
|
|||
|
|
@ -66,31 +66,71 @@ async function calculateNextIterations(task, startFromDate, userTimezone) {
|
|||
startDate.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
let nextDate = new Date(startDate);
|
||||
let includesToday = false;
|
||||
|
||||
if (task.recurrence_type === 'daily') {
|
||||
nextDate.setDate(nextDate.getDate() + (task.recurrence_interval || 1));
|
||||
} else if (task.recurrence_type === 'weekly') {
|
||||
const interval = task.recurrence_interval || 1;
|
||||
if (
|
||||
// Check if today matches the recurrence pattern
|
||||
if (task.recurrence_type === 'weekly') {
|
||||
// Check if today matches any of the weekdays
|
||||
if (task.recurrence_weekdays) {
|
||||
// Note: Sequelize getter already parses JSON, so it's already an array
|
||||
const weekdays = Array.isArray(task.recurrence_weekdays)
|
||||
? task.recurrence_weekdays
|
||||
: JSON.parse(task.recurrence_weekdays);
|
||||
const todayWeekday = nextDate.getDay();
|
||||
console.log('Weekly recurrence check:', {
|
||||
weekdays,
|
||||
todayWeekday,
|
||||
includes: weekdays.includes(todayWeekday),
|
||||
});
|
||||
includesToday = weekdays.includes(todayWeekday);
|
||||
} else if (
|
||||
task.recurrence_weekday !== null &&
|
||||
task.recurrence_weekday !== undefined
|
||||
) {
|
||||
const currentWeekday = nextDate.getDay();
|
||||
const daysUntilTarget =
|
||||
(task.recurrence_weekday - currentWeekday + 7) % 7;
|
||||
if (daysUntilTarget === 0) {
|
||||
nextDate.setDate(nextDate.getDate() + interval * 7);
|
||||
} else {
|
||||
nextDate.setDate(nextDate.getDate() + daysUntilTarget);
|
||||
}
|
||||
} else {
|
||||
nextDate.setDate(nextDate.getDate() + interval * 7);
|
||||
const todayWeekday = nextDate.getDay();
|
||||
includesToday = task.recurrence_weekday === todayWeekday;
|
||||
}
|
||||
} else {
|
||||
nextDate = calculateNextDueDate(task, startDate);
|
||||
} else if (task.recurrence_type === 'daily') {
|
||||
// For daily recurrence, today is always included
|
||||
includesToday = true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 5 && nextDate; i++) {
|
||||
console.log('calculateNextIterations:', {
|
||||
startDate: startDate.toISOString(),
|
||||
includesToday,
|
||||
recurrence_type: task.recurrence_type,
|
||||
recurrence_weekdays: task.recurrence_weekdays,
|
||||
});
|
||||
|
||||
// If today doesn't match, calculate the next occurrence
|
||||
if (!includesToday) {
|
||||
if (task.recurrence_type === 'daily') {
|
||||
nextDate.setDate(
|
||||
nextDate.getDate() + (task.recurrence_interval || 1)
|
||||
);
|
||||
} else if (task.recurrence_type === 'weekly') {
|
||||
const interval = task.recurrence_interval || 1;
|
||||
if (
|
||||
task.recurrence_weekday !== null &&
|
||||
task.recurrence_weekday !== undefined
|
||||
) {
|
||||
const currentWeekday = nextDate.getDay();
|
||||
const daysUntilTarget =
|
||||
(task.recurrence_weekday - currentWeekday + 7) % 7;
|
||||
if (daysUntilTarget === 0) {
|
||||
nextDate.setDate(nextDate.getDate() + interval * 7);
|
||||
} else {
|
||||
nextDate.setDate(nextDate.getDate() + daysUntilTarget);
|
||||
}
|
||||
} else {
|
||||
nextDate.setDate(nextDate.getDate() + interval * 7);
|
||||
}
|
||||
} else {
|
||||
nextDate = calculateNextDueDate(task, startDate);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 6 && nextDate; i++) {
|
||||
if (task.recurrence_end_date) {
|
||||
const endDate = new Date(task.recurrence_end_date);
|
||||
if (nextDate > endDate) {
|
||||
|
|
@ -113,9 +153,39 @@ async function calculateNextIterations(task, startFromDate, userTimezone) {
|
|||
);
|
||||
} else if (task.recurrence_type === 'weekly') {
|
||||
nextDate = new Date(nextDate);
|
||||
nextDate.setDate(
|
||||
nextDate.getDate() + (task.recurrence_interval || 1) * 7
|
||||
);
|
||||
|
||||
// Handle multiple weekdays
|
||||
if (task.recurrence_weekdays) {
|
||||
// Sequelize getter already parses JSON, so it's already an array
|
||||
const weekdays = Array.isArray(task.recurrence_weekdays)
|
||||
? task.recurrence_weekdays
|
||||
: JSON.parse(task.recurrence_weekdays);
|
||||
const currentWeekday = nextDate.getDay();
|
||||
|
||||
// Find next matching weekday
|
||||
let found = false;
|
||||
for (let daysAhead = 1; daysAhead <= 7; daysAhead++) {
|
||||
const testDate = new Date(nextDate);
|
||||
testDate.setDate(testDate.getDate() + daysAhead);
|
||||
const testWeekday = testDate.getDay();
|
||||
|
||||
if (weekdays.includes(testWeekday)) {
|
||||
nextDate = testDate;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
// Fallback: add 7 days
|
||||
nextDate.setDate(nextDate.getDate() + 7);
|
||||
}
|
||||
} else {
|
||||
// Old behavior for single weekday
|
||||
nextDate.setDate(
|
||||
nextDate.getDate() + (task.recurrence_interval || 1) * 7
|
||||
);
|
||||
}
|
||||
} else {
|
||||
nextDate = calculateNextDueDate(task, nextDate);
|
||||
}
|
||||
|
|
|
|||
220
frontend/components/Task/RecurrenceDisplay.tsx
Normal file
220
frontend/components/Task/RecurrenceDisplay.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RecurrenceType } from '../../entities/Task';
|
||||
import { getFirstDayOfWeek } from '../../utils/profileService';
|
||||
import { ArrowPathIcon, CalendarIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface RecurrenceDisplayProps {
|
||||
recurrenceType: RecurrenceType;
|
||||
recurrenceInterval?: number;
|
||||
recurrenceWeekdays?: number[];
|
||||
recurrenceEndDate?: string;
|
||||
recurrenceMonthDay?: number;
|
||||
recurrenceWeekOfMonth?: number;
|
||||
recurrenceWeekday?: number;
|
||||
completionBased?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const RecurrenceDisplay: React.FC<RecurrenceDisplayProps> = ({
|
||||
recurrenceType,
|
||||
recurrenceInterval = 1,
|
||||
recurrenceWeekdays,
|
||||
recurrenceEndDate,
|
||||
recurrenceMonthDay,
|
||||
// recurrenceWeekOfMonth and recurrenceWeekday kept for future use
|
||||
completionBased = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [firstDayOfWeek, setFirstDayOfWeek] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFirstDayOfWeek = async () => {
|
||||
try {
|
||||
const day = await getFirstDayOfWeek();
|
||||
setFirstDayOfWeek(day);
|
||||
} catch (error) {
|
||||
console.error('Error loading first day of week:', error);
|
||||
setFirstDayOfWeek(1); // Default to Monday on error
|
||||
}
|
||||
};
|
||||
loadFirstDayOfWeek();
|
||||
}, []);
|
||||
|
||||
const allWeekdays = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: 0,
|
||||
short: t('weekdays.sunday', 'Sun'),
|
||||
full: t('weekdaysFull.sunday', 'Sunday'),
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
short: t('weekdays.monday', 'Mon'),
|
||||
full: t('weekdaysFull.monday', 'Monday'),
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
short: t('weekdays.tuesday', 'Tue'),
|
||||
full: t('weekdaysFull.tuesday', 'Tuesday'),
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
short: t('weekdays.wednesday', 'Wed'),
|
||||
full: t('weekdaysFull.wednesday', 'Wednesday'),
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
short: t('weekdays.thursday', 'Thu'),
|
||||
full: t('weekdaysFull.thursday', 'Thursday'),
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
short: t('weekdays.friday', 'Fri'),
|
||||
full: t('weekdaysFull.friday', 'Friday'),
|
||||
},
|
||||
{
|
||||
value: 6,
|
||||
short: t('weekdays.saturday', 'Sat'),
|
||||
full: t('weekdaysFull.saturday', 'Saturday'),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const orderedWeekdays = useMemo(() => {
|
||||
if (firstDayOfWeek === null) return allWeekdays;
|
||||
return [
|
||||
...allWeekdays.slice(firstDayOfWeek),
|
||||
...allWeekdays.slice(0, firstDayOfWeek),
|
||||
];
|
||||
}, [allWeekdays, firstDayOfWeek]);
|
||||
|
||||
const formatRecurrenceText = () => {
|
||||
switch (recurrenceType) {
|
||||
case 'daily':
|
||||
return recurrenceInterval > 1
|
||||
? t(
|
||||
'recurrence.everyNDays',
|
||||
`Every ${recurrenceInterval} days`,
|
||||
{ count: recurrenceInterval }
|
||||
)
|
||||
: t('recurrence.daily', 'Daily');
|
||||
case 'weekly':
|
||||
return recurrenceInterval > 1
|
||||
? t(
|
||||
'recurrence.everyNWeeks',
|
||||
`Every ${recurrenceInterval} weeks`,
|
||||
{ count: recurrenceInterval }
|
||||
)
|
||||
: t('recurrence.weekly', 'Weekly');
|
||||
case 'monthly':
|
||||
return recurrenceInterval > 1
|
||||
? t(
|
||||
'recurrence.everyNMonths',
|
||||
`Every ${recurrenceInterval} months`,
|
||||
{ count: recurrenceInterval }
|
||||
)
|
||||
: t('recurrence.monthly', 'Monthly');
|
||||
case 'monthly_weekday':
|
||||
return t('recurrence.monthlyWeekday', 'Monthly on weekday');
|
||||
case 'monthly_last_day':
|
||||
return t('recurrence.monthlyLastDay', 'Monthly on last day');
|
||||
default:
|
||||
return t('recurrence.recurring', 'Recurring');
|
||||
}
|
||||
};
|
||||
|
||||
const formatEndDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
if (recurrenceType === 'none' || !recurrenceType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('RecurrenceDisplay rendering:', {
|
||||
recurrenceType,
|
||||
recurrenceWeekdays,
|
||||
recurrenceInterval,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`${compact ? 'space-y-2' : 'space-y-3'}`}>
|
||||
{/* Main recurrence info */}
|
||||
<div className="flex items-center">
|
||||
<ArrowPathIcon className="h-4 w-4 mr-2 text-blue-600 dark:text-blue-400" />
|
||||
<span
|
||||
className={`${compact ? 'text-sm' : 'text-base'} font-medium text-gray-900 dark:text-gray-100`}
|
||||
>
|
||||
{formatRecurrenceText()}
|
||||
</span>
|
||||
{completionBased && (
|
||||
<span className="ml-2 text-xs px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded">
|
||||
{t('recurrence.completionBased', 'After completion')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Weekday display for weekly recurrence */}
|
||||
{recurrenceType === 'weekly' &&
|
||||
recurrenceWeekdays &&
|
||||
recurrenceWeekdays.length > 0 && (
|
||||
<div className="ml-7">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
{t('forms.task.labels.repeatOn', 'Repeat on')}:
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{orderedWeekdays.map((weekday) => {
|
||||
const isSelected = recurrenceWeekdays.includes(
|
||||
weekday.value
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={weekday.value}
|
||||
className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium
|
||||
${
|
||||
isSelected
|
||||
? 'bg-blue-600 text-white dark:bg-blue-500'
|
||||
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600'
|
||||
}
|
||||
`}
|
||||
title={weekday.full}
|
||||
>
|
||||
{weekday.short}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Month day display for monthly recurrence */}
|
||||
{recurrenceType === 'monthly' && recurrenceMonthDay && (
|
||||
<div className="ml-7 text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('recurrence.onDay', 'On day')} {recurrenceMonthDay}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End date display */}
|
||||
{recurrenceEndDate && (
|
||||
<div className="ml-7 flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<CalendarIcon className="h-4 w-4 mr-1" />
|
||||
<span>
|
||||
{t('recurrence.until', 'Until')}{' '}
|
||||
{formatEndDate(recurrenceEndDate)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecurrenceDisplay;
|
||||
|
|
@ -1,526 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { RecurrenceType } from '../../entities/Task';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RecurrenceSelectDropdown from '../Shared/RecurrenceSelectDropdown';
|
||||
import NumberSelectDropdown from '../Shared/NumberSelectDropdown';
|
||||
import ToggleSwitch from '../Shared/ToggleSwitch';
|
||||
import DatePicker from '../Shared/DatePicker';
|
||||
|
||||
interface RecurrenceInputProps {
|
||||
recurrenceType: RecurrenceType;
|
||||
recurrenceInterval: number;
|
||||
recurrenceEndDate?: string;
|
||||
recurrenceWeekday?: number;
|
||||
recurrenceMonthDay?: number;
|
||||
recurrenceWeekOfMonth?: number;
|
||||
completionBased: boolean;
|
||||
onChange: (field: string, value: any) => void;
|
||||
disabled?: boolean;
|
||||
isChildTask?: boolean;
|
||||
parentTaskLoading?: boolean;
|
||||
onEditParent?: () => void;
|
||||
onParentRecurrenceChange?: (field: string, value: any) => void;
|
||||
}
|
||||
|
||||
const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
||||
recurrenceType,
|
||||
recurrenceInterval,
|
||||
recurrenceEndDate,
|
||||
recurrenceWeekday,
|
||||
recurrenceMonthDay,
|
||||
recurrenceWeekOfMonth,
|
||||
completionBased,
|
||||
onChange,
|
||||
disabled = false,
|
||||
isChildTask = false,
|
||||
parentTaskLoading = false,
|
||||
onEditParent, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
onParentRecurrenceChange,
|
||||
}) => {
|
||||
// Helper function to convert ISO date string to YYYY-MM-DD format for DatePicker
|
||||
const formatDateForPicker = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
return date.toISOString().split('T')[0]; // Returns YYYY-MM-DD
|
||||
} catch {
|
||||
return dateString; // Return as-is if it's already in the correct format
|
||||
}
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
const [editingParentRecurrence, setEditingParentRecurrence] =
|
||||
useState(false);
|
||||
|
||||
const weekdays = [
|
||||
{ value: 0, label: t('weekdays.sunday', 'Sunday') },
|
||||
{ value: 1, label: t('weekdays.monday', 'Monday') },
|
||||
{ value: 2, label: t('weekdays.tuesday', 'Tuesday') },
|
||||
{ value: 3, label: t('weekdays.wednesday', 'Wednesday') },
|
||||
{ value: 4, label: t('weekdays.thursday', 'Thursday') },
|
||||
{ value: 5, label: t('weekdays.friday', 'Friday') },
|
||||
{ value: 6, label: t('weekdays.saturday', 'Saturday') },
|
||||
];
|
||||
|
||||
const weekOfMonthOptions = [
|
||||
{ value: 1, label: t('recurrence.firstWeek', 'First') },
|
||||
{ value: 2, label: t('recurrence.secondWeek', 'Second') },
|
||||
{ value: 3, label: t('recurrence.thirdWeek', 'Third') },
|
||||
{ value: 4, label: t('recurrence.fourthWeek', 'Fourth') },
|
||||
{ value: 5, label: t('recurrence.lastWeek', 'Last') },
|
||||
];
|
||||
|
||||
const recurrenceTypeOptions = [
|
||||
{ value: 'none', label: t('recurrence.none', 'No repeat') },
|
||||
{ value: 'daily', label: t('recurrence.daily', 'Daily') },
|
||||
{ value: 'weekly', label: t('recurrence.weekly', 'Weekly') },
|
||||
{ value: 'monthly', label: t('recurrence.monthly', 'Monthly') },
|
||||
{
|
||||
value: 'monthly_weekday',
|
||||
label: t('recurrence.monthlyWeekday', 'Monthly on weekday'),
|
||||
},
|
||||
{
|
||||
value: 'monthly_last_day',
|
||||
label: t('recurrence.monthlyLastDay', 'Monthly on last day'),
|
||||
},
|
||||
];
|
||||
|
||||
const renderRecurrenceTypeSelect = (
|
||||
customOnChange?: (field: string, value: any) => void,
|
||||
isDisabled?: boolean
|
||||
) => (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.recurrenceType', 'Repeat')}
|
||||
</label>
|
||||
<RecurrenceSelectDropdown
|
||||
value={recurrenceType}
|
||||
onChange={(value) =>
|
||||
(customOnChange || onChange)(
|
||||
'recurrence_type',
|
||||
value as RecurrenceType
|
||||
)
|
||||
}
|
||||
options={recurrenceTypeOptions}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderIntervalInput = (
|
||||
customOnChange?: (field: string, value: any) => void,
|
||||
isDisabled?: boolean
|
||||
) => {
|
||||
// Determine max value based on recurrence type
|
||||
const getMaxValue = () => {
|
||||
if (recurrenceType === 'daily') return 30;
|
||||
if (recurrenceType === 'weekly') return 52; // Max 52 weeks (1 year)
|
||||
if (
|
||||
recurrenceType === 'monthly' ||
|
||||
recurrenceType === 'monthly_weekday' ||
|
||||
recurrenceType === 'monthly_last_day'
|
||||
)
|
||||
return 24; // Max 24 months (2 years)
|
||||
return 99;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.recurrenceInterval', 'Every')}
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-20">
|
||||
<NumberSelectDropdown
|
||||
value={recurrenceInterval || 1}
|
||||
onChange={(value) =>
|
||||
(customOnChange || onChange)(
|
||||
'recurrence_interval',
|
||||
value
|
||||
)
|
||||
}
|
||||
min={1}
|
||||
max={getMaxValue()}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{recurrenceType === 'daily' &&
|
||||
t('recurrence.days', 'days')}
|
||||
{recurrenceType === 'weekly' &&
|
||||
t('recurrence.weeks', 'weeks')}
|
||||
{(recurrenceType === 'monthly' ||
|
||||
recurrenceType === 'monthly_weekday' ||
|
||||
recurrenceType === 'monthly_last_day') &&
|
||||
t('recurrence.months', 'months')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWeekdaySelect = (
|
||||
customOnChange?: (field: string, value: any) => void,
|
||||
isDisabled?: boolean
|
||||
) => {
|
||||
const weekdayOptions = [
|
||||
{ value: '', label: t('recurrence.anyDay', 'Any day') },
|
||||
...weekdays,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.weekday', 'On day')}
|
||||
</label>
|
||||
<RecurrenceSelectDropdown
|
||||
value={
|
||||
recurrenceWeekday !== undefined ? recurrenceWeekday : ''
|
||||
}
|
||||
onChange={(value) =>
|
||||
(customOnChange || onChange)(
|
||||
'recurrence_weekday',
|
||||
value !== '' ? parseInt(value as string) : null
|
||||
)
|
||||
}
|
||||
options={weekdayOptions}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMonthDayInput = (
|
||||
customOnChange?: (field: string, value: any) => void,
|
||||
isDisabled?: boolean
|
||||
) => (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.monthDay', 'Day of month')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
value={recurrenceMonthDay || ''}
|
||||
onChange={(e) =>
|
||||
(customOnChange || onChange)(
|
||||
'recurrence_month_day',
|
||||
e.target.value ? parseInt(e.target.value) : null
|
||||
)
|
||||
}
|
||||
placeholder={t(
|
||||
'recurrence.monthDayPlaceholder',
|
||||
'Leave empty for current day'
|
||||
)}
|
||||
className="block w-full border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm px-2 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMonthlyWeekdayInputs = () => (
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.weekOfMonth', 'Week of month')}
|
||||
</label>
|
||||
<RecurrenceSelectDropdown
|
||||
value={recurrenceWeekOfMonth || 1}
|
||||
onChange={(value) =>
|
||||
onChange(
|
||||
'recurrence_week_of_month',
|
||||
parseInt(value as string)
|
||||
)
|
||||
}
|
||||
options={weekOfMonthOptions}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.weekday', 'Weekday')}
|
||||
</label>
|
||||
<RecurrenceSelectDropdown
|
||||
value={recurrenceWeekday || 1}
|
||||
onChange={(value) =>
|
||||
onChange(
|
||||
'recurrence_weekday',
|
||||
parseInt(value as string)
|
||||
)
|
||||
}
|
||||
options={weekdays}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderEndDateInput = (
|
||||
customOnChange?: (field: string, value: any) => void,
|
||||
isDisabled?: boolean
|
||||
) => (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t(
|
||||
'forms.task.labels.recurrenceEndDate',
|
||||
'End date (optional)'
|
||||
)}
|
||||
</label>
|
||||
<DatePicker
|
||||
value={formatDateForPicker(recurrenceEndDate)}
|
||||
onChange={(value) =>
|
||||
(customOnChange || onChange)(
|
||||
'recurrence_end_date',
|
||||
value || null
|
||||
)
|
||||
}
|
||||
placeholder={t(
|
||||
'forms.task.endDatePlaceholder',
|
||||
'Select end date'
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCompletionBasedToggle = () => (
|
||||
<div className="mb-4">
|
||||
<ToggleSwitch
|
||||
checked={completionBased}
|
||||
onChange={(checked) => onChange('completion_based', checked)}
|
||||
label={t(
|
||||
'forms.task.labels.completionBased',
|
||||
'Repeat after completion'
|
||||
)}
|
||||
description={t(
|
||||
'forms.task.completionBasedHelp',
|
||||
'If checked, the next task will be created based on completion date instead of due date'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Show message for child tasks
|
||||
if (isChildTask && parentTaskLoading) {
|
||||
return (
|
||||
<div className="pb-3 border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Loading parent task recurrence settings...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isChildTask) {
|
||||
return (
|
||||
<div className="pb-3">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
|
||||
</h3>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-md p-3 mb-4">
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>Recurring Task Instance</strong>
|
||||
<p className="mt-1">
|
||||
This task was generated from a recurring task. The
|
||||
recurrence settings shown below are inherited from
|
||||
the original task and cannot be edited here.
|
||||
</p>
|
||||
{onParentRecurrenceChange && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setEditingParentRecurrence(
|
||||
!editingParentRecurrence
|
||||
)
|
||||
}
|
||||
className={`mt-2 inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
|
||||
editingParentRecurrence
|
||||
? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/50 hover:bg-red-100 dark:hover:bg-red-800/50'
|
||||
: 'border-blue-300 dark:border-blue-600 text-blue-700 dark:text-blue-300 bg-white dark:bg-blue-900/50 hover:bg-blue-50 dark:hover:bg-blue-800/50'
|
||||
}`}
|
||||
>
|
||||
{editingParentRecurrence
|
||||
? 'Cancel Edit'
|
||||
: 'Edit Parent Recurrence'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
editingParentRecurrence
|
||||
? ''
|
||||
: 'opacity-60 pointer-events-none'
|
||||
}
|
||||
>
|
||||
{editingParentRecurrence && (
|
||||
<div className="mb-4 p-2 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 rounded-md">
|
||||
<div className="text-xs text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ You are editing the parent task's
|
||||
recurrence settings. Changes will affect all
|
||||
future instances of this recurring task.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{recurrenceType === 'none' ? (
|
||||
renderRecurrenceTypeSelect(
|
||||
editingParentRecurrence
|
||||
? onParentRecurrenceChange
|
||||
: undefined,
|
||||
!editingParentRecurrence
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{renderRecurrenceTypeSelect(
|
||||
editingParentRecurrence
|
||||
? onParentRecurrenceChange
|
||||
: undefined,
|
||||
!editingParentRecurrence
|
||||
)}
|
||||
{renderIntervalInput(
|
||||
editingParentRecurrence
|
||||
? onParentRecurrenceChange
|
||||
: undefined,
|
||||
!editingParentRecurrence
|
||||
)}
|
||||
{(recurrenceType === 'weekly' ||
|
||||
recurrenceType === 'monthly_weekday') &&
|
||||
renderWeekdaySelect(
|
||||
editingParentRecurrence
|
||||
? onParentRecurrenceChange
|
||||
: undefined,
|
||||
!editingParentRecurrence
|
||||
)}
|
||||
{recurrenceType === 'monthly' &&
|
||||
renderMonthDayInput(
|
||||
editingParentRecurrence
|
||||
? onParentRecurrenceChange
|
||||
: undefined,
|
||||
!editingParentRecurrence
|
||||
)}
|
||||
{recurrenceType === 'monthly_weekday' &&
|
||||
renderMonthlyWeekdayInputs()}
|
||||
{renderEndDateInput(
|
||||
editingParentRecurrence
|
||||
? onParentRecurrenceChange
|
||||
: undefined,
|
||||
!editingParentRecurrence
|
||||
)}
|
||||
{renderCompletionBasedToggle()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (recurrenceType === 'none') {
|
||||
return <div className="pb-3">{renderRecurrenceTypeSelect()}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-3">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
|
||||
</h3>
|
||||
|
||||
{/* Main recurrence settings in one row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.recurrenceType', 'Repeat')}
|
||||
</label>
|
||||
<RecurrenceSelectDropdown
|
||||
value={recurrenceType}
|
||||
onChange={(value) =>
|
||||
onChange('recurrence_type', value as RecurrenceType)
|
||||
}
|
||||
options={recurrenceTypeOptions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(recurrenceType === 'daily' ||
|
||||
recurrenceType === 'weekly' ||
|
||||
recurrenceType === 'monthly' ||
|
||||
recurrenceType === 'monthly_weekday' ||
|
||||
recurrenceType === 'monthly_last_day') && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.recurrenceInterval', 'Every')}
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-20">
|
||||
<NumberSelectDropdown
|
||||
value={recurrenceInterval || 1}
|
||||
onChange={(value) =>
|
||||
onChange('recurrence_interval', value)
|
||||
}
|
||||
min={1}
|
||||
max={
|
||||
recurrenceType === 'daily'
|
||||
? 30
|
||||
: recurrenceType === 'weekly'
|
||||
? 52
|
||||
: recurrenceType === 'monthly' ||
|
||||
recurrenceType ===
|
||||
'monthly_weekday' ||
|
||||
recurrenceType ===
|
||||
'monthly_last_day'
|
||||
? 24
|
||||
: 99
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{recurrenceType === 'daily' &&
|
||||
t('recurrence.days', 'days')}
|
||||
{recurrenceType === 'weekly' &&
|
||||
t('recurrence.weeks', 'weeks')}
|
||||
{(recurrenceType === 'monthly' ||
|
||||
recurrenceType === 'monthly_weekday' ||
|
||||
recurrenceType === 'monthly_last_day') &&
|
||||
t('recurrence.months', 'months')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t(
|
||||
'forms.task.labels.recurrenceEndDate',
|
||||
'End date (optional)'
|
||||
)}
|
||||
</label>
|
||||
<DatePicker
|
||||
value={formatDateForPicker(recurrenceEndDate)}
|
||||
onChange={(value) =>
|
||||
onChange('recurrence_end_date', value || null)
|
||||
}
|
||||
placeholder={t(
|
||||
'forms.task.endDatePlaceholder',
|
||||
'Select end date'
|
||||
)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional settings for specific recurrence types */}
|
||||
{recurrenceType === 'weekly' && renderWeekdaySelect()}
|
||||
|
||||
{recurrenceType === 'monthly' && renderMonthDayInput()}
|
||||
|
||||
{recurrenceType === 'monthly_weekday' &&
|
||||
renderMonthlyWeekdayInputs()}
|
||||
|
||||
{renderCompletionBasedToggle()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecurrenceInput;
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '@heroicons/react/24/outline';
|
||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||
import TaskModal from './TaskModal';
|
||||
import RecurrenceDisplay from './RecurrenceDisplay';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
import {
|
||||
|
|
@ -136,6 +137,25 @@ const TaskDetails: React.FC = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const formatDateWithDayName = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const isToday = dateString === today;
|
||||
|
||||
const dayName = date.toLocaleDateString(undefined, { weekday: 'long' });
|
||||
const formattedDate = date.toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
|
||||
return {
|
||||
dayName,
|
||||
formattedDate,
|
||||
fullText: `${dayName}, ${formattedDate}`,
|
||||
isToday,
|
||||
};
|
||||
};
|
||||
|
||||
const formatRecurrence = (recurrenceType: string) => {
|
||||
switch (recurrenceType) {
|
||||
case 'daily':
|
||||
|
|
@ -615,7 +635,8 @@ const TaskDetails: React.FC = () => {
|
|||
task.today_move_count > 1
|
||||
? t(
|
||||
'task.overdueMultipleDays',
|
||||
`This task has been postponed ${task.today_move_count} times.`
|
||||
`This task has been postponed {{count}} times.`,
|
||||
{ count: task.today_move_count }
|
||||
)
|
||||
: t(
|
||||
'task.overdueYesterday',
|
||||
|
|
@ -864,71 +885,56 @@ const TaskDetails: React.FC = () => {
|
|||
parentTask.recurrence_type !==
|
||||
'none') ? (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center mb-2">
|
||||
<ArrowPathIcon className="h-4 w-4 mr-2 text-gray-500 dark:text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{formatRecurrence(
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_type
|
||||
? parentTask.recurrence_type
|
||||
: task.recurrence_type
|
||||
)}
|
||||
</span>
|
||||
{((task.recurring_parent_id &&
|
||||
parentTask?.recurrence_interval &&
|
||||
parentTask.recurrence_interval >
|
||||
1) ||
|
||||
(!task.recurring_parent_id &&
|
||||
task.recurrence_interval &&
|
||||
task.recurrence_interval >
|
||||
1)) && (
|
||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
(
|
||||
{t(
|
||||
'recurrence.every',
|
||||
'Every'
|
||||
)}{' '}
|
||||
{task.recurring_parent_id &&
|
||||
parentTask?.recurrence_interval
|
||||
? parentTask.recurrence_interval
|
||||
: task.recurrence_interval}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{((task.recurring_parent_id &&
|
||||
parentTask?.recurrence_end_date) ||
|
||||
(!task.recurring_parent_id &&
|
||||
task.recurrence_end_date)) && (
|
||||
<div className="flex items-center text-sm">
|
||||
<CalendarIcon className="h-4 w-4 mr-2 text-gray-500 dark:text-gray-400" />
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'recurrence.endsOn',
|
||||
'Ends on'
|
||||
)}{' '}
|
||||
{formatDueDate(
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_end_date
|
||||
? parentTask.recurrence_end_date
|
||||
: task.recurrence_end_date!
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{((task.recurring_parent_id &&
|
||||
parentTask?.completion_based) ||
|
||||
(!task.recurring_parent_id &&
|
||||
task.completion_based)) && (
|
||||
<div className="flex items-center mt-2">
|
||||
<span className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">
|
||||
{t(
|
||||
'recurrence.completionBased',
|
||||
'Completion-based'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<RecurrenceDisplay
|
||||
recurrenceType={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_type
|
||||
? parentTask.recurrence_type
|
||||
: task.recurrence_type
|
||||
}
|
||||
recurrenceInterval={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_interval
|
||||
? parentTask.recurrence_interval
|
||||
: task.recurrence_interval
|
||||
}
|
||||
recurrenceWeekdays={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_weekdays
|
||||
? parentTask.recurrence_weekdays
|
||||
: task.recurrence_weekdays
|
||||
}
|
||||
recurrenceEndDate={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_end_date
|
||||
? parentTask.recurrence_end_date
|
||||
: task.recurrence_end_date
|
||||
}
|
||||
recurrenceMonthDay={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_month_day
|
||||
? parentTask.recurrence_month_day
|
||||
: task.recurrence_month_day
|
||||
}
|
||||
recurrenceWeekOfMonth={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_week_of_month
|
||||
? parentTask.recurrence_week_of_month
|
||||
: task.recurrence_week_of_month
|
||||
}
|
||||
recurrenceWeekday={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_weekday
|
||||
? parentTask.recurrence_weekday
|
||||
: task.recurrence_weekday
|
||||
}
|
||||
completionBased={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.completion_based
|
||||
? parentTask.completion_based
|
||||
: task.completion_based
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -946,12 +952,30 @@ const TaskDetails: React.FC = () => {
|
|||
{task.recurring_parent_id
|
||||
? t(
|
||||
'task.nextOccurrencesAfterThis',
|
||||
'Next 5 Occurrences After This'
|
||||
'Next Occurrences After This'
|
||||
)
|
||||
: t(
|
||||
'task.nextOccurrences',
|
||||
'Next 5 Occurrences'
|
||||
'Next Occurrences'
|
||||
)}
|
||||
{!loadingIterations &&
|
||||
nextIterations.length >
|
||||
0 &&
|
||||
nextIterations.some(
|
||||
(iter) =>
|
||||
formatDateWithDayName(
|
||||
iter.date
|
||||
).isToday
|
||||
) && (
|
||||
<span className="ml-2 text-xs text-blue-600 dark:text-blue-400">
|
||||
(
|
||||
{t(
|
||||
'task.includingToday',
|
||||
'including today'
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -972,24 +996,75 @@ const TaskDetails: React.FC = () => {
|
|||
(
|
||||
iteration,
|
||||
index
|
||||
) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center py-1 px-2 rounded bg-gray-50 dark:bg-gray-800"
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3">
|
||||
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
{index +
|
||||
1}
|
||||
</span>
|
||||
) => {
|
||||
const dateInfo =
|
||||
formatDateWithDayName(
|
||||
iteration.date
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={
|
||||
index
|
||||
}
|
||||
className={`flex items-center py-2 px-3 rounded transition-colors ${
|
||||
dateInfo.isToday
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-200 dark:border-blue-800'
|
||||
: 'bg-gray-50 dark:bg-gray-800 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center mr-3 ${
|
||||
dateInfo.isToday
|
||||
? 'bg-blue-600 dark:bg-blue-500'
|
||||
: 'bg-blue-100 dark:bg-blue-900'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
dateInfo.isToday
|
||||
? 'text-white'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{index +
|
||||
1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
dateInfo.isToday
|
||||
? 'text-blue-900 dark:text-blue-100'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{
|
||||
dateInfo.dayName
|
||||
}
|
||||
{dateInfo.isToday && (
|
||||
<span className="ml-2 text-xs px-2 py-0.5 bg-blue-600 dark:bg-blue-500 text-white rounded-full font-semibold">
|
||||
{t(
|
||||
'dateIndicators.today',
|
||||
'TODAY'
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${
|
||||
dateInfo.isToday
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{
|
||||
dateInfo.formattedDate
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{formatDueDate(
|
||||
iteration.date
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
26
frontend/components/Task/TaskForm/TaskDueDateSection.tsx
Normal file
26
frontend/components/Task/TaskForm/TaskDueDateSection.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import DatePicker from '../../Shared/DatePicker';
|
||||
|
||||
interface TaskDueDateSectionProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const TaskDueDateSection: React.FC<TaskDueDateSectionProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select due date',
|
||||
}) => {
|
||||
return (
|
||||
<div className="overflow-visible">
|
||||
<DatePicker
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDueDateSection;
|
||||
88
frontend/components/Task/TaskForm/TaskModalActions.tsx
Normal file
88
frontend/components/Task/TaskForm/TaskModalActions.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import React from 'react';
|
||||
import { TrashIcon } from '@heroicons/react/24/outline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TaskModalActionsProps {
|
||||
taskId?: number;
|
||||
isSaving: boolean;
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const TaskModalActions: React.FC<TaskModalActionsProps> = ({
|
||||
taskId,
|
||||
isSaving,
|
||||
onDelete,
|
||||
onCancel,
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between sm:rounded-bl-lg">
|
||||
{/* Left side: Delete and Cancel */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{taskId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 focus:outline-none transition duration-150 ease-in-out text-sm"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right side: Save */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className={`px-4 py-2 rounded-md focus:outline-none transition duration-150 ease-in-out text-sm ${
|
||||
isSaving
|
||||
? 'bg-blue-400 text-white cursor-not-allowed dark:bg-blue-400'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||
}`}
|
||||
data-testid="task-save-button"
|
||||
>
|
||||
{isSaving ? (
|
||||
<span className="flex items-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
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.saving', 'Saving...')}
|
||||
</span>
|
||||
) : (
|
||||
t('common.save', 'Save')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskModalActions;
|
||||
17
frontend/components/Task/TaskForm/TaskPrioritySection.tsx
Normal file
17
frontend/components/Task/TaskForm/TaskPrioritySection.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import { PriorityType } from '../../../entities/Task';
|
||||
import PriorityDropdown from '../../Shared/PriorityDropdown';
|
||||
|
||||
interface TaskPrioritySectionProps {
|
||||
value: PriorityType;
|
||||
onChange: (value: PriorityType) => void;
|
||||
}
|
||||
|
||||
const TaskPrioritySection: React.FC<TaskPrioritySectionProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
return <PriorityDropdown value={value} onChange={onChange} />;
|
||||
};
|
||||
|
||||
export default TaskPrioritySection;
|
||||
|
|
@ -1,70 +1,517 @@
|
|||
import React from 'react';
|
||||
import { Task } from '../../../entities/Task';
|
||||
import RecurrenceInput from '../RecurrenceInput';
|
||||
import React, { useState } from 'react';
|
||||
import { RecurrenceType } from '../../../entities/Task';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RecurrenceSelectDropdown from '../../Shared/RecurrenceSelectDropdown';
|
||||
import NumberSelectDropdown from '../../Shared/NumberSelectDropdown';
|
||||
import ToggleSwitch from '../../Shared/ToggleSwitch';
|
||||
import DatePicker from '../../Shared/DatePicker';
|
||||
import WeekdaySelector from './WeekdaySelector';
|
||||
|
||||
interface TaskRecurrenceSectionProps {
|
||||
formData: Task;
|
||||
parentTask: Task | null;
|
||||
parentTaskLoading: boolean;
|
||||
onRecurrenceChange: (field: string, value: any) => void;
|
||||
recurrenceType: RecurrenceType;
|
||||
recurrenceInterval: number;
|
||||
recurrenceEndDate?: string;
|
||||
recurrenceWeekday?: number;
|
||||
recurrenceWeekdays?: number[];
|
||||
recurrenceMonthDay?: number;
|
||||
recurrenceWeekOfMonth?: number;
|
||||
completionBased: boolean;
|
||||
onChange: (field: string, value: any) => void;
|
||||
disabled?: boolean;
|
||||
isChildTask?: boolean;
|
||||
parentTaskLoading?: boolean;
|
||||
onEditParent?: () => void;
|
||||
onParentRecurrenceChange?: (field: string, value: any) => void;
|
||||
}
|
||||
|
||||
const TaskRecurrenceSection: React.FC<TaskRecurrenceSectionProps> = ({
|
||||
formData,
|
||||
parentTask,
|
||||
parentTaskLoading,
|
||||
onRecurrenceChange,
|
||||
onEditParent,
|
||||
recurrenceType,
|
||||
recurrenceInterval,
|
||||
recurrenceEndDate,
|
||||
recurrenceWeekday,
|
||||
recurrenceWeekdays,
|
||||
recurrenceMonthDay,
|
||||
recurrenceWeekOfMonth,
|
||||
completionBased,
|
||||
onChange,
|
||||
disabled = false,
|
||||
isChildTask = false,
|
||||
parentTaskLoading = false,
|
||||
onEditParent, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
onParentRecurrenceChange,
|
||||
}) => {
|
||||
// Helper function to convert ISO date string to YYYY-MM-DD format for DatePicker
|
||||
const formatDateForPicker = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
return date.toISOString().split('T')[0]; // Returns YYYY-MM-DD
|
||||
} catch {
|
||||
return dateString; // Return as-is if it's already in the correct format
|
||||
}
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
const [editingParentRecurrence, setEditingParentRecurrence] =
|
||||
useState(false);
|
||||
|
||||
const weekdays = [
|
||||
{ value: 0, label: t('weekdays.sunday', 'Sunday') },
|
||||
{ value: 1, label: t('weekdays.monday', 'Monday') },
|
||||
{ value: 2, label: t('weekdays.tuesday', 'Tuesday') },
|
||||
{ value: 3, label: t('weekdays.wednesday', 'Wednesday') },
|
||||
{ value: 4, label: t('weekdays.thursday', 'Thursday') },
|
||||
{ value: 5, label: t('weekdays.friday', 'Friday') },
|
||||
{ value: 6, label: t('weekdays.saturday', 'Saturday') },
|
||||
];
|
||||
|
||||
const weekOfMonthOptions = [
|
||||
{ value: 1, label: t('recurrence.firstWeek', 'First') },
|
||||
{ value: 2, label: t('recurrence.secondWeek', 'Second') },
|
||||
{ value: 3, label: t('recurrence.thirdWeek', 'Third') },
|
||||
{ value: 4, label: t('recurrence.fourthWeek', 'Fourth') },
|
||||
{ value: 5, label: t('recurrence.lastWeek', 'Last') },
|
||||
];
|
||||
|
||||
const recurrenceTypeOptions = [
|
||||
{ value: 'none', label: t('recurrence.none', 'No repeat') },
|
||||
{ value: 'daily', label: t('recurrence.daily', 'Daily') },
|
||||
{ value: 'weekly', label: t('recurrence.weekly', 'Weekly') },
|
||||
{ value: 'monthly', label: t('recurrence.monthly', 'Monthly') },
|
||||
{
|
||||
value: 'monthly_weekday',
|
||||
label: t('recurrence.monthlyWeekday', 'Monthly on weekday'),
|
||||
},
|
||||
{
|
||||
value: 'monthly_last_day',
|
||||
label: t('recurrence.monthlyLastDay', 'Monthly on last day'),
|
||||
},
|
||||
];
|
||||
|
||||
const renderRecurrenceTypeSelect = (
|
||||
customOnChange?: (field: string, value: any) => void,
|
||||
isDisabled?: boolean
|
||||
) => (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.recurrenceType', 'Repeat')}
|
||||
</label>
|
||||
<RecurrenceSelectDropdown
|
||||
value={recurrenceType}
|
||||
onChange={(value) =>
|
||||
(customOnChange || onChange)(
|
||||
'recurrence_type',
|
||||
value as RecurrenceType
|
||||
)
|
||||
}
|
||||
options={recurrenceTypeOptions}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderIntervalInput = (
|
||||
customOnChange?: (field: string, value: any) => void,
|
||||
isDisabled?: boolean
|
||||
) => {
|
||||
// Determine max value based on recurrence type
|
||||
const getMaxValue = () => {
|
||||
if (recurrenceType === 'daily') return 30;
|
||||
if (recurrenceType === 'weekly') return 52; // Max 52 weeks (1 year)
|
||||
if (
|
||||
recurrenceType === 'monthly' ||
|
||||
recurrenceType === 'monthly_weekday' ||
|
||||
recurrenceType === 'monthly_last_day'
|
||||
)
|
||||
return 24; // Max 24 months (2 years)
|
||||
return 99;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.recurrenceInterval', 'Every')}
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-20">
|
||||
<NumberSelectDropdown
|
||||
value={recurrenceInterval || 1}
|
||||
onChange={(value) =>
|
||||
(customOnChange || onChange)(
|
||||
'recurrence_interval',
|
||||
value
|
||||
)
|
||||
}
|
||||
min={1}
|
||||
max={getMaxValue()}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{recurrenceType === 'daily' &&
|
||||
t('recurrence.days', 'days')}
|
||||
{recurrenceType === 'weekly' &&
|
||||
t('recurrence.weeks', 'weeks')}
|
||||
{(recurrenceType === 'monthly' ||
|
||||
recurrenceType === 'monthly_weekday' ||
|
||||
recurrenceType === 'monthly_last_day') &&
|
||||
t('recurrence.months', 'months')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWeekdaySelect = (
|
||||
customOnChange?: (field: string, value: any) => void,
|
||||
isDisabled?: boolean
|
||||
) => {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<WeekdaySelector
|
||||
selectedDays={recurrenceWeekdays || []}
|
||||
onChange={(days) =>
|
||||
(customOnChange || onChange)(
|
||||
'recurrence_weekdays',
|
||||
days
|
||||
)
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMonthDayInput = (
|
||||
customOnChange?: (field: string, value: any) => void,
|
||||
isDisabled?: boolean
|
||||
) => (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.monthDay', 'Day of month')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
value={recurrenceMonthDay || ''}
|
||||
onChange={(e) =>
|
||||
(customOnChange || onChange)(
|
||||
'recurrence_month_day',
|
||||
e.target.value ? parseInt(e.target.value) : null
|
||||
)
|
||||
}
|
||||
placeholder={t(
|
||||
'recurrence.monthDayPlaceholder',
|
||||
'Leave empty for current day'
|
||||
)}
|
||||
className="block w-full border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm px-2 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMonthlyWeekdayInputs = () => (
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.weekOfMonth', 'Week of month')}
|
||||
</label>
|
||||
<RecurrenceSelectDropdown
|
||||
value={recurrenceWeekOfMonth || 1}
|
||||
onChange={(value) =>
|
||||
onChange(
|
||||
'recurrence_week_of_month',
|
||||
parseInt(value as string)
|
||||
)
|
||||
}
|
||||
options={weekOfMonthOptions}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.weekday', 'Weekday')}
|
||||
</label>
|
||||
<RecurrenceSelectDropdown
|
||||
value={recurrenceWeekday || 1}
|
||||
onChange={(value) =>
|
||||
onChange(
|
||||
'recurrence_weekday',
|
||||
parseInt(value as string)
|
||||
)
|
||||
}
|
||||
options={weekdays}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderEndDateInput = (
|
||||
customOnChange?: (field: string, value: any) => void,
|
||||
isDisabled?: boolean
|
||||
) => (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t(
|
||||
'forms.task.labels.recurrenceEndDate',
|
||||
'End date (optional)'
|
||||
)}
|
||||
</label>
|
||||
<DatePicker
|
||||
value={formatDateForPicker(recurrenceEndDate)}
|
||||
onChange={(value) =>
|
||||
(customOnChange || onChange)(
|
||||
'recurrence_end_date',
|
||||
value || null
|
||||
)
|
||||
}
|
||||
placeholder={t(
|
||||
'forms.task.endDatePlaceholder',
|
||||
'Select end date'
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCompletionBasedToggle = () => (
|
||||
<div className="mb-4">
|
||||
<ToggleSwitch
|
||||
checked={completionBased}
|
||||
onChange={(checked) => onChange('completion_based', checked)}
|
||||
label={t(
|
||||
'forms.task.labels.completionBased',
|
||||
'Repeat after completion'
|
||||
)}
|
||||
description={t(
|
||||
'forms.task.completionBasedHelp',
|
||||
'If checked, the next task will be created based on completion date instead of due date'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Show message for child tasks
|
||||
if (isChildTask && parentTaskLoading) {
|
||||
return (
|
||||
<div className="pb-3 border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Loading parent task recurrence settings...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isChildTask) {
|
||||
return (
|
||||
<div className="pb-3">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
|
||||
</h3>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-md p-3 mb-4">
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>Recurring Task Instance</strong>
|
||||
<p className="mt-1">
|
||||
This task was generated from a recurring task. The
|
||||
recurrence settings shown below are inherited from
|
||||
the original task and cannot be edited here.
|
||||
</p>
|
||||
{onParentRecurrenceChange && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setEditingParentRecurrence(
|
||||
!editingParentRecurrence
|
||||
)
|
||||
}
|
||||
className={`mt-2 inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
|
||||
editingParentRecurrence
|
||||
? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/50 hover:bg-red-100 dark:hover:bg-red-800/50'
|
||||
: 'border-blue-300 dark:border-blue-600 text-blue-700 dark:text-blue-300 bg-white dark:bg-blue-900/50 hover:bg-blue-50 dark:hover:bg-blue-800/50'
|
||||
}`}
|
||||
>
|
||||
{editingParentRecurrence
|
||||
? 'Cancel Edit'
|
||||
: 'Edit Parent Recurrence'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
editingParentRecurrence
|
||||
? ''
|
||||
: 'opacity-60 pointer-events-none'
|
||||
}
|
||||
>
|
||||
{editingParentRecurrence && (
|
||||
<div className="mb-4 p-2 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 rounded-md">
|
||||
<div className="text-xs text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ You are editing the parent task's
|
||||
recurrence settings. Changes will affect all
|
||||
future instances of this recurring task.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{recurrenceType === 'none' ? (
|
||||
renderRecurrenceTypeSelect(
|
||||
editingParentRecurrence
|
||||
? onParentRecurrenceChange
|
||||
: undefined,
|
||||
!editingParentRecurrence
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{renderRecurrenceTypeSelect(
|
||||
editingParentRecurrence
|
||||
? onParentRecurrenceChange
|
||||
: undefined,
|
||||
!editingParentRecurrence
|
||||
)}
|
||||
{renderIntervalInput(
|
||||
editingParentRecurrence
|
||||
? onParentRecurrenceChange
|
||||
: undefined,
|
||||
!editingParentRecurrence
|
||||
)}
|
||||
{(recurrenceType === 'weekly' ||
|
||||
recurrenceType === 'monthly_weekday') &&
|
||||
renderWeekdaySelect(
|
||||
editingParentRecurrence
|
||||
? onParentRecurrenceChange
|
||||
: undefined,
|
||||
!editingParentRecurrence
|
||||
)}
|
||||
{recurrenceType === 'monthly' &&
|
||||
renderMonthDayInput(
|
||||
editingParentRecurrence
|
||||
? onParentRecurrenceChange
|
||||
: undefined,
|
||||
!editingParentRecurrence
|
||||
)}
|
||||
{recurrenceType === 'monthly_weekday' &&
|
||||
renderMonthlyWeekdayInputs()}
|
||||
{renderEndDateInput(
|
||||
editingParentRecurrence
|
||||
? onParentRecurrenceChange
|
||||
: undefined,
|
||||
!editingParentRecurrence
|
||||
)}
|
||||
{renderCompletionBasedToggle()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (recurrenceType === 'none') {
|
||||
return <div className="pb-3">{renderRecurrenceTypeSelect()}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<RecurrenceInput
|
||||
recurrenceType={
|
||||
parentTask
|
||||
? parentTask.recurrence_type || 'none'
|
||||
: formData.recurrence_type || 'none'
|
||||
}
|
||||
recurrenceInterval={
|
||||
parentTask
|
||||
? parentTask.recurrence_interval || 1
|
||||
: formData.recurrence_interval || 1
|
||||
}
|
||||
recurrenceEndDate={
|
||||
parentTask
|
||||
? parentTask.recurrence_end_date
|
||||
: formData.recurrence_end_date
|
||||
}
|
||||
recurrenceWeekday={
|
||||
parentTask
|
||||
? parentTask.recurrence_weekday
|
||||
: formData.recurrence_weekday
|
||||
}
|
||||
recurrenceMonthDay={
|
||||
parentTask
|
||||
? parentTask.recurrence_month_day
|
||||
: formData.recurrence_month_day
|
||||
}
|
||||
recurrenceWeekOfMonth={
|
||||
parentTask
|
||||
? parentTask.recurrence_week_of_month
|
||||
: formData.recurrence_week_of_month
|
||||
}
|
||||
completionBased={
|
||||
parentTask
|
||||
? parentTask.completion_based || false
|
||||
: formData.completion_based || false
|
||||
}
|
||||
onChange={onRecurrenceChange}
|
||||
disabled={!!parentTask}
|
||||
isChildTask={!!parentTask}
|
||||
parentTaskLoading={parentTaskLoading}
|
||||
onEditParent={parentTask ? onEditParent : undefined}
|
||||
onParentRecurrenceChange={
|
||||
parentTask ? onParentRecurrenceChange : undefined
|
||||
}
|
||||
/>
|
||||
<div className="pb-3">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
|
||||
</h3>
|
||||
|
||||
{/* Main recurrence settings in one row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.recurrenceType', 'Repeat')}
|
||||
</label>
|
||||
<RecurrenceSelectDropdown
|
||||
value={recurrenceType}
|
||||
onChange={(value) =>
|
||||
onChange('recurrence_type', value as RecurrenceType)
|
||||
}
|
||||
options={recurrenceTypeOptions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(recurrenceType === 'daily' ||
|
||||
recurrenceType === 'weekly' ||
|
||||
recurrenceType === 'monthly' ||
|
||||
recurrenceType === 'monthly_weekday' ||
|
||||
recurrenceType === 'monthly_last_day') && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.recurrenceInterval', 'Every')}
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-20">
|
||||
<NumberSelectDropdown
|
||||
value={recurrenceInterval || 1}
|
||||
onChange={(value) =>
|
||||
onChange('recurrence_interval', value)
|
||||
}
|
||||
min={1}
|
||||
max={
|
||||
recurrenceType === 'daily'
|
||||
? 30
|
||||
: recurrenceType === 'weekly'
|
||||
? 52
|
||||
: recurrenceType === 'monthly' ||
|
||||
recurrenceType ===
|
||||
'monthly_weekday' ||
|
||||
recurrenceType ===
|
||||
'monthly_last_day'
|
||||
? 24
|
||||
: 99
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{recurrenceType === 'daily' &&
|
||||
t('recurrence.days', 'days')}
|
||||
{recurrenceType === 'weekly' &&
|
||||
t('recurrence.weeks', 'weeks')}
|
||||
{(recurrenceType === 'monthly' ||
|
||||
recurrenceType === 'monthly_weekday' ||
|
||||
recurrenceType === 'monthly_last_day') &&
|
||||
t('recurrence.months', 'months')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t(
|
||||
'forms.task.labels.recurrenceEndDate',
|
||||
'End date (optional)'
|
||||
)}
|
||||
</label>
|
||||
<DatePicker
|
||||
value={formatDateForPicker(recurrenceEndDate)}
|
||||
onChange={(value) =>
|
||||
onChange('recurrence_end_date', value || null)
|
||||
}
|
||||
placeholder={t(
|
||||
'forms.task.endDatePlaceholder',
|
||||
'Select end date'
|
||||
)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional settings for specific recurrence types */}
|
||||
{recurrenceType === 'weekly' && renderWeekdaySelect()}
|
||||
|
||||
{recurrenceType === 'monthly' && renderMonthDayInput()}
|
||||
|
||||
{recurrenceType === 'monthly_weekday' &&
|
||||
renderMonthlyWeekdayInputs()}
|
||||
|
||||
{renderCompletionBasedToggle()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
107
frontend/components/Task/TaskForm/TaskSectionToggle.tsx
Normal file
107
frontend/components/Task/TaskForm/TaskSectionToggle.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
TagIcon,
|
||||
FolderIcon,
|
||||
ArrowPathIcon,
|
||||
ListBulletIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CalendarIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Task } from '../../../entities/Task';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TaskSectionToggleProps {
|
||||
expandedSections: {
|
||||
tags: boolean;
|
||||
project: boolean;
|
||||
priority: boolean;
|
||||
dueDate: boolean;
|
||||
recurrence: boolean;
|
||||
subtasks: boolean;
|
||||
};
|
||||
onToggleSection: (
|
||||
section: keyof TaskSectionToggleProps['expandedSections']
|
||||
) => void;
|
||||
formData: Task;
|
||||
subtasksCount: number;
|
||||
}
|
||||
|
||||
const TaskSectionToggle: React.FC<TaskSectionToggleProps> = ({
|
||||
expandedSections,
|
||||
onToggleSection,
|
||||
formData,
|
||||
subtasksCount,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const toggleButtons = [
|
||||
{
|
||||
key: 'tags' as const,
|
||||
icon: TagIcon,
|
||||
title: t('forms.task.labels.tags', 'Tags'),
|
||||
hasValue: formData.tags && formData.tags.length > 0,
|
||||
},
|
||||
{
|
||||
key: 'project' as const,
|
||||
icon: FolderIcon,
|
||||
title: t('forms.task.labels.project', 'Project'),
|
||||
hasValue: !!formData.project_id,
|
||||
},
|
||||
{
|
||||
key: 'priority' as const,
|
||||
icon: ExclamationTriangleIcon,
|
||||
title: t('forms.task.labels.priority', 'Priority'),
|
||||
hasValue: formData.priority != null,
|
||||
},
|
||||
{
|
||||
key: 'dueDate' as const,
|
||||
icon: CalendarIcon,
|
||||
title: t('forms.task.labels.dueDate', 'Due Date'),
|
||||
hasValue: !!formData.due_date,
|
||||
},
|
||||
{
|
||||
key: 'recurrence' as const,
|
||||
icon: ArrowPathIcon,
|
||||
title: t('forms.task.recurrence', 'Recurrence'),
|
||||
hasValue:
|
||||
(formData.recurrence_type &&
|
||||
formData.recurrence_type !== 'none') ||
|
||||
!!formData.recurring_parent_uid,
|
||||
},
|
||||
{
|
||||
key: 'subtasks' as const,
|
||||
icon: ListBulletIcon,
|
||||
title: t('forms.task.subtasks', 'Subtasks'),
|
||||
hasValue: subtasksCount > 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 bg-white dark:bg-gray-800 px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-1">
|
||||
{toggleButtons.map(
|
||||
({ key, icon: Icon, title, hasValue }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onToggleSection(key)}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections[key]
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={title}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{hasValue && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskSectionToggle;
|
||||
144
frontend/components/Task/TaskForm/WeekdaySelector.tsx
Normal file
144
frontend/components/Task/TaskForm/WeekdaySelector.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getFirstDayOfWeek } from '../../../utils/profileService';
|
||||
|
||||
interface WeekdaySelectorProps {
|
||||
selectedDays: number[];
|
||||
onChange: (days: number[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const WeekdaySelector: React.FC<WeekdaySelectorProps> = ({
|
||||
selectedDays = [],
|
||||
onChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [firstDayOfWeek, setFirstDayOfWeek] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFirstDayOfWeek = async () => {
|
||||
try {
|
||||
const day = await getFirstDayOfWeek();
|
||||
console.log('Loaded first day of week from profile:', day);
|
||||
setFirstDayOfWeek(day);
|
||||
} catch (error) {
|
||||
console.error('Error loading first day of week:', error);
|
||||
setFirstDayOfWeek(1); // Default to Monday on error
|
||||
}
|
||||
};
|
||||
loadFirstDayOfWeek();
|
||||
}, []);
|
||||
|
||||
// All weekdays with their short names - use useMemo to recalculate when translations change
|
||||
const allWeekdays = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: 0,
|
||||
short: t('weekdays.sunday', 'Sun'),
|
||||
full: t('weekdaysFull.sunday', 'Sunday'),
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
short: t('weekdays.monday', 'Mon'),
|
||||
full: t('weekdaysFull.monday', 'Monday'),
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
short: t('weekdays.tuesday', 'Tue'),
|
||||
full: t('weekdaysFull.tuesday', 'Tuesday'),
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
short: t('weekdays.wednesday', 'Wed'),
|
||||
full: t('weekdaysFull.wednesday', 'Wednesday'),
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
short: t('weekdays.thursday', 'Thu'),
|
||||
full: t('weekdaysFull.thursday', 'Thursday'),
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
short: t('weekdays.friday', 'Fri'),
|
||||
full: t('weekdaysFull.friday', 'Friday'),
|
||||
},
|
||||
{
|
||||
value: 6,
|
||||
short: t('weekdays.saturday', 'Sat'),
|
||||
full: t('weekdaysFull.saturday', 'Saturday'),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
// Reorder weekdays based on first day of week - use useMemo to recalculate when firstDayOfWeek changes
|
||||
const orderedWeekdays = useMemo(() => {
|
||||
if (firstDayOfWeek === null) return allWeekdays; // Return default order while loading
|
||||
console.log('Reordering weekdays with firstDayOfWeek:', firstDayOfWeek);
|
||||
const ordered = [
|
||||
...allWeekdays.slice(firstDayOfWeek),
|
||||
...allWeekdays.slice(0, firstDayOfWeek),
|
||||
];
|
||||
console.log(
|
||||
'Ordered weekdays:',
|
||||
ordered.map((w) => w.short).join(', ')
|
||||
);
|
||||
return ordered;
|
||||
}, [allWeekdays, firstDayOfWeek]);
|
||||
|
||||
const toggleDay = (day: number) => {
|
||||
if (disabled) return;
|
||||
|
||||
const newSelectedDays = selectedDays.includes(day)
|
||||
? selectedDays.filter((d) => d !== day)
|
||||
: [...selectedDays, day].sort((a, b) => a - b);
|
||||
|
||||
onChange(newSelectedDays);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('forms.task.labels.repeatOn', 'Repeat on')}
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{orderedWeekdays.map((weekday) => {
|
||||
const isSelected = selectedDays.includes(weekday.value);
|
||||
return (
|
||||
<button
|
||||
key={weekday.value}
|
||||
type="button"
|
||||
onClick={() => toggleDay(weekday.value)}
|
||||
disabled={disabled}
|
||||
title={weekday.full}
|
||||
className={`
|
||||
w-10 h-10 rounded-full font-medium text-sm
|
||||
transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
||||
${
|
||||
isSelected
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
{weekday.short}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedDays.length === 0 && !disabled && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{t(
|
||||
'forms.task.selectAtLeastOneDay',
|
||||
'Please select at least one day'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeekdaySelector;
|
||||
|
|
@ -13,16 +13,6 @@ import {
|
|||
} from '../../utils/taskIntelligenceService';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getTaskIntelligenceEnabled } from '../../utils/profileService';
|
||||
import {
|
||||
TagIcon,
|
||||
FolderIcon,
|
||||
ArrowPathIcon,
|
||||
TrashIcon,
|
||||
ListBulletIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CalendarIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
// Import form sections
|
||||
import TaskTitleSection from './TaskForm/TaskTitleSection';
|
||||
import TaskContentSection from './TaskForm/TaskContentSection';
|
||||
|
|
@ -30,8 +20,10 @@ import TaskTagsSection from './TaskForm/TaskTagsSection';
|
|||
import TaskProjectSection from './TaskForm/TaskProjectSection';
|
||||
import TaskRecurrenceSection from './TaskForm/TaskRecurrenceSection';
|
||||
import TaskSubtasksSection from './TaskForm/TaskSubtasksSection';
|
||||
import PriorityDropdown from '../Shared/PriorityDropdown';
|
||||
import DatePicker from '../Shared/DatePicker';
|
||||
import TaskPrioritySection from './TaskForm/TaskPrioritySection';
|
||||
import TaskDueDateSection from './TaskForm/TaskDueDateSection';
|
||||
import TaskSectionToggle from './TaskForm/TaskSectionToggle';
|
||||
import TaskModalActions from './TaskForm/TaskModalActions';
|
||||
|
||||
interface TaskModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -704,7 +696,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
'Priority'
|
||||
)}
|
||||
</h3>
|
||||
<PriorityDropdown
|
||||
<TaskPrioritySection
|
||||
value={getPriorityString(
|
||||
formData.priority
|
||||
)}
|
||||
|
|
@ -733,32 +725,29 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
'Due Date'
|
||||
)}
|
||||
</h3>
|
||||
<div className="overflow-visible">
|
||||
<DatePicker
|
||||
value={
|
||||
formData.due_date ||
|
||||
''
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) => {
|
||||
const event =
|
||||
{
|
||||
target: {
|
||||
name: 'due_date',
|
||||
value,
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
handleChange(
|
||||
event
|
||||
);
|
||||
}}
|
||||
placeholder={t(
|
||||
'forms.task.dueDatePlaceholder',
|
||||
'Select due date'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<TaskDueDateSection
|
||||
value={
|
||||
formData.due_date ||
|
||||
''
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) => {
|
||||
const event = {
|
||||
target: {
|
||||
name: 'due_date',
|
||||
value,
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
handleChange(
|
||||
event
|
||||
);
|
||||
}}
|
||||
placeholder={t(
|
||||
'forms.task.dueDatePlaceholder',
|
||||
'Select due date'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -771,16 +760,77 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
)}
|
||||
</h3>
|
||||
<TaskRecurrenceSection
|
||||
formData={formData}
|
||||
parentTask={
|
||||
parentTask
|
||||
recurrenceType={
|
||||
(
|
||||
parentTask ||
|
||||
formData
|
||||
)
|
||||
.recurrence_type ||
|
||||
'none'
|
||||
}
|
||||
recurrenceInterval={
|
||||
(
|
||||
parentTask ||
|
||||
formData
|
||||
)
|
||||
.recurrence_interval ||
|
||||
1
|
||||
}
|
||||
recurrenceEndDate={
|
||||
(
|
||||
parentTask ||
|
||||
formData
|
||||
)
|
||||
.recurrence_end_date
|
||||
}
|
||||
recurrenceWeekday={
|
||||
(
|
||||
parentTask ||
|
||||
formData
|
||||
)
|
||||
.recurrence_weekday
|
||||
}
|
||||
recurrenceWeekdays={
|
||||
(
|
||||
parentTask ||
|
||||
formData
|
||||
)
|
||||
.recurrence_weekdays
|
||||
}
|
||||
recurrenceMonthDay={
|
||||
(
|
||||
parentTask ||
|
||||
formData
|
||||
)
|
||||
.recurrence_month_day
|
||||
}
|
||||
recurrenceWeekOfMonth={
|
||||
(
|
||||
parentTask ||
|
||||
formData
|
||||
)
|
||||
.recurrence_week_of_month
|
||||
}
|
||||
completionBased={
|
||||
(
|
||||
parentTask ||
|
||||
formData
|
||||
)
|
||||
.completion_based ||
|
||||
false
|
||||
}
|
||||
onChange={
|
||||
handleRecurrenceChange
|
||||
}
|
||||
disabled={
|
||||
!!parentTask
|
||||
}
|
||||
isChildTask={
|
||||
!!parentTask
|
||||
}
|
||||
parentTaskLoading={
|
||||
parentTaskLoading
|
||||
}
|
||||
onRecurrenceChange={
|
||||
handleRecurrenceChange
|
||||
}
|
||||
onEditParent={
|
||||
parentTask
|
||||
? handleEditParent
|
||||
|
|
@ -840,214 +890,21 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Section Icons - Above border, split layout */}
|
||||
<div className="flex-shrink-0 bg-white dark:bg-gray-800 px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side: Section icons */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* Tags Toggle */}
|
||||
<button
|
||||
onClick={() =>
|
||||
toggleSection('tags')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.tags
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={t(
|
||||
'forms.task.labels.tags',
|
||||
'Tags'
|
||||
)}
|
||||
>
|
||||
<TagIcon className="h-5 w-5" />
|
||||
{formData.tags &&
|
||||
formData.tags.length >
|
||||
0 && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Project Toggle */}
|
||||
<button
|
||||
onClick={() =>
|
||||
toggleSection('project')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.project
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={t(
|
||||
'forms.task.labels.project',
|
||||
'Project'
|
||||
)}
|
||||
>
|
||||
<FolderIcon className="h-5 w-5" />
|
||||
{formData.project_id && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Priority Toggle */}
|
||||
<button
|
||||
onClick={() =>
|
||||
toggleSection('priority')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.priority
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={t(
|
||||
'forms.task.labels.priority',
|
||||
'Priority'
|
||||
)}
|
||||
>
|
||||
<ExclamationTriangleIcon className="h-5 w-5" />
|
||||
{formData.priority != null && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Due Date Toggle */}
|
||||
<button
|
||||
onClick={() =>
|
||||
toggleSection('dueDate')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.dueDate
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={t(
|
||||
'forms.task.labels.dueDate',
|
||||
'Due Date'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="h-5 w-5" />
|
||||
{formData.due_date && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Recurrence Toggle */}
|
||||
<button
|
||||
onClick={() =>
|
||||
toggleSection('recurrence')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.recurrence
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={t(
|
||||
'forms.task.recurrence',
|
||||
'Recurrence'
|
||||
)}
|
||||
>
|
||||
<ArrowPathIcon className="h-5 w-5" />
|
||||
{((formData.recurrence_type &&
|
||||
formData.recurrence_type !==
|
||||
'none') ||
|
||||
formData.recurring_parent_uid) && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Subtasks Toggle */}
|
||||
<button
|
||||
onClick={() =>
|
||||
toggleSection('subtasks')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.subtasks
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={t(
|
||||
'forms.task.subtasks',
|
||||
'Subtasks'
|
||||
)}
|
||||
>
|
||||
<ListBulletIcon className="h-5 w-5" />
|
||||
{subtasks.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TaskSectionToggle
|
||||
expandedSections={expandedSections}
|
||||
onToggleSection={toggleSection}
|
||||
formData={formData}
|
||||
subtasksCount={subtasks.length}
|
||||
/>
|
||||
|
||||
{/* Action Buttons - Below border with custom layout */}
|
||||
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between sm:rounded-bl-lg">
|
||||
{/* Left side: Delete and Cancel */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{task.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteClick}
|
||||
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
|
||||
title={t(
|
||||
'common.delete',
|
||||
'Delete'
|
||||
)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 focus:outline-none transition duration-150 ease-in-out text-sm"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right side: Save */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSaving}
|
||||
className={`px-4 py-2 rounded-md focus:outline-none transition duration-150 ease-in-out text-sm ${
|
||||
isSaving
|
||||
? 'bg-blue-400 text-white cursor-not-allowed dark:bg-blue-400'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||
}`}
|
||||
data-testid="task-save-button"
|
||||
>
|
||||
{isSaving ? (
|
||||
<span className="flex items-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
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.saving',
|
||||
'Saving...'
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
t('common.save', 'Save')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<TaskModalActions
|
||||
taskId={task.id}
|
||||
isSaving={isSaving}
|
||||
onDelete={handleDeleteClick}
|
||||
onCancel={handleClose}
|
||||
onSave={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -115,6 +115,31 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskUid, refreshKey }) => {
|
|||
}
|
||||
return t('timeline.events.recurrenceEndDateChanged');
|
||||
}
|
||||
case 'recurrence_type_changed': {
|
||||
const oldType = old_value?.recurrence_type;
|
||||
const newType = new_value?.recurrence_type;
|
||||
if (oldType !== undefined && newType !== undefined) {
|
||||
const formatRecurrenceType = (type: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
none: t('recurrence.none', 'None'),
|
||||
daily: t('recurrence.daily', 'Daily'),
|
||||
weekly: t('recurrence.weekly', 'Weekly'),
|
||||
monthly: t('recurrence.monthly', 'Monthly'),
|
||||
monthly_weekday: t(
|
||||
'recurrence.monthlyWeekday',
|
||||
'Monthly (weekday)'
|
||||
),
|
||||
monthly_last_day: t(
|
||||
'recurrence.monthlyLastDay',
|
||||
'Monthly (last day)'
|
||||
),
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
return `${t('timeline.events.recurrenceType')}: ${formatRecurrenceType(oldType)} → ${formatRecurrenceType(newType)}`;
|
||||
}
|
||||
return t('timeline.events.recurrenceTypeChanged');
|
||||
}
|
||||
case 'name_changed':
|
||||
return t('timeline.events.nameUpdated');
|
||||
case 'description_changed':
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface Task {
|
|||
recurrence_interval?: number;
|
||||
recurrence_end_date?: string;
|
||||
recurrence_weekday?: number;
|
||||
recurrence_weekdays?: number[]; // Array of weekday numbers for weekly recurrence
|
||||
recurrence_month_day?: number;
|
||||
recurrence_week_of_month?: number;
|
||||
completion_based?: boolean;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export interface TaskEvent {
|
|||
| 'priority_changed'
|
||||
| 'due_date_changed'
|
||||
| 'recurrence_end_date_changed'
|
||||
| 'recurrence_type_changed'
|
||||
| 'project_changed'
|
||||
| 'name_changed'
|
||||
| 'description_changed'
|
||||
|
|
|
|||
|
|
@ -156,6 +156,8 @@
|
|||
"priority": "Priority",
|
||||
"dueDate": "Due date",
|
||||
"recurrenceEndDate": "Recurrence end date",
|
||||
"recurrenceType": "Recurrence type",
|
||||
"recurrenceTypeChanged": "Recurrence type changed",
|
||||
"none": "None"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue