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:
+92
-45
@@ -27,9 +27,9 @@ import ProjectTemplate from './components/ProjectTemplate';
|
||||
import WeekListView from './components/WeekListView';
|
||||
import Settings from './pages/settings';
|
||||
import AchievementsPage from './pages/AchievementsPage';
|
||||
import UnscheduledTasksPage from './pages/UnscheduledTasksPage';
|
||||
import { Task, Label } from '@shared/schema';
|
||||
import { useTimer } from './hooks/useTimer';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { AppSidebar } from "./components/AppSidebar"
|
||||
@@ -45,16 +45,17 @@ import NotFound from "@/pages/not-found";
|
||||
import { AiChat } from "@/components/AiChat";
|
||||
import ForgotPasswordPage from "@/pages/ForgotPasswordPage";
|
||||
import ResetPasswordPage from "@/pages/ResetPasswordPage";
|
||||
import AiChatPage from "@/pages/AiChatPage";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { User } from "@shared/schema";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [labels, setLabels] = useState<Label[]>([]);
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isTaskDetailsOpen, setIsTaskDetailsOpen] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
@@ -62,6 +63,8 @@ function App() {
|
||||
const [showPomodoro, setShowPomodoro] = useState(false);
|
||||
const [activePomodoroTaskId, setActivePomodoroTaskId] = useState<string | null>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: user, isLoading: isLoadingUser } = useQuery<User>({
|
||||
queryKey: ["/api/user"],
|
||||
retry: false,
|
||||
@@ -71,32 +74,20 @@ function App() {
|
||||
queryKey: ['/api/setup/status'],
|
||||
});
|
||||
|
||||
// Fetch tasks and labels
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const tasksResponse = await fetch('/api/tasks');
|
||||
if (tasksResponse.ok) {
|
||||
const serverTasks: Task[] = await tasksResponse.json();
|
||||
const normalizedTasks = serverTasks.map(task => ({
|
||||
...task,
|
||||
dueDate: task.dueDate ? new Date(task.dueDate) : null
|
||||
}));
|
||||
setTasks(normalizedTasks);
|
||||
}
|
||||
// Fetch tasks and labels using React Query
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
enabled: !!user,
|
||||
select: (data) => data.map(task => ({
|
||||
...task,
|
||||
dueDate: task.dueDate ? new Date(task.dueDate) : null
|
||||
}))
|
||||
});
|
||||
|
||||
const labelsResponse = await fetch('/api/labels');
|
||||
if (labelsResponse.ok) {
|
||||
const serverLabels: Label[] = await labelsResponse.json();
|
||||
setLabels(serverLabels);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [user]);
|
||||
const { data: labels = [] } = useQuery<Label[]>({
|
||||
queryKey: ['/api/labels'],
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const handleCreateTask = async (newTask: Partial<Task>) => {
|
||||
try {
|
||||
@@ -122,17 +113,31 @@ function App() {
|
||||
if (!response.ok) throw new Error('Failed to create task');
|
||||
|
||||
const createdTask: Task = await response.json();
|
||||
const normalizedTask = {
|
||||
...createdTask,
|
||||
dueDate: createdTask.dueDate ? new Date(createdTask.dueDate) : null
|
||||
};
|
||||
setTasks(prev => [...prev, normalizedTask]);
|
||||
|
||||
// Update cache optimistically or invalidation
|
||||
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => {
|
||||
const normalizedTask = {
|
||||
...createdTask,
|
||||
dueDate: createdTask.dueDate ? new Date(createdTask.dueDate) : null
|
||||
};
|
||||
return [...old, normalizedTask];
|
||||
});
|
||||
// Invalidate to be sure
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskUpdate = async (taskId: string, updates: Partial<Task>) => {
|
||||
// Optimistic Update
|
||||
const previousTasks = queryClient.getQueryData<Task[]>(['/api/tasks']);
|
||||
|
||||
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) =>
|
||||
old.map(task => task.id === taskId ? { ...task, ...updates } : task)
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -143,19 +148,28 @@ function App() {
|
||||
if (!response.ok) throw new Error('Failed to update task');
|
||||
|
||||
const updatedTask: Task = await response.json();
|
||||
const normalizedTask = {
|
||||
...updatedTask,
|
||||
dueDate: updatedTask.dueDate ? new Date(updatedTask.dueDate) : null
|
||||
};
|
||||
setTasks(prev => prev.map(task => task.id === taskId ? normalizedTask : task));
|
||||
|
||||
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => uniqueTasks(old, updatedTask));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating task:', error);
|
||||
setTasks(prev => prev.map(task => task.id === taskId ? { ...task, ...updates } : task));
|
||||
// Rollback
|
||||
if (previousTasks) {
|
||||
queryClient.setQueryData(['/api/tasks'], previousTasks);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to merge updated task into array
|
||||
const uniqueTasks = (tasks: Task[], updated: Task) => {
|
||||
const normalized = { ...updated, dueDate: updated.dueDate ? new Date(updated.dueDate) : null };
|
||||
return tasks.map(t => t.id === updated.id ? normalized : t);
|
||||
};
|
||||
|
||||
const handleCreateFromTemplate = (templateId: string, startDate: Date, projectName: string) => {
|
||||
console.log('Creating project from template:', { templateId, startDate, projectName });
|
||||
// Assuming this might create tasks, we should invalidate
|
||||
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['/api/tasks'] }), 1000);
|
||||
};
|
||||
|
||||
const { startTimer, stopTimer } = useTimer({
|
||||
@@ -197,8 +211,14 @@ function App() {
|
||||
|
||||
const confirmTaskDelete = async () => {
|
||||
if (!deleteTaskId) return;
|
||||
|
||||
const previousTasks = queryClient.getQueryData<Task[]>(['/api/tasks']);
|
||||
const taskToDelete = tasks.find(task => task.id === deleteTaskId);
|
||||
setTasks(prev => prev.filter(task => task.id !== deleteTaskId));
|
||||
|
||||
// Optimistic delete
|
||||
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) =>
|
||||
old.filter(task => task.id !== deleteTaskId)
|
||||
);
|
||||
|
||||
if (selectedTask?.id === deleteTaskId) {
|
||||
handleTaskDetailsClose();
|
||||
@@ -208,9 +228,13 @@ function App() {
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${deleteTaskId}`, { method: 'DELETE' });
|
||||
if (!response.ok && response.status !== 404) throw new Error('Failed to delete task');
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
} catch (error) {
|
||||
console.error('Error deleting task:', error);
|
||||
if (taskToDelete) setTasks(prev => [...prev, taskToDelete]);
|
||||
// Rollback
|
||||
if (previousTasks) {
|
||||
queryClient.setQueryData(['/api/tasks'], previousTasks);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -315,16 +339,36 @@ function App() {
|
||||
<Route path="/templates">
|
||||
<ProjectTemplate
|
||||
onCreateFromTemplate={handleCreateFromTemplate}
|
||||
onCreateTasks={(newTasks) => setTasks(prev => [...prev, ...newTasks])}
|
||||
onCreateTasks={(newTasks) => {
|
||||
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => [...old, ...newTasks]);
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
}}
|
||||
onNavigateToSettings={() => setLocation('/settings')}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/achievements">
|
||||
<AchievementsPage user={user} />
|
||||
{user ? <AchievementsPage user={user} /> : <AuthPage />}
|
||||
</Route>
|
||||
|
||||
<Route path="/unscheduled">
|
||||
{user ? (
|
||||
<UnscheduledTasksPage
|
||||
user={user}
|
||||
onToggleCompletion={(taskId, currentStatus) => handleTaskStatusChange(taskId, currentStatus === 'done' ? 'todo' : 'done')}
|
||||
onDelete={(id) => setDeleteTaskId(id)}
|
||||
onUpdate={handleTaskUpdate}
|
||||
onSelect={setSelectedTask}
|
||||
/>
|
||||
) : (
|
||||
<AuthPage />
|
||||
)}
|
||||
</Route>
|
||||
<Route path="/leaderboard">
|
||||
<LeaderboardPage />
|
||||
</Route>
|
||||
<Route path="/ai">
|
||||
<AiChatPage />
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
<Settings onNavigateToTemplates={() => setLocation('/templates')} />
|
||||
</Route>
|
||||
@@ -370,7 +414,6 @@ function App() {
|
||||
/>
|
||||
|
||||
<Toaster />
|
||||
{user?.aiEnabled && <AiChat />}
|
||||
|
||||
<TaskCreationModal
|
||||
isOpen={isCreateModalOpen}
|
||||
@@ -384,6 +427,10 @@ function App() {
|
||||
task={selectedTask}
|
||||
onSave={handleTaskDetailsSave}
|
||||
labels={labels}
|
||||
onNavigate={(id) => {
|
||||
const t = tasks.find(x => x.id === id);
|
||||
if (t) setSelectedTask(t);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteTaskId !== null} onOpenChange={(open) => !open && setDeleteTaskId(null)}>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Task } from '@shared/schema';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { de, enUS } from 'date-fns/locale';
|
||||
|
||||
export function useNotifications({ poll = true }: { poll?: boolean } = {}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [permission, setPermission] = useState<NotificationPermission>('default');
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
// Track notified tasks to prevent duplicate alerts in same session
|
||||
const notifiedTasksRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if ('Notification' in window) {
|
||||
setPermission(Notification.permission);
|
||||
const isEnabled = localStorage.getItem('taskflow-notifications-enabled') === 'true';
|
||||
setEnabled(isEnabled && Notification.permission === 'granted');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const requestPermission = useCallback(async () => {
|
||||
if (!('Notification' in window)) return false;
|
||||
|
||||
const result = await Notification.requestPermission();
|
||||
setPermission(result);
|
||||
|
||||
if (result === 'granted') {
|
||||
setEnabled(true);
|
||||
localStorage.setItem('taskflow-notifications-enabled', 'true');
|
||||
new Notification(t('notifications.enabledTitle'), {
|
||||
body: t('notifications.enabledBody'),
|
||||
// icon: '/favicon.ico' // Chrome sometimes blocks if icon 404
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [t]);
|
||||
|
||||
const toggleEnabled = useCallback((value: boolean) => {
|
||||
if (value && permission !== 'granted') {
|
||||
requestPermission();
|
||||
} else {
|
||||
setEnabled(value);
|
||||
localStorage.setItem('taskflow-notifications-enabled', String(value));
|
||||
}
|
||||
}, [permission, requestPermission]);
|
||||
|
||||
// Query tasks for polling
|
||||
const { data: tasks } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
enabled: enabled && poll // Only poll if enabled AND polling is active
|
||||
});
|
||||
|
||||
// Polling Logic
|
||||
useEffect(() => {
|
||||
if (!poll || !enabled || !tasks || permission !== 'granted') return;
|
||||
|
||||
const checkTasks = () => {
|
||||
const now = new Date();
|
||||
|
||||
tasks.forEach(task => {
|
||||
if (!task.dueDate || task.status === 'done' || notifiedTasksRef.current.has(task.id)) return;
|
||||
|
||||
const dueDate = new Date(task.dueDate);
|
||||
const diffMs = dueDate.getTime() - now.getTime();
|
||||
const diffMinutes = diffMs / (1000 * 60);
|
||||
|
||||
// Alert: Upcoming (15 min before)
|
||||
if (diffMinutes > 0 && diffMinutes <= 15) {
|
||||
sendNotification(task, 'upcoming');
|
||||
notifiedTasksRef.current.add(task.id);
|
||||
}
|
||||
|
||||
// Alert: Just Overdue (within last 1 min to catch it once)
|
||||
// or purely check if overdue and not notified?
|
||||
// Let's stick to "Just became overdue" or "Is overdue" but protect with Set
|
||||
if (diffMinutes < 0) {
|
||||
sendNotification(task, 'overdue');
|
||||
notifiedTasksRef.current.add(task.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const sendNotification = (task: Task, type: 'upcoming' | 'overdue') => {
|
||||
const title = type === 'upcoming'
|
||||
? t('notifications.upcomingTitle', { task: task.title })
|
||||
: t('notifications.overdueTitle', { task: task.title });
|
||||
|
||||
const body = type === 'upcoming'
|
||||
? t('notifications.upcomingBody', { time: formatDistanceToNow(new Date(task.dueDate!), { locale: i18n.language === 'de' ? de : enUS }) })
|
||||
: t('notifications.overdueBody');
|
||||
|
||||
new Notification(title, {
|
||||
body,
|
||||
// icon: '/favicon.ico',
|
||||
tag: `task-${task.id}-${type}` // prevent duplicate native notifications
|
||||
});
|
||||
};
|
||||
|
||||
// Check immediately and then interval
|
||||
checkTasks();
|
||||
const interval = setInterval(checkTasks, 60000); // Check every minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [enabled, tasks, permission, t, i18n.language]);
|
||||
|
||||
return {
|
||||
permission,
|
||||
enabled,
|
||||
requestPermission,
|
||||
toggleEnabled
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,23 @@
|
||||
"title": "TaskFlow",
|
||||
"backToTasks": "Zurück zu Aufgaben"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"enableBrowser": "Browser-Benachrichtigungen aktivieren",
|
||||
"description": "Erhalten Sie Warnungen für bevorstehende und überfällige Aufgaben.",
|
||||
"enabledTitle": "Benachrichtigungen aktiviert",
|
||||
"enabledBody": "Sie erhalten nun Warnungen für Ihre Aufgaben.",
|
||||
"upcomingTitle": "Demnächst: {{task}}",
|
||||
"upcomingBody": "Fällig in {{time}}",
|
||||
"overdueTitle": "Überfällig: {{task}}",
|
||||
"overdueBody": "Diese Aufgabe ist jetzt überfällig!"
|
||||
},
|
||||
"unscheduled": {
|
||||
"title": "Ungeplante Aufgaben",
|
||||
"empty": "Keine ungeplanten Aufgaben!",
|
||||
"schedule": "Planen",
|
||||
"tab": "Ungeplant"
|
||||
},
|
||||
"navigation": {
|
||||
"tasks": "Aufgaben",
|
||||
"focus": "Fokus",
|
||||
@@ -73,7 +90,10 @@
|
||||
"create": "Aufgabe erstellen",
|
||||
"smartInputActive": "Smart Input Aktiv",
|
||||
"energy": "Energie",
|
||||
"minutesPlaceholder": "Minuten (optional)"
|
||||
"minutesPlaceholder": "Minuten (optional)",
|
||||
"duration": "Dauer",
|
||||
"durationNone": "Keine",
|
||||
"startDate": "Startdatum"
|
||||
},
|
||||
"taskDetails": {
|
||||
"title": "Aufgabendetails",
|
||||
@@ -93,6 +113,11 @@
|
||||
"notesTab": "Notizen",
|
||||
"timeTab": "Zeiterfassung",
|
||||
"editTask": "Aufgabe bearbeiten",
|
||||
"subtasksTab": "Teilaufgaben",
|
||||
"subtasks": "Teilaufgaben",
|
||||
"newSubtaskPlaceholder": "Titel der Teilaufgabe...",
|
||||
"createSubtask": "Teilaufgabe hinzufügen",
|
||||
"noSubtasks": "Noch keine Teilaufgaben",
|
||||
"titleLabel": "Titel",
|
||||
"titlePlaceholder": "Aufgabentitel",
|
||||
"taskNotes": "Aufgabennotizen",
|
||||
@@ -117,7 +142,21 @@
|
||||
"timeTracked": "Erfasste Zeit",
|
||||
"delete": "Aufgabe löschen",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Änderungen speichern"
|
||||
"save": "Änderungen speichern",
|
||||
"startDate": "Startdatum",
|
||||
"estimatedDuration": "Geschätzte Dauer",
|
||||
"subtaskOf": "Teilaufgabe von:",
|
||||
"hierarchyRestriction": "Teilaufgaben können keine eigenen Teilaufgaben haben. Dies ist eine strikte 1-Level-Hierarchie.",
|
||||
"blockedBy": "Blockiert durch",
|
||||
"autoSchedule": "Auto-Planen",
|
||||
"pickDate": "Datum wählen",
|
||||
"scheduling": "Plane...",
|
||||
"scheduledFor": "Geplant für {{date}}",
|
||||
"scheduleFail": "Planung fehlgeschlagen: {{error}}",
|
||||
"durationUnits": {
|
||||
"m": "Min",
|
||||
"h": "Std"
|
||||
}
|
||||
},
|
||||
"taskCard": {
|
||||
"play": "Timer starten",
|
||||
@@ -255,9 +294,25 @@
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI-Assistent",
|
||||
"assistant": "KI Assistent",
|
||||
"welcome": "Wie kann ich Ihnen heute bei Ihren Aufgaben helfen?",
|
||||
"welcomeTitle": "Wie kann ich Ihnen helfen?",
|
||||
"welcomeDesc": "Ich kann Sie bei Ihren Aufgaben, Ihrer Planung und mehr unterstützen.",
|
||||
"thinking": "Denke nach...",
|
||||
"placeholder": "Stellen Sie eine Frage...",
|
||||
"newChat": "Neuer Chat",
|
||||
"newChatDefault": "Neuer Chat",
|
||||
"startChat": "Neuen Chat starten",
|
||||
"selectConversation": "Chat wechseln",
|
||||
"recentChats": "Letzte Chats",
|
||||
"noChats": "Keine letzten Chats",
|
||||
"chatRenamed": "Chat umbenannt",
|
||||
"chatDeleted": "Chat gelöscht",
|
||||
"deleteConfirm": "Diesen Chat wirklich löschen?",
|
||||
"noConversation": "Kein Chat ausgewählt",
|
||||
"sendFailed": "Senden fehlgeschlagen",
|
||||
"errorSending": "Fehler beim Senden",
|
||||
"disclaimer": "KI kann Fehler machen. Überprüfen Sie wichtige Informationen.",
|
||||
"error": "Ich bin auf einen Fehler gestoßen"
|
||||
},
|
||||
"userManagement": {
|
||||
@@ -425,14 +480,36 @@
|
||||
"daily_streak": "Täglicher Serien-Bonus",
|
||||
"daily_clear_bonus": "Tagesziel-Bonus",
|
||||
"goal_completed": "Ziel erreicht"
|
||||
},
|
||||
"rules": {
|
||||
"title": "Gamification Regeln",
|
||||
"xpSystem": "XP System",
|
||||
"levelRequirements": "Level Anforderungen",
|
||||
"actions": "Aktionen & Belohnungen",
|
||||
"level": "Level {{level}}",
|
||||
"xp": "{{xp}} XP",
|
||||
"action": "Aktion",
|
||||
"points": "Punkte",
|
||||
"createTask": "Aufgabe erstellen",
|
||||
"createSubtask": "Unteraufgabe erstellen",
|
||||
"updateTask": "Aufgabe aktualisieren",
|
||||
"completeTask": "Aufgabe erledigen (Pünktlich)",
|
||||
"completeTaskLate": "Aufgabe erledigen (Verspätet)",
|
||||
"aiAction": "AI Funktion nutzen",
|
||||
"dailyStreak": "Täglicher Serienbonus"
|
||||
}
|
||||
},
|
||||
"ranks": {
|
||||
"novice": "Einsteiger",
|
||||
"builder": "Baumeister",
|
||||
"planner": "Planer",
|
||||
"architect": "Architekt",
|
||||
"master": "Meister"
|
||||
"novice": "Neuling",
|
||||
"apprentice": "Lehrling",
|
||||
"journeyman": "Geselle",
|
||||
"artisan": "Handwerker",
|
||||
"expert": "Experte",
|
||||
"master": "Meister",
|
||||
"grandmaster": "Großmeister",
|
||||
"virtuoso": "Virtuose",
|
||||
"legend": "Legende",
|
||||
"mythic": "Mythisch"
|
||||
},
|
||||
"leaderboardPage": {
|
||||
"title": "Bestenliste",
|
||||
|
||||
@@ -3,6 +3,23 @@
|
||||
"title": "TaskFlow",
|
||||
"backToTasks": "Back to Tasks"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"enableBrowser": "Enable Browser Notifications",
|
||||
"description": "Get alerted about upcoming and overdue tasks.",
|
||||
"enabledTitle": "Notifications Enabled",
|
||||
"enabledBody": "You will now receive alerts for your tasks.",
|
||||
"upcomingTitle": "Upcoming: {{task}}",
|
||||
"upcomingBody": "Due in {{time}}",
|
||||
"overdueTitle": "Overdue: {{task}}",
|
||||
"overdueBody": "This task is now overdue!"
|
||||
},
|
||||
"unscheduled": {
|
||||
"title": "Unscheduled Tasks",
|
||||
"empty": "No unscheduled tasks!",
|
||||
"schedule": "Schedule",
|
||||
"tab": "Unscheduled"
|
||||
},
|
||||
"navigation": {
|
||||
"tasks": "Tasks",
|
||||
"focus": "Focus",
|
||||
@@ -73,7 +90,10 @@
|
||||
"cancel": "Cancel",
|
||||
"smartInputActive": "Smart Input Active",
|
||||
"energy": "Energy",
|
||||
"minutesPlaceholder": "Minutes (optional)"
|
||||
"minutesPlaceholder": "Minutes (optional)",
|
||||
"duration": "Duration",
|
||||
"durationNone": "None",
|
||||
"startDate": "Start Date"
|
||||
},
|
||||
"taskDetails": {
|
||||
"title": "Task Details",
|
||||
@@ -107,6 +127,11 @@
|
||||
"notesTab": "Notes",
|
||||
"timeTab": "Time Tracking",
|
||||
"editTask": "Edit Task",
|
||||
"subtasksTab": "Subtasks",
|
||||
"subtasks": "Subtasks",
|
||||
"newSubtaskPlaceholder": "New subtask title...",
|
||||
"createSubtask": "Add Subtask",
|
||||
"noSubtasks": "No subtasks yet",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Task title",
|
||||
"taskNotes": "Task Notes",
|
||||
@@ -117,7 +142,21 @@
|
||||
"addTimeEntry": "Add Time Entry",
|
||||
"clock": "Clock",
|
||||
"manual": "Manual",
|
||||
"previouslyTrackedTime": "Previously tracked time"
|
||||
"previouslyTrackedTime": "Previously tracked time",
|
||||
"startDate": "Start Date",
|
||||
"estimatedDuration": "Estimated Duration",
|
||||
"subtaskOf": "Subtask of:",
|
||||
"hierarchyRestriction": "Subtasks cannot have their own subtasks. This is a strict 1-level hierarchy.",
|
||||
"blockedBy": "Blocked By",
|
||||
"autoSchedule": "Auto-Schedule",
|
||||
"pickDate": "Pick a date",
|
||||
"scheduling": "Scheduling...",
|
||||
"scheduledFor": "Scheduled for {{date}}",
|
||||
"scheduleFail": "Scheduling failed: {{error}}",
|
||||
"durationUnits": {
|
||||
"m": "m",
|
||||
"h": "h"
|
||||
}
|
||||
},
|
||||
"taskCard": {
|
||||
"play": "Start timer",
|
||||
@@ -428,10 +467,15 @@
|
||||
},
|
||||
"ranks": {
|
||||
"novice": "Novice",
|
||||
"builder": "Builder",
|
||||
"planner": "Planner",
|
||||
"architect": "Architect",
|
||||
"master": "Master"
|
||||
"apprentice": "Apprentice",
|
||||
"journeyman": "Journeyman",
|
||||
"artisan": "Artisan",
|
||||
"expert": "Expert",
|
||||
"master": "Master",
|
||||
"grandmaster": "Grandmaster",
|
||||
"virtuoso": "Virtuoso",
|
||||
"legend": "Legend",
|
||||
"mythic": "Mythic"
|
||||
},
|
||||
"leaderboardPage": {
|
||||
"title": "Leaderboard",
|
||||
@@ -519,6 +563,23 @@
|
||||
"daily_streak": "Daily Streak Bonus",
|
||||
"daily_clear_bonus": "Daily Clear Bonus",
|
||||
"goal_completed": "Goal Completed"
|
||||
},
|
||||
"rules": {
|
||||
"title": "Gamification Rules",
|
||||
"xpSystem": "XP System",
|
||||
"levelRequirements": "Level Requirements",
|
||||
"actions": "Actions & Rewards",
|
||||
"level": "Level {{level}}",
|
||||
"xp": "{{xp}} XP",
|
||||
"action": "Action",
|
||||
"points": "Points",
|
||||
"createTask": "Create Task",
|
||||
"createSubtask": "Create Subtask",
|
||||
"updateTask": "Update Task",
|
||||
"completeTask": "Complete Task (On Time)",
|
||||
"completeTaskLate": "Complete Task (Late)",
|
||||
"aiAction": "Use AI Feature",
|
||||
"dailyStreak": "Daily Streak Bonus"
|
||||
}
|
||||
},
|
||||
"rewards": {
|
||||
|
||||
@@ -1,47 +1 @@
|
||||
export const LEVEL_THRESHOLDS = [
|
||||
0, // Level 1: 0-99
|
||||
100, // Level 2: 100-249
|
||||
250, // Level 3: 250-499
|
||||
500, // Level 4: 500-999
|
||||
1000, // Level 5: 1000-1999
|
||||
2000, // Level 6: 2000-3499
|
||||
3500, // Level 7: 3500-4999
|
||||
5000, // Level 8: 5000-7499
|
||||
7500, // Level 9: 7500-9999
|
||||
10000 // Level 10: 10000+
|
||||
];
|
||||
|
||||
export function getLevelFromXP(xp: number): number {
|
||||
for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) {
|
||||
if (xp >= LEVEL_THRESHOLDS[i]) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function getNextLevelXP(level: number): number {
|
||||
if (level >= LEVEL_THRESHOLDS.length) {
|
||||
return LEVEL_THRESHOLDS[LEVEL_THRESHOLDS.length - 1] * 1.5; // Scale indefinitely
|
||||
}
|
||||
return LEVEL_THRESHOLDS[level];
|
||||
}
|
||||
|
||||
export function getLevelProgress(xp: number): number {
|
||||
const currentLevel = getLevelFromXP(xp);
|
||||
const currentLevelStart = LEVEL_THRESHOLDS[currentLevel - 1];
|
||||
const nextLevelStart = getNextLevelXP(currentLevel);
|
||||
|
||||
if (xp >= nextLevelStart) return 100;
|
||||
|
||||
const progress = ((xp - currentLevelStart) / (nextLevelStart - currentLevelStart)) * 100;
|
||||
return Math.min(100, Math.max(0, progress));
|
||||
}
|
||||
|
||||
export function getRankKey(level: number): string {
|
||||
if (level >= 50) return 'master';
|
||||
if (level >= 20) return 'architect';
|
||||
if (level >= 10) return 'planner';
|
||||
if (level >= 5) return 'builder';
|
||||
return 'novice';
|
||||
}
|
||||
export * from '@shared/gamification';
|
||||
|
||||
@@ -7,12 +7,12 @@ import { Progress } from "@/components/ui/progress";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
import { Trophy, Target, TrendingUp, Plus, CheckCircle2, Circle, Flame } from 'lucide-react';
|
||||
import { Trophy, Target, TrendingUp, Plus, CheckCircle2, Circle, Flame, Scroll, BookOpen, Hammer, Award, Medal, Star, Crown, Zap, Sparkles, Sun } from 'lucide-react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Goal, Reward, User } from '@shared/schema';
|
||||
import { getLevelFromXP, getRankKey } from '@/lib/gamification';
|
||||
import { getLevelFromXP, getRankKey, LEVEL_THRESHOLDS } from '@/lib/gamification';
|
||||
import { RewardCard } from '@/components/gamification/RewardCard';
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
@@ -174,6 +174,7 @@ export default function AchievementsPage({ user }: { user: User }) {
|
||||
<TabsTrigger value="rewards">{t('rewards.shopTitle')}</TabsTrigger>
|
||||
<TabsTrigger value="inventory">{t('achievements.inventory')}</TabsTrigger>
|
||||
<TabsTrigger value="history">{t('achievements.history')}</TabsTrigger>
|
||||
<TabsTrigger value="rules">{t('gamification.rules.title')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
|
||||
@@ -517,6 +518,121 @@ export default function AchievementsPage({ user }: { user: User }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="rules">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Level Requirements */}
|
||||
<Card className="border-primary/10 shadow-md">
|
||||
<CardHeader className="bg-muted/30 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<CardTitle>{t('gamification.rules.levelRequirements')}</CardTitle>
|
||||
<CardDescription>{t('gamification.rules.xpSystem')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border">
|
||||
{LEVEL_THRESHOLDS.slice(0, 10).map((threshold, index) => {
|
||||
const level = index + 1;
|
||||
const rankKey = getRankKey(level);
|
||||
const isCurrentLevel = getLevelFromXP(user.xp) === level;
|
||||
|
||||
// Visual Configuration for Ranks
|
||||
const getRankStyle = (l: number) => {
|
||||
if (l >= 10) return { icon: Sun, color: "text-rose-500", bg: "bg-rose-500/10", border: "border-rose-500/20" };
|
||||
if (l >= 9) return { icon: Sparkles, color: "text-purple-500", bg: "bg-purple-500/10", border: "border-purple-500/20" };
|
||||
if (l >= 8) return { icon: Zap, color: "text-violet-500", bg: "bg-violet-500/10", border: "border-violet-500/20" };
|
||||
if (l >= 7) return { icon: Crown, color: "text-yellow-600", bg: "bg-yellow-600/10", border: "border-yellow-600/20" };
|
||||
if (l >= 6) return { icon: Star, color: "text-yellow-500", bg: "bg-yellow-500/10", border: "border-yellow-500/20" };
|
||||
if (l >= 5) return { icon: Medal, color: "text-orange-500", bg: "bg-orange-500/10", border: "border-orange-500/20" };
|
||||
if (l >= 4) return { icon: Award, color: "text-blue-500", bg: "bg-blue-500/10", border: "border-blue-500/20" };
|
||||
if (l >= 3) return { icon: Hammer, color: "text-cyan-500", bg: "bg-cyan-500/10", border: "border-cyan-500/20" };
|
||||
if (l >= 2) return { icon: BookOpen, color: "text-green-500", bg: "bg-green-500/10", border: "border-green-500/20" };
|
||||
return { icon: Scroll, color: "text-slate-500", bg: "bg-slate-500/10", border: "border-slate-500/20" };
|
||||
};
|
||||
|
||||
const style = getRankStyle(level);
|
||||
const RankIcon = style.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-between p-4 transition-all hover:bg-muted/50 ${isCurrentLevel ? 'bg-primary/5 ring-1 ring-inset ring-primary/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`h-10 w-10 rounded-lg ${style.bg} ${style.color} flex items-center justify-center border ${style.border}`}>
|
||||
<RankIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`font-semibold ${isCurrentLevel ? 'text-primary' : ''}`}>
|
||||
{t(`ranks.${rankKey}`)}
|
||||
{isCurrentLevel && <span className="ml-2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">{t('gamification.level', { level })}</span>}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
{t('gamification.rules.level', { level })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono font-medium text-sm">
|
||||
{t('gamification.rules.xp', { xp: threshold })}
|
||||
</div>
|
||||
{isCurrentLevel && (
|
||||
<div className="text-[10px] text-primary font-medium mt-0.5">
|
||||
Current
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* XP Rewards */}
|
||||
<Card className="border-primary/10 shadow-md h-fit">
|
||||
<CardHeader className="bg-muted/30 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<CardTitle>{t('gamification.rules.actions')}</CardTitle>
|
||||
<CardDescription>{t('gamification.rules.xpSystem')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border">
|
||||
{[
|
||||
{ action: 'createTask', points: 10, icon: Plus, color: 'text-blue-500', bg: 'bg-blue-500/10' },
|
||||
{ action: 'createSubtask', points: 5, icon: Plus, color: 'text-cyan-500', bg: 'bg-cyan-500/10' },
|
||||
{ action: 'updateTask', points: 2, icon: CheckCircle2, color: 'text-slate-500', bg: 'bg-slate-500/10' },
|
||||
{ action: 'completeTask', points: 50, icon: CheckCircle2, color: 'text-green-500', bg: 'bg-green-500/10' },
|
||||
{ action: 'completeTaskLate', points: 20, icon: CheckCircle2, color: 'text-yellow-500', bg: 'bg-yellow-500/10' },
|
||||
{ action: 'aiAction', points: 5, icon: Sparkles, color: 'text-purple-500', bg: 'bg-purple-500/10' },
|
||||
{ action: 'dailyStreak', points: 100, icon: Flame, color: 'text-orange-500', bg: 'bg-orange-500/10' },
|
||||
].map((item, index) => {
|
||||
const ActionIcon = item.icon;
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-md ${item.bg} ${item.color}`}>
|
||||
<ActionIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="font-medium text-sm">{t(`gamification.rules.${item.action}`)}</span>
|
||||
</div>
|
||||
<div className="font-bold text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/30 px-2 py-1 rounded text-xs">
|
||||
+{item.points} XP
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs >
|
||||
</div >
|
||||
);
|
||||
|
||||
@@ -3,8 +3,11 @@ import { SMTPSettingsCard } from "@/components/admin/SMTPSettingsCard";
|
||||
import { AiSettingsCard } from "@/components/admin/AiSettingsCard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { ArrowLeft, Shield, Bot, FileText, Settings } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AuditLogsTable } from "@/components/admin/AuditLogsTable";
|
||||
import { McpSettingsCard } from "@/components/admin/McpSettingsCard";
|
||||
|
||||
export default function AdminSettings() {
|
||||
const { t } = useTranslation();
|
||||
@@ -23,8 +26,28 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<AiSettingsCard />
|
||||
<SMTPSettingsCard />
|
||||
<Tabs defaultValue="settings" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="settings" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
General & AI
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="audit" className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Audit Logs
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="settings" className="space-y-6">
|
||||
<AiSettingsCard />
|
||||
<McpSettingsCard />
|
||||
<SMTPSettingsCard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audit">
|
||||
<AuditLogsTable />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
import { useState, useEffect, useRef, useLayoutEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Plus, MessageSquare, Trash2, Send, Bot, User as UserIcon, Loader2, Sparkles, AlertCircle, Pencil, Check, ChevronDown, History, X } from "lucide-react";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
// Types
|
||||
type Conversation = {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type Message = {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const TypewriterMessage = ({ content, onComplete }: { content: string, onComplete?: () => void }) => {
|
||||
const [displayedContent, setDisplayedContent] = useState("");
|
||||
const indexRef = useRef(0);
|
||||
|
||||
// Optimization: Render faster
|
||||
const SPEED_MS = 1;
|
||||
const CHARS_PER_TICK = 5;
|
||||
|
||||
useEffect(() => {
|
||||
indexRef.current = 0;
|
||||
setDisplayedContent("");
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setDisplayedContent((prev) => {
|
||||
if (indexRef.current >= content.length) {
|
||||
clearInterval(interval);
|
||||
onComplete?.();
|
||||
return content;
|
||||
}
|
||||
const nextSlice = content.slice(indexRef.current, indexRef.current + CHARS_PER_TICK);
|
||||
indexRef.current += CHARS_PER_TICK;
|
||||
return prev + nextSlice;
|
||||
});
|
||||
}, SPEED_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{displayedContent}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function AiChatPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 'new' indicates a temporary draft state
|
||||
const [selectedConversationId, setSelectedConversationId] = useState<string | 'new' | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [latestMessageId, setLatestMessageId] = useState<string | null>(null);
|
||||
|
||||
// Rename state
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||
const [editMessageContent, setEditMessageContent] = useState("");
|
||||
|
||||
// Fetch Conversations
|
||||
const { data: conversations, isLoading: isLoadingConvs } = useQuery<Conversation[]>({
|
||||
queryKey: ["/api/ai/conversations"],
|
||||
});
|
||||
|
||||
// Select latest conversation on load if none selected
|
||||
useEffect(() => {
|
||||
if (conversations && conversations.length > 0 && !selectedConversationId) {
|
||||
setSelectedConversationId(conversations[0].id);
|
||||
}
|
||||
}, [conversations, selectedConversationId]);
|
||||
|
||||
// Fetch Messages for selected conversation (disabled if 'new')
|
||||
const { data: messages, isLoading: isLoadingMessages } = useQuery<Message[]>({
|
||||
queryKey: ["/api/ai/conversations", selectedConversationId, "messages"],
|
||||
enabled: !!selectedConversationId && selectedConversationId !== 'new',
|
||||
});
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, selectedConversationId, latestMessageId]);
|
||||
|
||||
// Mutations
|
||||
const createConversationMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("POST", "/api/ai/conversations", { title: t('ai.newChatDefault', 'New Chat') });
|
||||
if (!res.ok) throw new Error("Failed to create conversation");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (newConv) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
|
||||
// Do NOT set selected ID here, handled in handleSend to avoid race conditions or double sets
|
||||
},
|
||||
});
|
||||
|
||||
const deleteConversationMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiRequest("DELETE", `/api/ai/conversations/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
|
||||
if (selectedConversationId !== 'new') {
|
||||
setSelectedConversationId(null);
|
||||
}
|
||||
toast({ title: t('ai.chatDeleted', 'Chat deleted') });
|
||||
},
|
||||
});
|
||||
|
||||
const renameConversationMutation = useMutation({
|
||||
mutationFn: async ({ id, title }: { id: string; title: string }) => {
|
||||
await apiRequest("PATCH", `/api/ai/conversations/${id}`, { title });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
|
||||
setEditingId(null);
|
||||
toast({ title: t('ai.chatRenamed', 'Chat renamed') });
|
||||
},
|
||||
});
|
||||
|
||||
const generateTitleMutation = useMutation({
|
||||
mutationFn: async ({ messages }: { conversationId: string, messages: any[] }) => {
|
||||
const res = await apiRequest("POST", "/api/ai/generate-title", { messages });
|
||||
if (!res.ok) throw new Error("Failed to generate title");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data, vars) => {
|
||||
renameConversationMutation.mutate({ id: vars.conversationId, title: data.title });
|
||||
}
|
||||
});
|
||||
|
||||
const sendMessageMutation = useMutation({
|
||||
mutationFn: async ({ conversationId, content }: { conversationId: string, content: string }) => {
|
||||
const res = await apiRequest("POST", "/api/ai/chat", {
|
||||
conversationId,
|
||||
content,
|
||||
clientTime: new Date().toLocaleString()
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.error || t('ai.sendFailed', 'Failed to send message'));
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onMutate: async ({ conversationId, content }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["/api/ai/conversations", conversationId, "messages"] });
|
||||
const previousMessages = queryClient.getQueryData<Message[]>(["/api/ai/conversations", conversationId, "messages"]);
|
||||
|
||||
queryClient.setQueryData(["/api/ai/conversations", conversationId, "messages"], (old: Message[] = []) => [
|
||||
...old,
|
||||
{ id: 'temp-' + Date.now(), role: 'user', content: content, createdAt: new Date().toISOString() }
|
||||
]);
|
||||
|
||||
const previousInput = input;
|
||||
setInput("");
|
||||
return { previousMessages, newContent: previousInput };
|
||||
},
|
||||
onError: (err: any, vars, context: any) => {
|
||||
if (context?.previousMessages) {
|
||||
queryClient.setQueryData(["/api/ai/conversations", vars.conversationId, "messages"], context.previousMessages);
|
||||
}
|
||||
if (context?.newContent) {
|
||||
setInput(context.newContent);
|
||||
}
|
||||
toast({
|
||||
title: t('ai.errorSending', 'Error sending message'),
|
||||
description: err.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
},
|
||||
onSuccess: (botMessage, vars, context) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations", vars.conversationId, "messages"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/goals"] });
|
||||
setLatestMessageId(botMessage.id);
|
||||
|
||||
// Auto-title if this was the first exchange (previous messages empty or undefined)
|
||||
if (!context?.previousMessages || context.previousMessages.length === 0) {
|
||||
const messagesForTitle = [
|
||||
{ role: 'user', content: vars.content },
|
||||
{ role: 'assistant', content: botMessage.content }
|
||||
];
|
||||
generateTitleMutation.mutate({ conversationId: vars.conversationId, messages: messagesForTitle });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const editMessageMutation = useMutation({
|
||||
mutationFn: async ({ id, content }: { id: string, content: string }) => {
|
||||
const res = await apiRequest("PUT", `/api/ai/chat/${id}`, {
|
||||
content,
|
||||
clientTime: new Date().toLocaleString()
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.error || t('ai.editFailed', 'Failed to edit message'));
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (botMessage, vars) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations", selectedConversationId, "messages"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); // In case tasks were updated during regen
|
||||
setEditingMessageId(null);
|
||||
setLatestMessageId(botMessage.id);
|
||||
toast({ title: t('ai.messageEdited', 'Message edited & answer regenerated') });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: t('ai.errorEditing', 'Error editing message'),
|
||||
description: err.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleSend = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!input.trim() || sendMessageMutation.isPending || createConversationMutation.isPending) return;
|
||||
|
||||
let conversationId = selectedConversationId;
|
||||
|
||||
// If drafting a new chat, create it now
|
||||
if (conversationId === 'new') {
|
||||
try {
|
||||
const newConv = await createConversationMutation.mutateAsync();
|
||||
conversationId = newConv.id;
|
||||
setSelectedConversationId(conversationId);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('ai.error', 'Error'),
|
||||
description: t('ai.createFailed', 'Failed to create new conversation'),
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (conversationId && conversationId !== 'new') {
|
||||
sendMessageMutation.mutate({ conversationId, content: input });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setSelectedConversationId('new');
|
||||
setInput("");
|
||||
// No mutation call here.
|
||||
};
|
||||
|
||||
const handleDelete = (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDeleteId(id);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteId) {
|
||||
deleteConversationMutation.mutate(deleteId);
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = (conv: Conversation, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditingId(conv.id);
|
||||
setEditName(conv.title);
|
||||
};
|
||||
|
||||
const saveName = () => {
|
||||
if (!editingId || !editName.trim()) {
|
||||
setEditingId(null);
|
||||
return;
|
||||
}
|
||||
renameConversationMutation.mutate({ id: editingId, title: editName });
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') saveName();
|
||||
if (e.key === 'Escape') setEditingId(null);
|
||||
e.stopPropagation(); // prevent closing dropdown
|
||||
};
|
||||
|
||||
const handleStartEditMessage = (msg: Message) => {
|
||||
setEditingMessageId(msg.id);
|
||||
setEditMessageContent(msg.content);
|
||||
};
|
||||
|
||||
const handleSaveEditMessage = () => {
|
||||
if (!editingMessageId || !editMessageContent.trim()) return;
|
||||
editMessageMutation.mutate({ id: editingMessageId, content: editMessageContent });
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'ArrowUp' && !input && !editingMessageId && messages) {
|
||||
e.preventDefault();
|
||||
// Find last user message
|
||||
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
||||
if (lastUserMsg) {
|
||||
handleStartEditMessage(lastUserMsg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const currentTitle = selectedConversationId === 'new'
|
||||
? t('ai.newChat', 'New Chat')
|
||||
: conversations?.find(c => c.id === selectedConversationId)?.title || t('ai.assistant', 'AI Assistant');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] -m-4 md:-m-6 bg-background relative">
|
||||
{/* Header / Top Navigation Bar */}
|
||||
<div className="h-16 border-b flex items-center px-4 md:px-6 justify-between shrink-0 bg-background/80 backdrop-blur-sm z-30 sticky top-0 shadow-sm">
|
||||
|
||||
{/* Left: Branding, History & Current Title */}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Sparkles className="w-4 h-4 text-violet-500" />
|
||||
</div>
|
||||
|
||||
{/* Logic: If editing current title, show input. Else show Dropdown. */}
|
||||
{editingId === selectedConversationId ? (
|
||||
<div className="flex items-center gap-2 flex-1 max-w-[300px]">
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={saveName}
|
||||
autoFocus
|
||||
className="h-9"
|
||||
/>
|
||||
<Button size="icon" variant="ghost" onClick={() => setEditingId(null)}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-auto py-2 px-3 font-semibold text-lg flex gap-2 items-center hover:bg-muted/50 transition-colors rounded-lg max-w-full">
|
||||
<div className="flex flex-col items-start leading-none min-w-0">
|
||||
<span className="bg-gradient-to-r from-violet-600 to-indigo-600 bg-clip-text text-transparent text-lg truncate max-w-[200px] md:max-w-[400px]">
|
||||
{currentTitle}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground font-normal flex items-center gap-1">
|
||||
{t('ai.selectConversation', 'Switch Chat')} <ChevronDown className="w-3 h-3" />
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[300px] max-h-[500px] overflow-y-auto">
|
||||
<DropdownMenuLabel>{t('ai.recentChats', 'Recent Chats')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{conversations?.map(conv => (
|
||||
<DropdownMenuItem
|
||||
key={conv.id}
|
||||
onClick={() => setSelectedConversationId(conv.id)}
|
||||
className={cn("flex justify-between items-center cursor-pointer py-3 group", selectedConversationId === conv.id ? "bg-muted" : "")}
|
||||
>
|
||||
{editingId === conv.id ? (
|
||||
<div className="flex items-center gap-2 flex-1 onClick-stop">
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={saveName}
|
||||
autoFocus
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 overflow-hidden flex-1">
|
||||
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate font-medium">{conv.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground"
|
||||
onClick={(e) => startEditing(conv, e)}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => handleDelete(conv.id, e)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{(!conversations || conversations.length === 0) && (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{t('ai.noChats', 'No recent chats')}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Edit Button: Only show if we have a REAL selected conversation (not 'new') */}
|
||||
{selectedConversationId && selectedConversationId !== 'new' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:bg-muted"
|
||||
onClick={(e) => {
|
||||
const conv = conversations?.find(c => c.id === selectedConversationId);
|
||||
if (conv) startEditing(conv, e);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: New Chat Action */}
|
||||
<Button onClick={handleCreateNew} size="sm" className="gap-2 shadow-sm rounded-full shrink-0">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('ai.newChat', 'New Chat')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0 relative overflow-hidden">
|
||||
{!selectedConversationId ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-8 text-center animate-in fade-in zoom-in duration-300">
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-violet-500/10 to-indigo-500/10 rounded-full flex items-center justify-center mb-6 animate-pulse">
|
||||
<Sparkles className="w-12 h-12 text-violet-500" />
|
||||
</div>
|
||||
<h3 className="text-3xl font-bold mb-3 tracking-tight text-foreground">
|
||||
{t('ai.welcomeTitle', 'How can I help you?')}
|
||||
</h3>
|
||||
<p className="max-w-md mb-8 text-lg opacity-80 leading-relaxed">
|
||||
{t('ai.welcomeDesc', 'I can assist you with your tasks, planning, and more.')}
|
||||
</p>
|
||||
<Button size="lg" onClick={handleCreateNew} className="rounded-full px-8 h-12 text-base shadow-lg hover:shadow-xl transition-all">
|
||||
{t('ai.startChat', 'Start a New Chat')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Messages: Render if not 'new', or if 'new' show empty */}
|
||||
<ScrollArea className="flex-1 px-4 md:px-8 pt-4 md:pt-8 pb-2">
|
||||
<div className="space-y-8 max-w-3xl mx-auto pb-4">
|
||||
{selectedConversationId !== 'new' && messages?.map((msg) => (
|
||||
<div key={msg.id} className={cn("flex gap-4 animate-in slide-in-from-bottom-2 duration-300 group", msg.role === 'user' ? "flex-row-reverse" : "flex-row")}>
|
||||
<div className={cn(
|
||||
"w-9 h-9 rounded-full flex items-center justify-center shrink-0 shadow-sm transition-transform group-hover:scale-105",
|
||||
msg.role === 'user' ? "bg-primary text-primary-foreground" : "bg-gradient-to-br from-violet-500 to-indigo-600 text-white"
|
||||
)}>
|
||||
{msg.role === 'user' ? <UserIcon className="w-5 h-5" /> : <Sparkles className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"flex flex-col gap-1 min-w-0 max-w-[85%]",
|
||||
msg.role === 'user' ? "items-end" : "items-start"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"rounded-2xl px-6 py-4 text-sm shadow-sm leading-relaxed relative",
|
||||
editingMessageId === msg.id ? "w-full min-w-[300px] border-primary ring-2 ring-primary/20" : "",
|
||||
msg.role === 'user'
|
||||
? "bg-primary text-primary-foreground rounded-tr-none"
|
||||
: "bg-background border shadow-md text-foreground rounded-tl-none"
|
||||
)}>
|
||||
{editingMessageId === msg.id ? (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<textarea
|
||||
value={editMessageContent}
|
||||
onChange={(e) => setEditMessageContent(e.target.value)}
|
||||
className="w-full bg-transparent border-0 focus:ring-0 p-0 text-inherit resize-none min-h-[60px]"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditingMessageId(null)} className="h-7 text-xs bg-white/20 hover:bg-white/30 text-inherit border-0">
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveEditMessage} disabled={editMessageMutation.isPending} className="h-7 text-xs bg-white text-primary hover:bg-white/90">
|
||||
{editMessageMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : t('common.save', 'Regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Edit Button for User Messages */}
|
||||
{msg.role === 'user' && !editingMessageId && (
|
||||
<button
|
||||
onClick={() => handleStartEditMessage(msg)}
|
||||
className="absolute -left-8 top-1/2 -translate-y-1/2 p-1.5 rounded-full bg-muted/80 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity hover:bg-muted hover:text-foreground"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{msg.role === 'assistant' ? (
|
||||
msg.id === latestMessageId ? (
|
||||
<TypewriterMessage content={msg.content} onComplete={() => scrollToBottom()} />
|
||||
) : (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap">{msg.content}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{msg.createdAt && (
|
||||
<span className="text-[10px] text-muted-foreground opacity-0 group-hover:opacity-60 transition-opacity px-2">
|
||||
{formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true, locale: i18n.language?.startsWith('de') ? de : undefined })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending) && (
|
||||
<div className="flex gap-4 animate-pulse">
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center shrink-0 opacity-80">
|
||||
<Sparkles className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div className="bg-background border rounded-2xl rounded-tl-none px-6 py-4 flex items-center gap-2 shadow-sm">
|
||||
<span className="flex gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-violet-500 animate-[bounce_1.4s_infinite] [animation-delay:-0.32s]"></span>
|
||||
<span className="w-2 h-2 rounded-full bg-indigo-500 animate-[bounce_1.4s_infinite] [animation-delay:-0.16s]"></span>
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500 animate-[bounce_1.4s_infinite]"></span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-medium ml-2">{editMessageMutation.isPending ? t('ai.regenerating', 'Regenerating...') : t('ai.thinking', 'Thinking...')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={scrollRef} className="h-px" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="pt-2 px-4 md:px-6 md:pt-2 pb-6 bg-background/50 backdrop-blur-sm shrink-0">
|
||||
<div className="max-w-3xl mx-auto space-y-3">
|
||||
<form
|
||||
className="relative flex items-center gap-2 bg-muted/40 border rounded-2xl px-4 py-2.5 shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary transition-all hover:bg-muted/60"
|
||||
onSubmit={handleSend}
|
||||
>
|
||||
<Sparkles className="w-5 h-5 text-muted-foreground/70" />
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={t('ai.placeholder', 'Ask me anything about your tasks...')}
|
||||
className="flex-1 border-0 bg-transparent shadow-none focus-visible:ring-0 px-3 h-11 text-base placeholder:text-muted-foreground/60"
|
||||
autoFocus
|
||||
disabled={sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!input.trim() || sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-9 w-9 rounded-xl shrink-0 transition-all duration-300",
|
||||
input.trim()
|
||||
? "opacity-100 scale-100 bg-primary text-primary-foreground shadow-md hover:scale-105"
|
||||
: "opacity-0 scale-75"
|
||||
)}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</form>
|
||||
<div className="text-center">
|
||||
<span className="text-[11px] text-muted-foreground/60 flex items-center justify-center gap-1.5">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{t('ai.disclaimer', 'AI can make mistakes. Verify important information.')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('ai.deleteConfirmTitle', 'Delete Chat')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('ai.deleteConfirmDesc', 'Are you sure you want to delete this conversation? This cannot be undone.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
{t('common.delete', 'Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Task, Label, User } from '@shared/schema';
|
||||
import TaskCard from '@/components/TaskCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { CalendarOff } from 'lucide-react';
|
||||
|
||||
interface UnscheduledTasksProps {
|
||||
user: User;
|
||||
onToggleCompletion: (taskId: string, currentStatus: string) => void;
|
||||
onDelete: (taskId: string) => void;
|
||||
onUpdate: (taskId: string, updates: Partial<Task>) => void;
|
||||
onSelect: (task: Task) => void;
|
||||
}
|
||||
|
||||
export default function UnscheduledTasksPage({ user, onToggleCompletion, onDelete, onUpdate, onSelect }: UnscheduledTasksProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
select: (data) => data
|
||||
.filter(task => !task.dueDate && task.status !== 'done')
|
||||
.map(task => ({
|
||||
...task,
|
||||
dueDate: task.dueDate ? new Date(task.dueDate) : null
|
||||
}))
|
||||
});
|
||||
|
||||
const { data: labels = [] } = useQuery<Label[]>({
|
||||
queryKey: ['/api/labels']
|
||||
});
|
||||
|
||||
// We need a dummy toggleTaskCompletion and onDelete for display purposes,
|
||||
// or we pass the real ones if we lift state up.
|
||||
// Ideally we use the mutations directly in TaskCard or pass them from App.tsx context.
|
||||
// For now, let's assume TaskCard handles some, but it takes props.
|
||||
// Checking TaskCard props... it needs `onToggleCompletion`, `onDelete`, `onUpdate`, `onSelect`.
|
||||
// This suggests I should wrap this page in App.tsx or pass these handlers down.
|
||||
// Refactoring: I'll create this component but it might need to accept props from App.tsx
|
||||
// if I want it to be fully functional without duplicating handler logic.
|
||||
// Alternatively, I can implement the handlers here using mutations.
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 overflow-y-auto h-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-muted rounded-xl">
|
||||
<CalendarOff className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t('unscheduled.title')}</h1>
|
||||
<p className="text-muted-foreground">{tasks.length} {t('taskList.taskCount', { count: tasks.length })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-[50vh] text-center space-y-4">
|
||||
<div className="p-6 bg-muted/30 rounded-full">
|
||||
<CalendarOff className="w-12 h-12 text-muted-foreground/50" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{t('unscheduled.empty')}</h3>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto mt-2">
|
||||
{t('taskList.noUnscheduledTasks')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{tasks.map(task => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={(status) => onUpdate(task.id, { status })}
|
||||
onDelete={() => onDelete(task.id)}
|
||||
onUpdate={(updates) => onUpdate(task.id, updates)}
|
||||
onEdit={() => onSelect(task)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck, Share2, Server, Copy, Settings as SettingsIcon } from 'lucide-react';
|
||||
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck, Share2, Server, Copy, Settings as SettingsIcon, Bell } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ShareAccessModal } from '@/components/ShareAccessModal';
|
||||
import { ChangePasswordModal } from '@/components/ChangePasswordModal';
|
||||
@@ -17,6 +17,39 @@ import { User } from '@shared/schema';
|
||||
import { queryClient, apiRequest } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useLocation } from "wouter";
|
||||
import { useNotifications } from '@/hooks/use-notifications';
|
||||
|
||||
const NotificationSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const { enabled, toggleEnabled, permission, requestPermission } = useNotifications({ poll: false });
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
if (checked && permission !== 'granted') {
|
||||
requestPermission();
|
||||
} else {
|
||||
toggleEnabled(checked);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium">{t('notifications.enableBrowser')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{permission === 'denied' ?
|
||||
<span className="text-destructive">Permission denied by browser. Please reset site permissions.</span> :
|
||||
t('notifications.description')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={permission === 'denied'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsProps {
|
||||
onNavigateToTemplates: () => void;
|
||||
@@ -228,6 +261,22 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Notifications Settings */}
|
||||
<Card data-testid="card-notifications">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="w-5 h-5" />
|
||||
{t('notifications.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('notifications.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<NotificationSettings />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Social & Privacy */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
Reference in New Issue
Block a user