feat: Enhance task filtering, smart scheduling, audit logs and translations
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -45,6 +45,7 @@ interface TaskCardProps {
|
||||
onDelete?: () => void;
|
||||
onStatusChange?: (status: Task['status']) => void;
|
||||
onUpdate?: (updates: Partial<Task>) => void;
|
||||
onAutoSchedule?: () => void;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
@@ -119,7 +120,7 @@ function SharedMenuItem({ task, onShare }: { task: Task, onShare: () => void })
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, onUpdate, isDragging }: TaskCardProps) {
|
||||
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, onUpdate, onAutoSchedule, isDragging }: TaskCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
@@ -332,7 +333,7 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
<TooltipContent>
|
||||
{isBlocked ? (
|
||||
<p className="text-destructive font-medium">
|
||||
Blocked by: {blockingTasks.map(t => t?.title).join(", ")}
|
||||
{t('taskCard.blockedBy', { tasks: blockingTasks.map(t => t?.title).join(", ") })}
|
||||
</p>
|
||||
) : (
|
||||
<p>{t('taskCard.toggleComplete')}</p>
|
||||
@@ -490,6 +491,20 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
{t('taskCard.editTask')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{onAutoSchedule && !task.dueDate && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAutoSchedule();
|
||||
}}
|
||||
className="text-indigo-600 dark:text-indigo-400"
|
||||
data-testid={`menu-schedule-${task.id}`}
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
{t('taskDetails.autoSchedule', 'Auto-Schedule')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -219,26 +219,32 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="col-span-1">
|
||||
<Select
|
||||
value={estimatedDuration ? estimatedDuration.toString() : "0"}
|
||||
onValueChange={(val) => setEstimatedDuration(val === "0" ? undefined : parseInt(val))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('taskCreation.duration')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">{t('taskCreation.durationNone')}</SelectItem>
|
||||
<SelectItem value="15">15m</SelectItem>
|
||||
<SelectItem value="30">30m</SelectItem>
|
||||
<SelectItem value="45">45m</SelectItem>
|
||||
<SelectItem value="60">1h</SelectItem>
|
||||
<SelectItem value="90">1.5h</SelectItem>
|
||||
<SelectItem value="120">2h</SelectItem>
|
||||
<SelectItem value="240">4h</SelectItem>
|
||||
<SelectItem value="480">8h (1 Day)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="col-span-2 sm:col-span-1 space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('taskCreation.duration')} ({t('planning.minutes')})</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="5"
|
||||
placeholder={t('planning.durationPlaceholder')}
|
||||
value={estimatedDuration || ''}
|
||||
onChange={(e) => setEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
className="w-full"
|
||||
data-testid="input-duration"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[15, 30, 45, 60, 90, 120].map((mins) => (
|
||||
<Badge
|
||||
key={mins}
|
||||
variant={estimatedDuration === mins ? "default" : "outline"}
|
||||
className="cursor-pointer text-[10px] px-1.5 py-0.5"
|
||||
onClick={() => setEstimatedDuration(mins)}
|
||||
>
|
||||
{mins}m
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
|
||||
@@ -326,15 +332,15 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
setRecurrenceInterval(v === 'none' ? undefined : v);
|
||||
setIsRecurring(v !== 'none');
|
||||
}}>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectTrigger className="h-8" data-testid="recurrence-trigger">
|
||||
<SelectValue placeholder={t('taskCreation.recurrence.none')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('taskCreation.recurrence.none')}</SelectItem>
|
||||
<SelectItem value="daily">{t('taskCreation.recurrence.daily')}</SelectItem>
|
||||
<SelectItem value="weekly">{t('taskCreation.recurrence.weekly')}</SelectItem>
|
||||
<SelectItem value="monthly">{t('taskCreation.recurrence.monthly')}</SelectItem>
|
||||
<SelectItem value="yearly">{t('taskCreation.recurrence.yearly')}</SelectItem>
|
||||
<SelectItem value="none" data-testid="recurrence-option-none">{t('taskCreation.recurrence.none')}</SelectItem>
|
||||
<SelectItem value="daily" data-testid="recurrence-option-daily">{t('taskCreation.recurrence.daily')}</SelectItem>
|
||||
<SelectItem value="weekly" data-testid="recurrence-option-weekly">{t('taskCreation.recurrence.weekly')}</SelectItem>
|
||||
<SelectItem value="monthly" data-testid="recurrence-option-monthly">{t('taskCreation.recurrence.monthly')}</SelectItem>
|
||||
<SelectItem value="yearly" data-testid="recurrence-option-yearly">{t('taskCreation.recurrence.yearly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
@@ -611,26 +611,30 @@ export default function TaskDetailsModal({
|
||||
|
||||
{/* Estimated Duration */}
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">{t('taskDetails.estimatedDuration')}</label>
|
||||
<Select
|
||||
value={editedEstimatedDuration ? editedEstimatedDuration.toString() : "0"}
|
||||
onValueChange={(val) => setEditedEstimatedDuration(val === "0" ? undefined : parseInt(val))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('taskCreation.duration')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">{t('taskCreation.durationNone')}</SelectItem>
|
||||
<SelectItem value="15">15m</SelectItem>
|
||||
<SelectItem value="30">30m</SelectItem>
|
||||
<SelectItem value="45">45m</SelectItem>
|
||||
<SelectItem value="60">1h</SelectItem>
|
||||
<SelectItem value="90">1.5h</SelectItem>
|
||||
<SelectItem value="120">2h</SelectItem>
|
||||
<SelectItem value="240">4h</SelectItem>
|
||||
<SelectItem value="480">8h (1 Day)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="text-sm font-medium block mb-2">{t('taskDetails.estimatedDuration')} ({t('planning.minutes')})</label>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="5"
|
||||
placeholder={t('planning.durationPlaceholder')}
|
||||
value={editedEstimatedDuration || ''}
|
||||
onChange={(e) => setEditedEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
data-testid="input-edit-duration"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[15, 30, 45, 60, 90, 120].map((mins) => (
|
||||
<Badge
|
||||
key={mins}
|
||||
variant={editedEstimatedDuration === mins ? "default" : "outline"}
|
||||
className="cursor-pointer text-xs"
|
||||
onClick={() => setEditedEstimatedDuration(mins)}
|
||||
>
|
||||
{mins}m
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
|
||||
@@ -35,7 +35,7 @@ interface TasksWithCalendarProps {
|
||||
}
|
||||
|
||||
type SortOption = 'dueDate' | 'priority' | 'title' | 'status';
|
||||
type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue';
|
||||
type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue' | 'planned' | 'unplanned';
|
||||
|
||||
export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onTaskDelete, onStartTimer, onStopTimer }: TasksWithCalendarProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -100,6 +100,10 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
switch (filterBy) {
|
||||
case 'overdue':
|
||||
return isOverdue(task);
|
||||
case 'planned':
|
||||
return !!task.dueDate || !!task.startDate;
|
||||
case 'unplanned':
|
||||
return !task.dueDate && !task.startDate;
|
||||
case 'all':
|
||||
return true;
|
||||
default:
|
||||
@@ -163,6 +167,10 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
switch (filter) {
|
||||
case 'overdue':
|
||||
return tasks.filter(isOverdue).length;
|
||||
case 'planned':
|
||||
return tasks.filter(t => !!t.dueDate || !!t.startDate).length;
|
||||
case 'unplanned':
|
||||
return tasks.filter(t => !t.dueDate && !t.startDate).length;
|
||||
case 'all':
|
||||
return tasks.length;
|
||||
default:
|
||||
@@ -283,6 +291,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
<SelectItem value="inProgress">{t('taskList.filter.inProgress')} ({getFilterCount('inProgress')})</SelectItem>
|
||||
<SelectItem value="done">{t('taskList.filter.done')} ({getFilterCount('done')})</SelectItem>
|
||||
<SelectItem value="overdue">{t('taskList.filter.overdue')} ({getFilterCount('overdue')})</SelectItem>
|
||||
<SelectItem value="planned">{t('taskList.filter.planned')} ({getFilterCount('planned')})</SelectItem>
|
||||
<SelectItem value="unplanned">{t('taskList.filter.unplanned')} ({getFilterCount('unplanned')})</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -321,7 +331,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2 mt-2">
|
||||
Task List
|
||||
{t('taskList.listHeader')}
|
||||
</div>
|
||||
{unscheduledTasks.map((task) => (
|
||||
<div
|
||||
@@ -592,7 +602,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
<div className="p-3 border-b bg-muted/30">
|
||||
<h4 className="font-semibold text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span>{t('calendar.moreItems', { count: dayTasks.length - 2 })}</span>
|
||||
<span className="text-[10px] font-normal opacity-75">Click to edit</span>
|
||||
<span className="text-[10px] font-normal opacity-75">{t('calendar.clickToEdit')}</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto p-2 space-y-1">
|
||||
@@ -637,7 +647,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
? 'border-primary/50 text-primary/70'
|
||||
: 'border-muted-foreground/30 text-muted-foreground'
|
||||
}`}>
|
||||
{isHovered && draggedTask ? '✓ Drop here' : draggedTask ? 'Drop' : ''}
|
||||
{isHovered && draggedTask ? t('calendar.dropHere') : draggedTask ? t('calendar.drop') : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -60,16 +60,18 @@ export function AuditLogsTable() {
|
||||
{logs?.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{format(new Date(log.createdAt), "MMM d, HH:mm:ss")}
|
||||
{new Date(log.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={log.source === 'AI' ? 'secondary' : 'outline'}>
|
||||
{log.source}
|
||||
{t(`audit.source.${log.source}`, { defaultValue: log.source })}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{log.action}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{t(`audit.action.${log.action}`, { defaultValue: log.action })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.entityType}
|
||||
{t(`audit.entity.${log.entityType}`, { defaultValue: log.entityType })}
|
||||
{log.entityId && <span className="text-xs text-muted-foreground block truncate max-w-[100px]">{log.entityId}</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground w-1/3">
|
||||
|
||||
@@ -28,7 +28,7 @@ export function DataExportCard({ user }: DataExportCardProps) {
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
const response = await apiRequest("POST", "/api/user/export", {
|
||||
const response = await apiRequest("POST", "/api/user/data-export", {
|
||||
includeTasks,
|
||||
includeLabels,
|
||||
includeSettings
|
||||
@@ -106,6 +106,7 @@ export function DataExportCard({ user }: DataExportCardProps) {
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting || (!includeTasks && !includeLabels && !includeSettings)}
|
||||
data-testid="button-export-data"
|
||||
>
|
||||
{isExporting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||
{t('settings.export.button')}
|
||||
|
||||
Reference in New Issue
Block a user