feat: enhance audit logging, add MCP settings, and production docker setup
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:
2025-12-15 15:53:31 +01:00
parent fdf321cde9
commit d1736c5991
35 changed files with 5165 additions and 499 deletions
+2 -2
View File
@@ -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" />
+16 -6
View File
@@ -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' && (
+70 -7
View File
@@ -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>
+16 -2
View File
@@ -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(
+33 -7
View File
@@ -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>
)}
+58 -12
View File
@@ -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>
+249 -13
View File
@@ -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 >
);
}
+92 -19
View File
@@ -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>
);
}
+10 -7
View File
@@ -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 }
+12 -10
View File
@@ -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