feat: enhance audit logging, add MCP settings, and production docker setup
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
- Implemented comprehensive audit logging for Tasks, Users, Settings, Goals, Labels, AI Chat, and Rewards. - Added Admin UI for MCP Server settings and Audit Logs. - Created docker-compose-production.yml with Traefik configuration. - Fixed backend bugs (missing storage methods, route closure). - Added Audit Logging Guidelines.
This commit is contained in:
@@ -57,7 +57,7 @@ export function AiChat() {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-24 right-8 z-50 rounded-full h-14 w-14 shadow-xl bg-gradient-to-r from-pink-500 to-purple-600 hover:scale-110 transition-transform duration-200"
|
||||
className="fixed bottom-4 right-8 z-50 rounded-full h-14 w-14 shadow-xl bg-gradient-to-r from-pink-500 to-purple-600 hover:scale-110 transition-transform duration-200"
|
||||
size="icon"
|
||||
>
|
||||
<Sparkles className="h-6 w-6 text-white animate-pulse" />
|
||||
@@ -66,7 +66,7 @@ export function AiChat() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="fixed bottom-24 right-8 z-50 w-80 md:w-96 h-[500px] flex flex-col shadow-2xl border-primary/20 animate-in slide-in-from-bottom-10 fade-in duration-200">
|
||||
<Card className="fixed bottom-4 right-8 z-50 w-80 md:w-96 h-[500px] flex flex-col shadow-2xl border-primary/20 animate-in slide-in-from-bottom-10 fade-in duration-200">
|
||||
<CardHeader className="p-4 border-b bg-primary/5 flex flex-row items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Home, Calendar, List, LayoutGrid, Settings, CheckSquare, Target, Trophy, LogOut, Award } from 'lucide-react';
|
||||
import { Home, Calendar, List, LayoutGrid, Settings, CheckSquare, Target, Trophy, LogOut, Award, Bot, CalendarOff } from 'lucide-react';
|
||||
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -13,12 +16,9 @@ import {
|
||||
useSidebar,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { User } from "@shared/schema";
|
||||
import { Task, User } from '@shared/schema';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
import { GamificationBar } from './GamificationBar';
|
||||
|
||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
@@ -32,6 +32,13 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [location, setLocation] = useLocation();
|
||||
|
||||
// Fetch unscheduled count
|
||||
// const { data: unscheduledCount = 0 } = useQuery({
|
||||
// queryKey: ['/api/tasks'],
|
||||
// select: (tasks: Task[]) => tasks.filter(t => !t.dueDate && t.status !== 'done').length,
|
||||
// enabled: !!user
|
||||
// });
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await fetch("/api/logout", { method: "POST" });
|
||||
@@ -47,9 +54,11 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
|
||||
{ title: t('navigation.tasks'), id: 'tasks', path: '/tasks', icon: Home, color: 'text-blue-500' },
|
||||
{ title: t('navigation.calendar'), id: 'calendar', path: '/calendar', icon: Calendar, color: 'text-violet-500' },
|
||||
{ title: t('navigation.weekList'), id: 'weeklist', path: '/weeklist', icon: List, color: 'text-pink-500' },
|
||||
{ title: t('unscheduled.title', 'Unscheduled'), id: 'unscheduled', path: '/unscheduled', icon: CalendarOff, color: 'text-slate-500' },
|
||||
{ title: t('navigation.kanban'), id: 'kanban', path: '/kanban', icon: LayoutGrid, color: 'text-orange-500' },
|
||||
{ title: t('navigation.achievements'), id: 'achievements', path: '/achievements', icon: Trophy, color: 'text-yellow-500' },
|
||||
{ title: t('navigation.leaderboard'), id: 'leaderboard', path: '/leaderboard', icon: Award, color: 'text-yellow-500' },
|
||||
...(user?.aiEnabled ? [{ title: t('navigation.aiChat', 'AI Chat'), id: 'ai-chat', path: '/ai', icon: Bot, color: 'text-indigo-500' }] : []),
|
||||
{ title: t('navigation.settings'), id: 'settings', path: '/settings', icon: Settings, color: 'text-gray-500' },
|
||||
]
|
||||
|
||||
@@ -82,7 +91,8 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
|
||||
tooltip={item.title}
|
||||
className={`h-12 transition-all duration-200 ${location === item.path || (item.path !== '/' && location.startsWith(item.path))
|
||||
? 'bg-gradient-to-r from-violet-600 to-indigo-600 text-white shadow-md hover:from-violet-500 hover:to-indigo-500 hover:text-white'
|
||||
: 'hover:bg-sidebar-accent hover:pl-4'}`}
|
||||
: 'hover:bg-sidebar-accent hover:pl-4'
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`transition-all duration-200 ${state === 'collapsed' ? 'size-7' : 'size-5'} ${location === item.path || (item.path !== '/' && location.startsWith(item.path)) ? 'text-white' : item.color}`} />
|
||||
{state !== 'collapsed' && (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { ChevronLeft, ChevronRight, Calendar, Edit, Timer, CheckCircle, Play, Clock, Trash2 } from 'lucide-react';
|
||||
import { Task, Label } from '@shared/schema';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { addDays, format, isSameDay, startOfWeek, subWeeks } from 'date-fns';
|
||||
import { addDays, format, isSameDay, startOfWeek, subWeeks, startOfDay } from 'date-fns';
|
||||
import { de, enUS } from 'date-fns/locale';
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -21,7 +21,14 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
|
||||
|
||||
interface CalendarViewProps {
|
||||
tasks: Task[];
|
||||
@@ -61,9 +68,22 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
|
||||
|
||||
const getTasksForDate = (date: Date) => {
|
||||
return tasks.filter(task => {
|
||||
if (!task.dueDate) return false;
|
||||
const taskDate = new Date(task.dueDate);
|
||||
return isSameDay(taskDate, date);
|
||||
// Handle multi-day tasks
|
||||
if (task.startDate && task.dueDate) {
|
||||
const start = startOfDay(new Date(task.startDate));
|
||||
const end = startOfDay(new Date(task.dueDate));
|
||||
const current = startOfDay(date);
|
||||
return current >= start && current <= end;
|
||||
}
|
||||
|
||||
// Handle single date tasks (dueDate or startDate)
|
||||
if (task.dueDate) {
|
||||
return isSameDay(new Date(task.dueDate), date);
|
||||
}
|
||||
if (task.startDate) { // Fallback if only start date exists
|
||||
return isSameDay(new Date(task.startDate), date);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -367,9 +387,52 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
|
||||
})}
|
||||
|
||||
{dayTasks.length > 3 && (
|
||||
<Badge variant="secondary" className="w-full justify-center text-xs">
|
||||
{t('calendar.moreItems', { count: dayTasks.length - 3 })}
|
||||
</Badge>
|
||||
<HoverCard openDelay={0} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge variant="secondary" className="w-full justify-center text-xs cursor-pointer hover:bg-secondary/80">
|
||||
{t('calendar.moreItems', { count: dayTasks.length - 3 })}
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-72 p-0">
|
||||
<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 - 3 })}</span>
|
||||
<span className="text-[10px] font-normal opacity-75">Click to edit</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto p-2 space-y-1">
|
||||
{dayTasks.slice(3).map(task => {
|
||||
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-2 border rounded hover:bg-accent cursor-pointer flex items-center justify-between group transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskEdit?.(task);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1 mr-2">
|
||||
<div className="text-xs font-medium truncate">{task.title}</div>
|
||||
{taskLabel && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: taskLabel.color }} />
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[100px]">{taskLabel.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskEdit?.(task);
|
||||
}}>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -99,14 +99,28 @@ export default function FocusMode({
|
||||
};
|
||||
|
||||
const focusTasks = useMemo(() => {
|
||||
// Return top 3 tasks for focus list
|
||||
// Return tasks for focus list
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
return tasks.filter(t => t.status !== 'done')
|
||||
.sort((a, b) => {
|
||||
// 1. Due Date (Overdue/Today first)
|
||||
if (a.dueDate && b.dueDate) {
|
||||
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
|
||||
}
|
||||
// Tasks with due dates come before those without
|
||||
if (a.dueDate && !b.dueDate) return -1;
|
||||
if (!a.dueDate && b.dueDate) return 1;
|
||||
|
||||
// 2. Priority
|
||||
if (a.priority === 'high' && b.priority !== 'high') return -1;
|
||||
if (b.priority === 'high' && a.priority !== 'high') return 1;
|
||||
|
||||
return 0;
|
||||
})
|
||||
.slice(0, 3);
|
||||
// Take top 5 to include more subtasks if relevant
|
||||
.slice(0, 5);
|
||||
}, [tasks]);
|
||||
|
||||
const sensors = useSensors(
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle, X, Lock } from "lucide-react";
|
||||
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle, X, Lock, CornerDownRight } from "lucide-react";
|
||||
import { Task, Label } from '@shared/schema';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -150,6 +150,9 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
const blockingTasks = task.dependencies?.map(dId => allTasks.find(t => t.id === dId)).filter(t => t && t.status !== 'done') || [];
|
||||
const isBlocked = blockingTasks.length > 0;
|
||||
|
||||
const subtasks = allTasks.filter(t => t.parentTaskId === task.id);
|
||||
const completedSubtasks = subtasks.filter(t => t.status === 'done');
|
||||
|
||||
// Fetch labels to get the label color
|
||||
const { data: labels = [] } = useQuery<Label[]>({
|
||||
queryKey: ['/api/labels'],
|
||||
@@ -231,6 +234,11 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
}
|
||||
};
|
||||
|
||||
/* Retrieve parent task if this is a subtask */
|
||||
const parentTask = task.parentTaskId
|
||||
? allTasks.find(t => String(t.id) === String(task.parentTaskId))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
drag="x"
|
||||
@@ -245,11 +253,13 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
<ContextMenuTrigger asChild>
|
||||
<Card
|
||||
className={`p-5 hover-elevate active-elevate-2 transition-all duration-300 relative hover:-translate-y-1 hover:shadow-xl ${isDragging ? 'rotate-1 scale-105 shadow-lg' : ''
|
||||
} ${task.status === 'done' ? 'opacity-60' : ''}`}
|
||||
} ${task.status === 'done' ? 'opacity-60' : ''} ${parentTask ? 'border-l-4 border-l-indigo-400 bg-indigo-50/10' : ''}`}
|
||||
style={taskLabel ? {
|
||||
borderColor: taskLabel.color,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid'
|
||||
borderStyle: 'solid',
|
||||
borderLeftWidth: parentTask ? '4px' : '2px', // Make left border thicker if subtask
|
||||
borderLeftColor: parentTask ? '#818cf8' : taskLabel.color // Indigo for subtask
|
||||
} : {}}
|
||||
data-testid={`card-task-${task.id}`}
|
||||
>
|
||||
@@ -283,8 +293,17 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
console.log(`Task card clicked: ${task.title}`);
|
||||
}}
|
||||
>
|
||||
{/* Parent Task Link rendered in title now */}
|
||||
<h3 className={`font-medium text-sm leading-tight truncate ${task.status === 'done' ? 'line-through' : ''}`} data-testid={`text-task-title-${task.id}`}>
|
||||
{task.title}
|
||||
{parentTask ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground font-normal">{parentTask.title}</span>
|
||||
<span className="text-muted-foreground">›</span>
|
||||
<span>{task.title}</span>
|
||||
</span>
|
||||
) : (
|
||||
task.title
|
||||
)}
|
||||
</h3>
|
||||
{task.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2" data-testid={`text-task-description-${task.id}`}>
|
||||
@@ -312,11 +331,12 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
{/* Shared Status Icon */}
|
||||
<SharedTaskIcon task={task} />
|
||||
|
||||
{task.dueDate && (
|
||||
{(task.dueDate || task.startDate) && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span data-testid={`text-due-date-${task.id}`}>
|
||||
{task.dueDate.toLocaleDateString()}
|
||||
{task.startDate ? `${new Date(task.startDate).toLocaleDateString()} - ` : ''}
|
||||
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -328,7 +348,13 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
|
||||
{task.estimatedDuration && (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground border-dashed">
|
||||
⏳ {task.estimatedDuration}m
|
||||
⏳ {formatTime(task.estimatedDuration)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{subtasks.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{completedSubtasks.length}/{subtasks.length} Subtasks
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
|
||||
@@ -30,12 +30,14 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
const [energyLevel, setEnergyLevel] = useState<'low' | 'medium' | 'high'>('medium');
|
||||
const [estimatedDuration, setEstimatedDuration] = useState<number | undefined>();
|
||||
const [dueDate, setDueDate] = useState<Date | undefined>();
|
||||
const [startDate, setStartDate] = useState<Date | undefined>();
|
||||
|
||||
const [labelId, setLabelId] = useState<string | undefined>();
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
const [isDependenciesOpen, setIsDependenciesOpen] = useState(false);
|
||||
|
||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||
const [isStartDateOpen, setIsStartDateOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch labels
|
||||
@@ -59,6 +61,8 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
priority,
|
||||
energyLevel,
|
||||
estimatedDuration,
|
||||
|
||||
startDate,
|
||||
dueDate,
|
||||
labelId,
|
||||
dependencies,
|
||||
@@ -75,7 +79,7 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
setDescription('');
|
||||
setPriority('medium');
|
||||
setDueDate(undefined);
|
||||
setDueDate(undefined);
|
||||
setStartDate(undefined);
|
||||
setLabelId(undefined);
|
||||
setDependencies([]);
|
||||
setError(null);
|
||||
@@ -94,7 +98,7 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
setDescription('');
|
||||
setPriority('medium');
|
||||
setDueDate(undefined);
|
||||
setDueDate(undefined);
|
||||
setStartDate(undefined);
|
||||
setLabelId(undefined);
|
||||
setDependencies([]);
|
||||
setError(null);
|
||||
@@ -180,13 +184,25 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
</Select>
|
||||
|
||||
<div className="col-span-1">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('taskCreation.minutesPlaceholder')}
|
||||
value={estimatedDuration || ''}
|
||||
onChange={(e) => setEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
className="text-sm"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
|
||||
@@ -209,12 +225,43 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="col-span-2">
|
||||
<div className="col-span-1">
|
||||
<Popover open={isStartDateOpen} onOpenChange={setIsStartDateOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!startDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{startDate ? startDate.toLocaleDateString() : t('taskCreation.startDate')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={startDate}
|
||||
onSelect={(date) => {
|
||||
setStartDate(date);
|
||||
setIsStartDateOpen(false);
|
||||
}}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1">
|
||||
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left font-normal"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!dueDate && "text-muted-foreground"
|
||||
)}
|
||||
data-testid="button-due-date"
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
@@ -229,7 +276,6 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
setDueDate(date);
|
||||
setIsCalendarOpen(false);
|
||||
}}
|
||||
disabled={(date) => date < new Date()}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -11,8 +11,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar, Tag, Link2, Check } from 'lucide-react';
|
||||
import { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar as CalendarIcon, Tag, Link2, Check, LayoutList, Trash2, ArrowRight } from 'lucide-react';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { format } from 'date-fns';
|
||||
import { de, enUS } from 'date-fns/locale';
|
||||
import { Task, Label } from '@shared/schema';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -29,6 +33,7 @@ interface TaskDetailsModalProps {
|
||||
task: Task | null;
|
||||
onSave: (updatedTask: Task) => void;
|
||||
labels?: Label[];
|
||||
onNavigate?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export default function TaskDetailsModal({
|
||||
@@ -36,9 +41,12 @@ export default function TaskDetailsModal({
|
||||
onClose,
|
||||
task,
|
||||
onSave,
|
||||
labels = []
|
||||
labels = [],
|
||||
onNavigate
|
||||
}: TaskDetailsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const currentLocale = i18n.language === 'de' ? de : enUS;
|
||||
|
||||
// Edit form state
|
||||
const [editedTitle, setEditedTitle] = useState('');
|
||||
@@ -47,9 +55,17 @@ export default function TaskDetailsModal({
|
||||
const [editedPriority, setEditedPriority] = useState<'low' | 'medium' | 'high'>('medium');
|
||||
const [editedLabelId, setEditedLabelId] = useState<string | null>(null);
|
||||
const [editedDueDate, setEditedDueDate] = useState('');
|
||||
const [editedStartDate, setEditedStartDate] = useState('');
|
||||
const [editedEstimatedDuration, setEditedEstimatedDuration] = useState<number | undefined>(undefined);
|
||||
const [editedDependencies, setEditedDependencies] = useState<string[]>([]);
|
||||
const [isDependenciesOpen, setIsDependenciesOpen] = useState(false);
|
||||
|
||||
// Subtasks state
|
||||
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||
const [isCreatingSubtask, setIsCreatingSubtask] = useState(false);
|
||||
const [isScheduling, setIsScheduling] = useState(false);
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
@@ -78,6 +94,8 @@ export default function TaskDetailsModal({
|
||||
setEditedPriority(task.priority as 'low' | 'medium' | 'high');
|
||||
setEditedLabelId(task.labelId || null);
|
||||
setEditedDueDate(task.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : '');
|
||||
setEditedStartDate(task.startDate ? new Date(task.startDate).toISOString().split('T')[0] : '');
|
||||
setEditedEstimatedDuration(task.estimatedDuration || undefined);
|
||||
setEditedDependencies(task.dependencies || []);
|
||||
|
||||
setNotes(task.notes || '');
|
||||
@@ -123,8 +141,11 @@ export default function TaskDetailsModal({
|
||||
description: editedDescription || null,
|
||||
status: editedStatus,
|
||||
priority: editedPriority,
|
||||
|
||||
labelId: editedLabelId,
|
||||
dueDate: editedDueDate ? new Date(editedDueDate) : null,
|
||||
startDate: editedStartDate ? new Date(editedStartDate) : null,
|
||||
estimatedDuration: editedEstimatedDuration ?? null,
|
||||
dependencies: editedDependencies
|
||||
};
|
||||
onSave(updatedTask);
|
||||
@@ -180,6 +201,55 @@ export default function TaskDetailsModal({
|
||||
console.log(`Time entry saved: ${totalMinutes} minutes for task: ${task.title}`);
|
||||
};
|
||||
|
||||
const handleCreateSubtask = async () => {
|
||||
if (!task || !newSubtaskTitle.trim()) return;
|
||||
|
||||
try {
|
||||
setIsCreatingSubtask(true);
|
||||
const subtaskData = {
|
||||
title: newSubtaskTitle,
|
||||
description: '',
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
parentTaskId: task.id,
|
||||
dueDate: null,
|
||||
estimatedDuration: null
|
||||
};
|
||||
|
||||
await apiRequest('POST', '/api/tasks', subtaskData);
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
setNewSubtaskTitle('');
|
||||
// Switch to the newly created task? Or just show it in the list.
|
||||
} catch (error) {
|
||||
console.error('Failed to create subtask:', error);
|
||||
} finally {
|
||||
setIsCreatingSubtask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoSchedule = async () => {
|
||||
if (!task) return;
|
||||
setIsScheduling(true);
|
||||
try {
|
||||
const res = await apiRequest('POST', `/api/tasks/${task.id}/schedule`, {});
|
||||
const data = await res.json();
|
||||
if (data.success && data.scheduledDate) {
|
||||
setEditedDueDate(new Date(data.scheduledDate).toISOString().split('T')[0]);
|
||||
// Also update the local task object immediately for smoother UX, or let queryClient invalidate
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
} else {
|
||||
console.error("Scheduling failed: ", data.error);
|
||||
// Could add a toast here ideally
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Auto-schedule error", error);
|
||||
} finally {
|
||||
setIsScheduling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const subtasks = tasks.filter(t => t.parentTaskId === task?.id);
|
||||
|
||||
const handleDeleteTimeEntry = (entryId: string) => {
|
||||
if (!task || entryId === 'tracked-time') {
|
||||
// Don't allow deleting the main tracked time entry
|
||||
@@ -246,6 +316,10 @@ export default function TaskDetailsModal({
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
|
||||
const parentTask = task.parentTaskId ? tasks.find(t => String(t.id) === String(task.parentTaskId)) : null;
|
||||
const isSubtask = !!task.parentTaskId;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
@@ -260,6 +334,16 @@ export default function TaskDetailsModal({
|
||||
{/* Task Information */}
|
||||
<Card className="p-4">
|
||||
<div className="space-y-3">
|
||||
{parentTask && (
|
||||
<div
|
||||
className="flex items-center gap-1 text-sm text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/30 px-2 py-1 rounded w-fit mb-1 cursor-pointer hover:underline"
|
||||
onClick={() => onNavigate?.(String(parentTask.id))}
|
||||
>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
<span className="font-medium">{t('taskDetails.subtaskOf')}</span>
|
||||
<span>{parentTask.title}</span>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="font-semibold text-lg" data-testid="text-task-details-title">
|
||||
{task.title}
|
||||
</h3>
|
||||
@@ -287,7 +371,7 @@ export default function TaskDetailsModal({
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="edit" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="edit" data-testid="tab-edit">
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
{t('taskDetails.editTab')}
|
||||
@@ -296,12 +380,74 @@ export default function TaskDetailsModal({
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
{t('taskDetails.notesTab')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="subtasks" data-testid="tab-subtasks">
|
||||
<LayoutList className="w-4 h-4 mr-2" />
|
||||
{t('taskDetails.subtasksTab')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="time" data-testid="tab-time">
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
{t('taskDetails.timeTab')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Subtasks Tab */}
|
||||
<TabsContent value="subtasks" className="space-y-4">
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium mb-4">{t('taskDetails.subtasks')}</h4>
|
||||
|
||||
{!isSubtask ? (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Input
|
||||
placeholder={t('taskDetails.newSubtaskPlaceholder')}
|
||||
value={newSubtaskTitle}
|
||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateSubtask();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateSubtask}
|
||||
disabled={!newSubtaskTitle.trim() || isCreatingSubtask}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t('taskDetails.createSubtask')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 text-sm rounded-md border border-yellow-200 dark:border-yellow-900 flex items-center gap-2">
|
||||
<LayoutList className="w-4 h-4" />
|
||||
{t('taskDetails.hierarchyRestriction')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{subtasks.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-4">
|
||||
{t('taskDetails.noSubtasks')}
|
||||
</div>
|
||||
) : (
|
||||
subtasks.map(subtask => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className="flex items-center justify-between p-3 border rounded-md hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onClick={() => onNavigate?.(String(subtask.id))}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={subtask.status === 'done' ? 'secondary' : 'outline'}>
|
||||
{t(`taskDetails.statusValue.${subtask.status}`)}
|
||||
</Badge>
|
||||
<span className={subtask.status === 'done' ? 'line-through text-muted-foreground' : ''}>
|
||||
{subtask.title}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Edit Tab */}
|
||||
<TabsContent value="edit" className="space-y-4">
|
||||
<Card className="p-4">
|
||||
@@ -386,21 +532,111 @@ export default function TaskDetailsModal({
|
||||
</div>
|
||||
|
||||
{/* Due Date */}
|
||||
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Start Date */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2 h-8">
|
||||
<label className="text-sm font-medium">{t('taskDetails.startDate')}</label>
|
||||
</div>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!editedStartDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{editedStartDate ? format(new Date(editedStartDate), "PPP", { locale: i18n.language === 'de' ? de : enUS }) : <span>{t('taskDetails.pickDate')}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={editedStartDate ? new Date(editedStartDate) : undefined}
|
||||
onSelect={(date) => setEditedStartDate(date ? date.toISOString().split('T')[0] : '')}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Due Date */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2 h-8">
|
||||
<label className="text-sm font-medium">{t('taskDetails.dueDate')}</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-indigo-600 border-indigo-200 hover:text-indigo-700 hover:bg-indigo-50"
|
||||
onClick={handleAutoSchedule}
|
||||
disabled={isScheduling}
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3 mr-1" />
|
||||
{isScheduling ? t('taskDetails.scheduling') : t('taskDetails.autoSchedule')}
|
||||
</Button>
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!editedDueDate && "text-muted-foreground"
|
||||
)}
|
||||
data-testid="input-due-date"
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{editedDueDate ? format(new Date(editedDueDate), "PPP", { locale: i18n.language === 'de' ? de : enUS }) : <span>{t('taskDetails.pickDate')}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={editedDueDate ? new Date(editedDueDate) : undefined}
|
||||
onSelect={(date) => setEditedDueDate(date ? date.toISOString().split('T')[0] : '')}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Estimated Duration */}
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">{t('taskDetails.dueDate')}</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={editedDueDate}
|
||||
onChange={(e) => setEditedDueDate(e.target.value)}
|
||||
data-testid="input-due-date"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
<div>
|
||||
<label className="text-sm font-medium flex items-center gap-2 mb-2">
|
||||
<Link2 className="w-4 h-4" />
|
||||
Blocked By
|
||||
{t('taskDetails.blockedBy')}
|
||||
</label>
|
||||
<Popover open={isDependenciesOpen} onOpenChange={setIsDependenciesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -772,6 +1008,6 @@ export default function TaskDetailsModal({
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog >
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,17 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Search, Filter, SortAsc, Calendar, ChevronLeft, ChevronRight, Edit, Timer, CheckCircle, Play, Clock, Trash2 } from 'lucide-react';
|
||||
import { Task, Label } from '@shared/schema';
|
||||
import TaskCard from './TaskCard';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import TimeCompletionModal from './TimeCompletionModal';
|
||||
import { addDays, format, isSameDay, startOfToday } from 'date-fns';
|
||||
import { addDays, format, isSameDay, startOfToday, startOfDay, isWithinInterval } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDateLocale } from '../hooks/use-date-locale';
|
||||
|
||||
@@ -125,13 +130,33 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
}
|
||||
});
|
||||
|
||||
// Tasks that are NOT on the calendar (no due date or not scheduled)
|
||||
const unscheduledTasks = filteredAndSortedTasks.filter(task => !task.dueDate);
|
||||
// Tasks that are NOT on the calendar (no due date OR not in visible dates)
|
||||
const unscheduledTasks = filteredAndSortedTasks.filter(task => {
|
||||
if (!task.dueDate) return true;
|
||||
const isVisible = dates.some(date => isSameDay(date, task.dueDate!));
|
||||
return !isVisible;
|
||||
});
|
||||
|
||||
const getTasksForDate = (date: Date) => {
|
||||
return filteredAndSortedTasks.filter(task =>
|
||||
task.dueDate && isSameDay(task.dueDate, date)
|
||||
);
|
||||
return filteredAndSortedTasks.filter(task => {
|
||||
// Handle multi-day tasks
|
||||
if (task.startDate && task.dueDate) {
|
||||
const start = startOfDay(new Date(task.startDate));
|
||||
const end = startOfDay(new Date(task.dueDate));
|
||||
const current = startOfDay(date);
|
||||
return current >= start && current <= end;
|
||||
}
|
||||
|
||||
// Handle single date tasks (dueDate or startDate)
|
||||
if (task.dueDate) {
|
||||
return isSameDay(new Date(task.dueDate), date);
|
||||
}
|
||||
if (task.startDate) {
|
||||
return isSameDay(new Date(task.startDate), date);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const getFilterCount = (filter: FilterOption) => {
|
||||
@@ -214,9 +239,9 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-[calc(100vh-5rem)] md:min-h-[calc(100vh-3rem)] relative space-y-4">
|
||||
{/* Header and Search Filters - Sticky */}
|
||||
<div className="sticky top-16 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-3 mb-4">
|
||||
<div className="flex flex-col h-[calc(100vh-6rem)] relative">
|
||||
{/* Header and Search Filters - Fixed at Top */}
|
||||
<div className="flex-none pb-3 mb-2 bg-background/95 backdrop-blur z-40 border-b">
|
||||
<div className="space-y-3">
|
||||
{/* Title Bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -282,7 +307,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 flex-1">
|
||||
{/* Scrollable Task List */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 space-y-3 px-1 pb-4">
|
||||
{unscheduledTasks.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground" data-testid="text-no-tasks">
|
||||
@@ -294,8 +320,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2 mt-8 px-1">
|
||||
{t('taskList.unscheduledTasksLabel')}
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2 mt-2">
|
||||
Task List
|
||||
</div>
|
||||
{unscheduledTasks.map((task) => (
|
||||
<div
|
||||
@@ -335,9 +361,9 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calendar Section - Sticky at bottom */}
|
||||
<div className="sticky bottom-0 -mx-4 sm:-mx-6 bg-background/95 backdrop-blur z-30 border-t mt-auto shadow-up-lg">
|
||||
<div className="p-3 min-h-[200px] max-h-[40vh] overflow-y-auto">
|
||||
{/* Calendar Section - Fixed at bottom */}
|
||||
<div className="flex-none -mx-4 sm:-mx-6 bg-background/95 backdrop-blur z-30 border-t mt-auto shadow-up-lg">
|
||||
<div className="p-3">
|
||||
{/* Calendar Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -371,7 +397,11 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
{/* Full Width Calendar Grid - Dynamic */}
|
||||
<div className={`grid gap-2`} style={{ gridTemplateColumns: `repeat(${visibleDays}, minmax(0, 1fr))` }}>
|
||||
{dates.map((date, index) => {
|
||||
const dayTasks = getTasksForDate(date);
|
||||
// Sort by priority to ensure high priority items are shown first
|
||||
const dayTasks = getTasksForDate(date).sort((a, b) => {
|
||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||
return (priorityOrder[b.priority as keyof typeof priorityOrder] || 0) - (priorityOrder[a.priority as keyof typeof priorityOrder] || 0);
|
||||
});
|
||||
const isToday = isSameDay(date, new Date());
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
|
||||
const isHovered = hoveredDate && isSameDay(date, hoveredDate);
|
||||
@@ -552,9 +582,52 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
})}
|
||||
|
||||
{dayTasks.length > 2 && (
|
||||
<Badge variant="secondary" className="w-full justify-center text-sm sm:text-xs py-1 sm:py-0">
|
||||
+{dayTasks.length - 2}
|
||||
</Badge>
|
||||
<HoverCard openDelay={0} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge variant="secondary" className="w-full justify-center text-sm sm:text-xs py-1 sm:py-0 cursor-pointer hover:bg-secondary/80">
|
||||
+{dayTasks.length - 2} more
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-72 p-0" side="top">
|
||||
<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>
|
||||
</h4>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto p-2 space-y-1">
|
||||
{dayTasks.slice(2).map(task => {
|
||||
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-2 border rounded hover:bg-accent cursor-pointer flex items-center justify-between group transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskEdit?.(task);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1 mr-2">
|
||||
<div className="text-xs font-medium truncate">{task.title}</div>
|
||||
{taskLabel && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: taskLabel.color }} />
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[100px]">{taskLabel.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskEdit?.(task);
|
||||
}}>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
|
||||
{dayTasks.length === 0 && (
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string | null;
|
||||
source: string;
|
||||
details: any;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function AuditLogsTable() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: logs, isLoading, error } = useQuery<AuditLog[]>({
|
||||
queryKey: ['/api/admin/audit-logs'],
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex justify-center p-8"><Loader2 className="h-8 w-8 animate-spin" /></div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="p-8 text-center text-red-500">Failed to load audit logs. Please check server logs.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit Logs</CardTitle>
|
||||
<CardDescription>Track all system changes and AI actions.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Entity</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs?.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{format(new Date(log.createdAt), "MMM d, HH:mm:ss")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={log.source === 'AI' ? 'secondary' : 'outline'}>
|
||||
{log.source}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{log.action}</TableCell>
|
||||
<TableCell>
|
||||
{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">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs">
|
||||
{JSON.stringify(log.details, null, 2)}
|
||||
</pre>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{(!logs || logs.length === 0) && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
No logs found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Loader2, Server, Save } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export function McpSettingsCard() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
mcp_enabled: false,
|
||||
mcp_port: "5001" // Default to main app port unless specific override logic is added
|
||||
});
|
||||
|
||||
const { data: settings, isLoading } = useQuery<any>({
|
||||
queryKey: ['/api/admin/settings'],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setFormData({
|
||||
mcp_enabled: settings.mcp_enabled === true,
|
||||
mcp_port: settings.mcp_port || "5001"
|
||||
});
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
const res = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save settings");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/admin/settings'] });
|
||||
toast({ title: t('settings.saved', 'Settings saved') });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: t('settings.error', 'Failed to save settings'), variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
mutation.mutate({
|
||||
mcp_enabled: formData.mcp_enabled,
|
||||
mcp_port: formData.mcp_port
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="flex justify-center p-4"><Loader2 className="animate-spin" /></div>;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-primary" />
|
||||
<CardTitle>MCP Server Configuration</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Configure the Model Context Protocol (MCP) server settings.
|
||||
The MCP server runs on the same port as the application (/api/mcp).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="mcp_enabled" className="flex flex-col gap-1">
|
||||
<span>Enable MCP Server</span>
|
||||
<span className="font-normal text-xs text-muted-foreground">
|
||||
Allow external AI tools to connect via MCP protocol.
|
||||
</span>
|
||||
</Label>
|
||||
<Switch
|
||||
id="mcp_enabled"
|
||||
checked={formData.mcp_enabled}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, mcp_enabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp_port">Port (Informational)</Label>
|
||||
<Input
|
||||
id="mcp_port"
|
||||
value={formData.mcp_port}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, mcp_port: e.target.value }))}
|
||||
placeholder="5001"
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Currently runs on the main application port. Separate port configuration coming soon.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={mutation.isPending}>
|
||||
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t('settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils"
|
||||
const badgeVariants = cva(
|
||||
// Whitespace-nowrap: Badges should never wrap.
|
||||
"whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" +
|
||||
" hover-elevate " ,
|
||||
" hover-elevate ",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -27,12 +27,15 @@ const badgeVariants = cva(
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
VariantProps<typeof badgeVariants> { }
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
)
|
||||
Badge.displayName = "Badge"
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@@ -13,16 +13,18 @@ const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<HoverCardPrimitive.Portal>
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
|
||||
Reference in New Issue
Block a user