feat: Implement AI Chat Agent, Email Notifications, and UI enhancements
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-12-12 08:35:48 +01:00
parent ccfb674318
commit 5d8976b1cd
58 changed files with 7867 additions and 844 deletions
+124
View File
@@ -0,0 +1,124 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardFooter, CardHeader } from '@/components/ui/card';
import { Bot, X, Send, Sparkles, Loader2 } from 'lucide-react';
import { apiRequest } from '@/lib/queryClient';
import { cn } from '@/lib/utils';
interface Message {
role: 'user' | 'assistant';
content: string;
}
export function AiChat() {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [input, setInput] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const mutation = useMutation({
mutationFn: async (msgs: Message[]) => {
const res = await apiRequest("POST", "/api/ai/chat", { messages: msgs });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed");
}
return res.json();
},
onSuccess: (data) => {
setMessages(prev => [...prev, data]);
},
onError: (err: Error) => {
setMessages(prev => [...prev, { role: 'assistant', content: t('ai.error') + ": " + err.message }]);
}
});
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, isOpen, mutation.isPending]);
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
if (!input.trim() || mutation.isPending) return;
const newMsgs: Message[] = [...messages, { role: 'user', content: input }];
setMessages(newMsgs);
setInput('');
mutation.mutate(newMsgs);
};
if (!isOpen) {
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"
size="icon"
>
<Sparkles className="h-6 w-6 text-white animate-pulse" />
</Button>
);
}
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">
<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" />
{t('ai.title')}
</div>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsOpen(false)}>
<X className="w-4 h-4" />
</Button>
</CardHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4" ref={scrollRef}>
{messages.length === 0 && (
<div className="text-center text-muted-foreground mt-10">
<Bot className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t('ai.welcome')}</p>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={cn("flex w-full", msg.role === 'user' ? "justify-end" : "justify-start")}>
<div className={cn(
"max-w-[80%] rounded-2xl px-4 py-2 text-sm shadow-sm whitespace-pre-wrap",
msg.role === 'user'
? "bg-primary text-primary-foreground rounded-br-none"
: "bg-muted text-foreground rounded-bl-none"
)}>
{msg.content}
</div>
</div>
))}
{mutation.isPending && (
<div className="flex w-full justify-start">
<div className="bg-muted rounded-2xl rounded-bl-none px-4 py-2 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-xs text-muted-foreground">{t('ai.thinking')}</span>
</div>
</div>
)}
</div>
<CardFooter className="p-3 border-t bg-background shrink-0">
<form onSubmit={handleSubmit} className="flex w-full gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t('ai.placeholder')}
className="bg-muted/50 focus-visible:ring-primary/50"
/>
<Button type="submit" size="icon" disabled={mutation.isPending || !input.trim()}>
<Send className="w-4 h-4" />
</Button>
</form>
</CardFooter>
</Card>
);
}
+21 -11
View File
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Home, Calendar, List, LayoutGrid, Settings, CheckSquare, Target, Trophy, LogOut, Award } from 'lucide-react';
import { Button } from "@/components/ui/button";
import {
Sidebar,
SidebarContent,
@@ -96,21 +97,30 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
{state !== 'collapsed' && user && (
<GamificationBar xp={user.xp} level={user.level} streak={user.currentStreak} />
)}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
<div className={`p-4 ${state === 'collapsed'
? 'flex flex-col items-center justify-center gap-4'
: 'grid grid-cols-3 items-center'
}`}>
<div className={state === 'collapsed' ? '' : 'justify-self-start'}>
<ThemeToggle />
</div>
<div className={state === 'collapsed' ? '' : 'justify-self-center'}>
<Button
variant="ghost"
size="icon"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
title="Logout"
>
<LogOut className={state === 'collapsed' ? 'size-5' : 'size-4'} />
{state !== 'collapsed' && <span className="font-medium ml-2">Logout</span>}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<div className={`p-4 flex items-center ${state === 'collapsed' ? 'justify-center flex-col gap-4' : 'justify-between'}`}>
<ThemeToggle />
<SidebarTrigger className={state === 'collapsed' ? '' : 'ml-auto'} />
<LogOut className="size-4" />
</Button>
</div>
<div className={state === 'collapsed' ? '' : 'justify-self-end'}>
<SidebarTrigger />
</div>
</div>
</SidebarFooter>
<SidebarRail />
@@ -0,0 +1,152 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation } from '@tanstack/react-query';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, "auth.currentPasswordRequired"),
newPassword: z.string().min(6, "auth.passwordMinLength"),
confirmNewPassword: z.string(),
}).refine((data) => data.newPassword === data.confirmNewPassword, {
message: "validation.passwordMatch",
path: ["confirmNewPassword"],
});
type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;
interface ChangePasswordModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ChangePasswordModal({ open, onOpenChange }: ChangePasswordModalProps) {
const { t } = useTranslation();
const { toast } = useToast();
const form = useForm<ChangePasswordFormValues>({
resolver: zodResolver(changePasswordSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmNewPassword: '',
},
});
const mutation = useMutation({
mutationFn: async (data: ChangePasswordFormValues) => {
const res = await apiRequest("PATCH", "/api/user/password", {
currentPassword: data.currentPassword,
newPassword: data.newPassword,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || "Failed to change password");
}
return res.json();
},
onSuccess: () => {
toast({ title: t('auth.passwordChangedSuccess') });
form.reset();
onOpenChange(false);
},
onError: (error: Error) => {
// If error mentions current password, set field error
if (error.message.toLowerCase().includes("current password")) {
form.setError("currentPassword", { message: t('auth.incorrectCurrentPassword') });
} else {
toast({
title: t('auth.changePasswordFailed'),
description: error.message,
variant: "destructive",
});
}
},
});
const onSubmit = (data: ChangePasswordFormValues) => {
mutation.mutate(data);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('auth.changePassword')}</DialogTitle>
<DialogDescription>
{t('auth.changePasswordDesc')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('auth.currentPassword')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage>
{form.formState.errors.currentPassword?.message && t(form.formState.errors.currentPassword.message)}
</FormMessage>
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('auth.newPassword')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage>
{form.formState.errors.newPassword?.message && t(form.formState.errors.newPassword.message)}
</FormMessage>
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmNewPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('auth.confirmPassword')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage>
{form.formState.errors.confirmNewPassword?.message && t(form.formState.errors.confirmNewPassword.message)}
</FormMessage>
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? t('auth.changing') : t('auth.changePassword')}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
+240 -222
View File
@@ -34,7 +34,11 @@ const convertProjectTaskToTask = (projectTask: ProjectTask, projectId: string):
isTracking: false,
projectId: projectId,
notes: projectTask.notes || null,
labelId: projectTask.labelId || null
labelId: projectTask.labelId || null,
energyLevel: 'medium',
estimatedDuration: (projectTask.estimatedHours || 0) * 60,
dependencies: [],
userId: null
};
};
@@ -121,8 +125,8 @@ const defaultProjects: Project[] = [
}
];
export default function ProjectTemplate({
onCreateFromTemplate,
export default function ProjectTemplate({
onCreateFromTemplate,
onCreateTemplate,
onCreateTasks,
onNavigateToSettings
@@ -143,33 +147,33 @@ export default function ProjectTemplate({
const [clearHistory, setClearHistory] = useState(false);
const [isEndDateCalendarOpen, setIsEndDateCalendarOpen] = useState(false);
const [closedProjectsFilter, setClosedProjectsFilter] = useState('');
// Task management state
const [projectTasks, setProjectTasks] = useState<ProjectTask[]>([]);
const [isEditingTask, setIsEditingTask] = useState(false);
const [editingTaskIndex, setEditingTaskIndex] = useState<number | null>(null);
const [taskForm, setTaskForm] = useState<Partial<ProjectTask>>({});
// Label management state
const [isLabelDialogOpen, setIsLabelDialogOpen] = useState(false);
const [editingLabel, setEditingLabel] = useState<Label | null>(null);
const [labelName, setLabelName] = useState('');
const [labelColor, setLabelColor] = useState('#3B82F6');
// Task details modal state
const [isTaskDetailsOpen, setIsTaskDetailsOpen] = useState(false);
const [selectedTaskForDetails, setSelectedTaskForDetails] = useState<ProjectTask | null>(null);
const queryClient = useQueryClient();
// Fetch labels
const { data: labels = [], isLoading: labelsLoading } = useQuery<Label[]>({
queryKey: ['/api/labels'],
});
// Create label mutation
const createLabelMutation = useMutation({
mutationFn: (data: { name: string; color: string }) =>
mutationFn: (data: { name: string; color: string }) =>
apiRequest('POST', '/api/labels', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
@@ -179,7 +183,7 @@ export default function ProjectTemplate({
setEditingLabel(null);
}
});
// Update label mutation
const updateLabelMutation = useMutation({
mutationFn: ({ id, ...data }: { id: string; name?: string; color?: string }) =>
@@ -192,7 +196,7 @@ export default function ProjectTemplate({
setEditingLabel(null);
}
});
// Delete label mutation
const deleteLabelMutation = useMutation({
mutationFn: (id: string) => apiRequest('DELETE', `/api/labels/${id}`),
@@ -213,19 +217,19 @@ export default function ProjectTemplate({
plannedEndDate: newPlannedEndDate,
createdAt: new Date()
};
// Add project to projects list
setProjects(currentProjects => [...currentProjects, newProject]);
// Convert project tasks to global tasks and add them to global state
if (projectTasks.length > 0 && onCreateTasks) {
const globalTasks = projectTasks.map(task =>
const globalTasks = projectTasks.map(task =>
convertProjectTaskToTask(task, newProjectId)
);
onCreateTasks(globalTasks);
console.log('Added', globalTasks.length, 'tasks to global state for project:', newProject.name);
}
// Reset form state
setNewProjectName('');
setNewProjectDescription('');
@@ -249,7 +253,7 @@ export default function ProjectTemplate({
};
const handleProjectStatusChange = (projectId: string, newStatus: Project['status']) => {
setProjects(currentProjects => currentProjects.map(project =>
setProjects(currentProjects => currentProjects.map(project =>
project.id === projectId ? { ...project, status: newStatus } : project
));
};
@@ -282,15 +286,15 @@ export default function ProjectTemplate({
if (restartingProject && newEndDate) {
// Create a new project as a duplicate/copy of the closed project
const newProjectId = Date.now().toString();
const duplicatedTasks = clearHistory
? []
const duplicatedTasks = clearHistory
? []
: restartingProject.tasks.map(task => ({
...task,
id: `${newProjectId}-task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // Generate new task ID
status: 'todo' as const // Reset all tasks to todo status
}));
...task,
id: `${newProjectId}-task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // Generate new task ID
status: 'todo' as const // Reset all tasks to todo status
}));
const duplicatedProject: Project = {
...restartingProject,
id: newProjectId, // New unique ID
@@ -301,19 +305,19 @@ export default function ProjectTemplate({
createdAt: new Date(), // Set new creation date
tasks: duplicatedTasks
};
// Add the new project to the projects array without removing the original
setProjects(currentProjects => [...currentProjects, duplicatedProject]);
// Convert project tasks to global tasks and add them to global state
if (duplicatedTasks.length > 0 && onCreateTasks) {
const globalTasks = duplicatedTasks.map(task =>
const globalTasks = duplicatedTasks.map(task =>
convertProjectTaskToTask(task, newProjectId)
);
onCreateTasks(globalTasks);
console.log('Added', globalTasks.length, 'tasks to global state for duplicated project:', duplicatedProject.name);
}
setIsRestartProjectOpen(false);
setRestartingProject(null);
setNewEndDate(undefined);
@@ -325,7 +329,7 @@ export default function ProjectTemplate({
const getFilteredClosedProjects = () => {
const closedProjects = projects.filter(project => project.status === 'finished');
if (!closedProjectsFilter) return closedProjects;
return closedProjects.filter(project =>
project.name.toLowerCase().includes(closedProjectsFilter.toLowerCase()) ||
(project.description && project.description.toLowerCase().includes(closedProjectsFilter.toLowerCase()))
@@ -343,7 +347,7 @@ export default function ProjectTemplate({
estimatedHours: taskForm.estimatedHours || 0,
labelId: taskForm.labelId
};
setProjectTasks([...projectTasks, newTask]);
setTaskForm({});
setIsEditingTask(false);
@@ -359,10 +363,10 @@ export default function ProjectTemplate({
if (editingTaskIndex !== null) {
const currentTask = projectTasks[editingTaskIndex];
const updatedTasks = [...projectTasks];
// Check if this is a new task (temporary ID) or existing task
const isNewTask = currentTask.id.startsWith('temp-');
updatedTasks[editingTaskIndex] = {
id: isNewTask ? Date.now().toString() : currentTask.id,
title: taskForm.title || 'New Task',
@@ -372,10 +376,10 @@ export default function ProjectTemplate({
estimatedHours: taskForm.estimatedHours || 0,
labelId: taskForm.labelId
};
setProjectTasks(updatedTasks);
}
setTaskForm({});
setIsEditingTask(false);
setEditingTaskIndex(null);
@@ -394,7 +398,7 @@ export default function ProjectTemplate({
setProjectTasks(updatedTasks);
}
}
setTaskForm({});
setIsEditingTask(false);
setEditingTaskIndex(null);
@@ -410,7 +414,7 @@ export default function ProjectTemplate({
status: 'todo',
estimatedHours: 0
};
const newTasks = [...projectTasks, tempTask];
setProjectTasks(newTasks);
setEditingTaskIndex(newTasks.length - 1);
@@ -452,9 +456,9 @@ export default function ProjectTemplate({
if (projectTaskIndex !== -1) {
updatedProject.tasks[projectTaskIndex] = updatedTask;
setSelectedProject(updatedProject);
// Update the projects array
setProjects(currentProjects => currentProjects.map(p =>
setProjects(currentProjects => currentProjects.map(p =>
p.id === selectedProject.id ? updatedProject : p
));
}
@@ -467,36 +471,36 @@ export default function ProjectTemplate({
...selectedProject,
tasks: projectTasks
};
setProjects(currentProjects => currentProjects.map(p =>
setProjects(currentProjects => currentProjects.map(p =>
p.id === selectedProject.id ? updatedProject : p
));
setSelectedProject(null);
setProjectTasks([]);
setIsEditProjectOpen(false);
console.log('Updated project:', updatedProject.name, 'with', projectTasks.length, 'tasks');
}
};
// Label management functions
const handleSaveLabel = () => {
if (!labelName.trim()) return;
if (editingLabel) {
updateLabelMutation.mutate({ id: editingLabel.id, name: labelName.trim(), color: labelColor });
} else {
createLabelMutation.mutate({ name: labelName.trim(), color: labelColor });
}
};
const handleEditLabel = (label: Label) => {
setEditingLabel(label);
setLabelName(label.name);
setLabelColor(label.color);
setIsLabelDialogOpen(true);
};
const handleDeleteLabel = (id: string) => {
if (confirm(t('projectTemplate.deleteConfirm'))) {
deleteLabelMutation.mutate(id);
@@ -542,7 +546,7 @@ export default function ProjectTemplate({
{t('projectTemplate.title')}
</h2>
</div>
<Button
<Button
onClick={() => setIsCreateProjectOpen(true)}
data-testid="button-create-project"
size="lg"
@@ -564,7 +568,7 @@ export default function ProjectTemplate({
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 sm:gap-4 lg:gap-6">
{projectColumns.map((column) => {
const columnProjects = getProjectsByStatus(column.status);
return (
<Card
key={column.id}
@@ -581,7 +585,7 @@ export default function ProjectTemplate({
{columnProjects.length}
</Badge>
</div>
<div className="space-y-2 sm:space-y-3 min-h-[200px] sm:min-h-[300px]">
{columnProjects.map((project) => (
<Card
@@ -589,9 +593,8 @@ export default function ProjectTemplate({
draggable
onDragStart={() => handleDragStart(project.id)}
onDragEnd={handleDragEnd}
className={`p-3 sm:p-4 cursor-move hover-elevate transition-all ${
draggedProject === project.id ? 'opacity-50 scale-95' : ''
}`}
className={`p-3 sm:p-4 cursor-move hover-elevate transition-all ${draggedProject === project.id ? 'opacity-50 scale-95' : ''
}`}
data-testid={`project-card-${project.id}`}
>
<div className="space-y-3">
@@ -606,7 +609,7 @@ export default function ProjectTemplate({
</p>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
@@ -662,7 +665,7 @@ export default function ProjectTemplate({
</div>
</Card>
))}
{columnProjects.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8 border-2 border-dashed border-muted rounded-lg">
No projects in {column.title.toLowerCase()}
@@ -686,7 +689,7 @@ export default function ProjectTemplate({
data-testid="input-filter-closed-projects"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{getFilteredClosedProjects().map((project) => (
<Card key={project.id} className="p-4 hover-elevate" data-testid={`closed-project-card-${project.id}`}>
@@ -702,7 +705,7 @@ export default function ProjectTemplate({
</p>
)}
</div>
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">
Finished
</Badge>
@@ -739,7 +742,7 @@ export default function ProjectTemplate({
</div>
</Card>
))}
{getFilteredClosedProjects().length === 0 && (
<div className="col-span-full text-center text-muted-foreground text-sm py-8">
{closedProjectsFilter ? 'No closed projects match your filter' : 'No closed projects yet'}
@@ -763,7 +766,7 @@ export default function ProjectTemplate({
{restartingProject.tasks.length} tasks {getTotalEstimatedHours(restartingProject)}h estimated
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Planned End Date</label>
<Popover open={isEndDateCalendarOpen} onOpenChange={setIsEndDateCalendarOpen}>
@@ -846,7 +849,7 @@ export default function ProjectTemplate({
<DialogHeader>
<DialogTitle>{t('projectTemplate.createProject')}</DialogTitle>
<DialogDescription>
{t('projectTemplate.description')}
{t('projectTemplate.pageDescription')}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
@@ -972,7 +975,7 @@ export default function ProjectTemplate({
</div>
</Card>
))}
{projectTasks.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8 border-2 border-dashed border-muted rounded-lg">
{t('projectTemplate.noLabelsDescription')}
@@ -987,34 +990,34 @@ export default function ProjectTemplate({
<h4 className="font-semibold text-sm">
{editingTaskIndex !== null ? t('projectTemplate.edit') : t('projectTemplate.addTask')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.taskTitle')}</label>
<Input
value={taskForm.title || ''}
onChange={(e) => setTaskForm({...taskForm, title: e.target.value})}
onChange={(e) => setTaskForm({ ...taskForm, title: e.target.value })}
placeholder={t('projectTemplate.enterTaskTitle')}
data-testid="input-task-title"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.description')}</label>
<Input
value={taskForm.description || ''}
onChange={(e) => setTaskForm({...taskForm, description: e.target.value})}
onChange={(e) => setTaskForm({ ...taskForm, description: e.target.value })}
placeholder={t('projectTemplate.enterTaskDescription')}
data-testid="input-task-description"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.priority')}</label>
<Select
value={taskForm.priority || 'medium'}
onValueChange={(value: ProjectTask['priority']) =>
setTaskForm({...taskForm, priority: value})
onValueChange={(value: ProjectTask['priority']) =>
setTaskForm({ ...taskForm, priority: value })
}
>
<SelectTrigger data-testid="select-task-priority">
@@ -1027,13 +1030,13 @@ export default function ProjectTemplate({
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.status')}</label>
<Select
value={taskForm.status || 'todo'}
onValueChange={(value: ProjectTask['status']) =>
setTaskForm({...taskForm, status: value})
onValueChange={(value: ProjectTask['status']) =>
setTaskForm({ ...taskForm, status: value })
}
>
<SelectTrigger data-testid="select-task-status">
@@ -1046,20 +1049,20 @@ export default function ProjectTemplate({
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.estimatedHours')}</label>
<Input
type="number"
value={taskForm.estimatedHours || ''}
onChange={(e) => setTaskForm({...taskForm, estimatedHours: Number(e.target.value)})}
onChange={(e) => setTaskForm({ ...taskForm, estimatedHours: Number(e.target.value) })}
placeholder="0"
min="0"
data-testid="input-task-hours"
/>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
@@ -1145,7 +1148,7 @@ export default function ProjectTemplate({
<label className="text-sm font-medium">{t('projectTemplate.status')}</label>
<Select
value={selectedProject.status}
onValueChange={(value: Project['status']) =>
onValueChange={(value: Project['status']) =>
setSelectedProject({ ...selectedProject, status: value })
}
>
@@ -1172,8 +1175,8 @@ export default function ProjectTemplate({
data-testid="button-edit-planned-end-date"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedProject.plannedEndDate
? selectedProject.plannedEndDate.toLocaleDateString()
{selectedProject.plannedEndDate
? selectedProject.plannedEndDate.toLocaleDateString()
: t('projectTemplate.selectPlannedEndDate')}
</Button>
</PopoverTrigger>
@@ -1230,30 +1233,30 @@ export default function ProjectTemplate({
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.taskTitle')}</label>
<Input
value={taskForm.title || ''}
onChange={(e) => setTaskForm({...taskForm, title: e.target.value})}
onChange={(e) => setTaskForm({ ...taskForm, title: e.target.value })}
placeholder={t('projectTemplate.enterTaskTitle')}
className="text-sm"
data-testid={`input-inline-task-title-${index}`}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.description')}</label>
<Input
value={taskForm.description || ''}
onChange={(e) => setTaskForm({...taskForm, description: e.target.value})}
onChange={(e) => setTaskForm({ ...taskForm, description: e.target.value })}
placeholder={t('projectTemplate.enterTaskDescription')}
className="text-sm"
data-testid={`input-inline-task-description-${index}`}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.priority')}</label>
<Select
value={taskForm.priority || 'medium'}
onValueChange={(value: ProjectTask['priority']) =>
setTaskForm({...taskForm, priority: value})
onValueChange={(value: ProjectTask['priority']) =>
setTaskForm({ ...taskForm, priority: value })
}
>
<SelectTrigger className="text-sm" data-testid={`select-inline-task-priority-${index}`}>
@@ -1266,13 +1269,13 @@ export default function ProjectTemplate({
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.status')}</label>
<Select
value={taskForm.status || 'todo'}
onValueChange={(value: ProjectTask['status']) =>
setTaskForm({...taskForm, status: value})
onValueChange={(value: ProjectTask['status']) =>
setTaskForm({ ...taskForm, status: value })
}
>
<SelectTrigger className="text-sm" data-testid={`select-inline-task-status-${index}`}>
@@ -1285,13 +1288,13 @@ export default function ProjectTemplate({
</SelectContent>
</Select>
</div>
<div className="space-y-1 md:col-span-2">
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.estimatedHours')}</label>
<Input
type="number"
value={taskForm.estimatedHours || ''}
onChange={(e) => setTaskForm({...taskForm, estimatedHours: Number(e.target.value)})}
onChange={(e) => setTaskForm({ ...taskForm, estimatedHours: Number(e.target.value) })}
placeholder="0"
min="0"
className="text-sm max-w-32"
@@ -1299,7 +1302,7 @@ export default function ProjectTemplate({
/>
</div>
</div>
<div className="flex items-center gap-2 justify-end pt-2 border-t">
<Button
variant="outline"
@@ -1322,8 +1325,8 @@ export default function ProjectTemplate({
) : (
// Display Mode
<div className="flex items-center justify-between">
<div
className="flex-1 cursor-pointer hover-elevate rounded-md p-2 -m-2"
<div
className="flex-1 cursor-pointer hover-elevate rounded-md p-2 -m-2"
onClick={() => handleOpenTaskDetails(task)}
data-testid={`clickable-task-${index}`}
>
@@ -1398,7 +1401,7 @@ export default function ProjectTemplate({
)}
</Card>
))}
{projectTasks.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8 border-2 border-dashed border-muted rounded-lg">
{t('projectTemplate.noTasksInProject')}
@@ -1436,150 +1439,165 @@ export default function ProjectTemplate({
</Dialog>
<TabsContent value="labels" className="space-y-6">
{/* Labels Section Header */}
<div className="flex items-center justify-between">
<h3 className="text-md font-medium">{t('settings.labels.title')}</h3>
<Dialog open={isLabelDialogOpen} onOpenChange={setIsLabelDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" data-testid="button-create-label">
<Plus className="w-4 h-4 mr-2" />
{t('projectTemplate.createLabel')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingLabel ? t('projectTemplate.editLabel') : t('projectTemplate.createNewLabel')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Input
placeholder={t('projectTemplate.labelNamePlaceholder')}
value={labelName}
onChange={(e) => setLabelName(e.target.value)}
data-testid="input-label-name"
/>
</div>
<div>
<div className="flex items-center gap-3">
<input
type="color"
value={labelColor}
onChange={(e) => setLabelColor(e.target.value)}
className="w-12 h-8 rounded border cursor-pointer"
data-testid="input-label-color"
/>
<Input
value={labelColor}
onChange={(e) => setLabelColor(e.target.value)}
placeholder="#3B82F6"
className="flex-1"
data-testid="input-label-color-text"
/>
{/* Labels Section Header */}
<div className="flex items-center justify-between">
<h3 className="text-md font-medium">{t('settings.labels.title')}</h3>
<Dialog open={isLabelDialogOpen} onOpenChange={setIsLabelDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" data-testid="button-create-label">
<Plus className="w-4 h-4 mr-2" />
{t('projectTemplate.createLabel')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingLabel ? t('projectTemplate.editLabel') : t('projectTemplate.createNewLabel')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Input
placeholder={t('projectTemplate.labelNamePlaceholder')}
value={labelName}
onChange={(e) => setLabelName(e.target.value)}
data-testid="input-label-name"
/>
</div>
<div>
<div className="flex items-center gap-3">
<input
type="color"
value={labelColor}
onChange={(e) => setLabelColor(e.target.value)}
className="w-12 h-8 rounded border cursor-pointer"
data-testid="input-label-color"
/>
<Input
value={labelColor}
onChange={(e) => setLabelColor(e.target.value)}
placeholder="#3B82F6"
className="flex-1"
data-testid="input-label-color-text"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={() => {
setIsLabelDialogOpen(false);
setEditingLabel(null);
setLabelName('');
setLabelColor('#3B82F6');
}}
className="flex-1"
data-testid="button-cancel-label"
>
{t('projectTemplate.cancel')}
</Button>
<Button
onClick={handleSaveLabel}
disabled={!labelName.trim() || createLabelMutation.isPending || updateLabelMutation.isPending}
className="flex-1"
data-testid="button-save-label"
>
{editingLabel ? t('projectTemplate.update') : t('projectTemplate.create')}
</Button>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={() => {
setIsLabelDialogOpen(false);
setEditingLabel(null);
setLabelName('');
setLabelColor('#3B82F6');
}}
className="flex-1"
data-testid="button-cancel-label"
>
{t('projectTemplate.cancel')}
</Button>
<Button
onClick={handleSaveLabel}
disabled={!labelName.trim() || createLabelMutation.isPending || updateLabelMutation.isPending}
className="flex-1"
data-testid="button-save-label"
>
{editingLabel ? t('projectTemplate.update') : t('projectTemplate.create')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</DialogContent>
</Dialog>
</div>
{/* Labels List */}
{labelsLoading ? (
<div className="flex items-center justify-center p-8">
<div className="text-muted-foreground">{t('projectTemplate.loadingLabels')}</div>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{labels.map((label) => (
<Card
key={label.id}
className="p-4 hover-elevate active-elevate-2"
style={{ borderLeft: `4px solid ${label.color}` }}
data-testid={`label-${label.id}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: label.color }}
/>
<span className="font-medium text-sm" data-testid={`text-label-name-${label.id}`}>
{label.name}
</span>
</div>
<div className="flex items-center gap-1">
{/* Labels List */}
{labelsLoading ? (
<div className="flex items-center justify-center p-8">
<div className="text-muted-foreground">{t('projectTemplate.loadingLabels')}</div>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{labels.map((label) => (
<Card
key={label.id}
className="p-4 hover-elevate active-elevate-2"
style={{ borderLeft: `4px solid ${label.color}` }}
data-testid={`label-${label.id}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: label.color }}
/>
<span className="font-medium text-sm" data-testid={`text-label-name-${label.id}`}>
{label.name}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleEditLabel(label)}
className="w-6 h-6"
data-testid={`button-edit-${label.id}`}
>
<Edit className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteLabel(label.id)}
className="w-6 h-6 text-destructive hover:text-destructive"
data-testid={`button-delete-${label.id}`}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
</Card>
))}
{labels.length === 0 && (
<div className="col-span-full flex flex-col items-center justify-center p-8 text-center">
<Tag className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="font-medium text-muted-foreground mb-2">{t('projectTemplate.noLabelsYet')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('projectTemplate.noLabelsDescription')}
</p>
<Button
variant="ghost"
size="icon"
onClick={() => handleEditLabel(label)}
className="w-6 h-6"
data-testid={`button-edit-${label.id}`}
variant="outline"
onClick={() => setIsLabelDialogOpen(true)}
data-testid="button-create-first-label"
>
<Edit className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteLabel(label.id)}
className="w-6 h-6 text-destructive hover:text-destructive"
data-testid={`button-delete-${label.id}`}
>
<Trash2 className="w-3 h-3" />
<Plus className="w-4 h-4 mr-2" />
{t('projectTemplate.createLabel')}
</Button>
</div>
</div>
</Card>
))}
{labels.length === 0 && (
<div className="col-span-full flex flex-col items-center justify-center p-8 text-center">
<Tag className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="font-medium text-muted-foreground mb-2">{t('projectTemplate.noLabelsYet')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('projectTemplate.noLabelsDescription')}
</p>
<Button
variant="outline"
onClick={() => setIsLabelDialogOpen(true)}
data-testid="button-create-first-label"
>
<Plus className="w-4 h-4 mr-2" />
{t('projectTemplate.createLabel')}
</Button>
)}
</div>
)}
</div>
)}
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
{/* Task Details Modal */}
<TaskDetailsModal
isOpen={isTaskDetailsOpen}
onClose={handleCloseTaskDetails}
task={selectedTaskForDetails}
onSave={handleSaveTaskDetails}
task={selectedTaskForDetails ? convertProjectTaskToTask(selectedTaskForDetails, selectedProject?.id || 'temp') : null}
onSave={(updatedTask) => {
// Convert back to ProjectTask
const projectTask: ProjectTask = {
id: selectedTaskForDetails?.id || updatedTask.id, // Keep original ID if possible
title: updatedTask.title,
description: updatedTask.description || undefined,
status: updatedTask.status as any,
priority: updatedTask.priority as any,
estimatedHours: updatedTask.estimatedDuration ? Math.round(updatedTask.estimatedDuration / 60) : 0,
labelId: updatedTask.labelId || undefined,
notes: updatedTask.notes || undefined,
timeTracked: updatedTask.timeTracked,
timeEntries: [] // Simplify for now
};
handleSaveTaskDetails(projectTask);
}}
labels={labels}
/>
</div>
+123
View File
@@ -0,0 +1,123 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { User } from "@shared/schema";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Loader2, Share2, Search, Users } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useTranslation } from "react-i18next";
interface ShareAccessModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ShareAccessModal({ open, onOpenChange }: ShareAccessModalProps) {
const { toast } = useToast();
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const { data: users, isLoading } = useQuery<Pick<User, "id" | "username">[]>({
queryKey: ["/api/users/search", debouncedQuery],
queryFn: async () => {
if (debouncedQuery.length < 2) return [];
const res = await fetch(`/api/users/search?q=${encodeURIComponent(debouncedQuery)}`);
if (!res.ok) throw new Error("Failed to search users");
return res.json();
},
enabled: debouncedQuery.length >= 2,
});
const shareMutation = useMutation({
mutationFn: async (targetUserId: string) => {
const res = await apiRequest("POST", `/api/users/share-all`, { targetUserId });
return res.json();
},
onSuccess: () => {
toast({ title: t('share.successAccess'), description: t('share.successAccessDesc') });
onOpenChange(false);
},
onError: () => {
toast({ title: t('share.errorAccess'), variant: "destructive" });
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
{t('share.allTasksTitle')}
</DialogTitle>
<DialogDescription>
{t('share.allTasksDesc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('share.searchPlaceholder')}
className="pl-9"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
// Simple manual debounce simulation for UI response, real query uses effect or direct set
// For consistency with ShareTaskModal, we'll just set it
}}
onKeyUp={() => setDebouncedQuery(searchQuery)}
/>
</div>
<ScrollArea className="h-[200px] rounded-md border p-2">
{isLoading && debouncedQuery.length >= 2 ? (
<div className="flex justify-center p-4">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : users && users.length > 0 ? (
<div className="space-y-2">
{users.map((user) => (
<div key={user.id} className="flex items-center justify-between p-2 hover:bg-muted/50 rounded-lg transition-colors">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>{user.username.substring(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="font-medium">{user.username}</span>
</div>
<Button
size="sm"
onClick={() => shareMutation.mutate(user.id)}
disabled={shareMutation.isPending}
>
{shareMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Share2 className="w-4 h-4 mr-2" />
)}
{t('share.grantAccess')}
</Button>
</div>
))}
</div>
) : debouncedQuery.length >= 2 ? (
<div className="text-center p-4 text-sm text-muted-foreground">
{t('share.noUsers')}
</div>
) : (
<div className="text-center p-4 text-sm text-muted-foreground">
{t('share.typeToSearch')}
</div>
)}
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}
+170
View File
@@ -0,0 +1,170 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@shared/schema';
import { queryClient, apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { X, UserPlus, Users } from 'lucide-react';
interface ShareLabelModalProps {
label: Label | null;
open: boolean;
onOpenChange: (open: boolean) => void;
currentUser: any;
}
interface LabelShare {
userId: string;
username: string;
permission: 'read' | 'write' | 'admin';
}
export function ShareLabelModal({ label, open, onOpenChange, currentUser }: ShareLabelModalProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [newUsername, setNewUsername] = useState('');
const [newPermission, setNewPermission] = useState<'read' | 'write'>('read');
const { data: shares = [], isLoading } = useQuery<LabelShare[]>({
queryKey: [`/api/labels/${label?.id}/share`],
enabled: !!label && open,
});
const shareMutation = useMutation({
mutationFn: (data: { username: string; permission: string }) =>
apiRequest('POST', `/api/labels/${label?.id}/share`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [`/api/labels/${label?.id}/share`] });
setNewUsername('');
toast({ title: t('settings.labels.shareSuccess') });
},
onError: (e: Error) => {
toast({
title: t('settings.labels.shareFailed'),
description: e.message,
variant: 'destructive'
});
}
});
const unshareMutation = useMutation({
mutationFn: (userId: string) =>
apiRequest('DELETE', `/api/labels/${label?.id}/share/${userId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [`/api/labels/${label?.id}/share`] });
toast({ title: t('settings.labels.unshareSuccess') });
},
});
const handleShare = (e: React.FormEvent) => {
e.preventDefault();
if (!newUsername.trim()) return;
shareMutation.mutate({ username: newUsername, permission: newPermission });
};
if (!label) return null;
// Only creator can share (enforced by backend, but UI check is good too)
const isCreator = label.creatorId === currentUser?.id;
const isSystem = label.creatorId === null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
{t('settings.labels.shareLabel')}: {label.name}
</DialogTitle>
<DialogDescription>
{t('settings.labels.shareDescription')}
</DialogDescription>
</DialogHeader>
{isSystem ? (
<div className="p-4 bg-muted rounded-md text-center text-sm text-muted-foreground">
{t('settings.labels.systemLabelNoShare')}
</div>
) : !isCreator ? (
<div className="p-4 bg-yellow-500/10 text-yellow-500 border border-yellow-500/20 rounded-md text-center text-sm">
{t('settings.labels.onlyOwnerCanShare')}
<p className="text-xs text-muted-foreground mt-1">
{t('settings.labels.owner')}: {t('common.unknown')}
</p>
</div>
) : (
<div className="space-y-6">
{/* Add Collaborator Form */}
<form onSubmit={handleShare} className="flex gap-2 items-end">
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">{t('settings.labels.addCollaborator')}</label>
<div className="flex gap-2">
<Input
placeholder={t('settings.labels.usernamePlaceholder')}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
/>
<Select
value={newPermission}
onValueChange={(v: 'read' | 'write') => setNewPermission(v)}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="read">{t('settings.labels.permRead')}</SelectItem>
<SelectItem value="write">{t('settings.labels.permWrite')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button type="submit" disabled={!newUsername || shareMutation.isPending}>
<UserPlus className="w-4 h-4" />
</Button>
</form>
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground">{t('settings.labels.accessList')}</h4>
{isLoading ? (
<div className="text-sm text-center py-4">{t('common.loading')}</div>
) : shares.length === 0 ? (
<div className="text-sm text-center py-4 text-muted-foreground bg-muted/30 rounded-md border border-dashed">
{t('settings.labels.noCollaborators')}
</div>
) : (
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{shares.map((share) => (
<div key={share.userId} className="flex items-center justify-between p-2 rounded-md bg-muted/50 border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary">
{share.username.substring(0, 2).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium">{share.username}</p>
<p className="text-xs text-muted-foreground capitalize">{t(`settings.labels.perm${share.permission.charAt(0).toUpperCase() + share.permission.slice(1)}`)}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => unshareMutation.mutate(share.userId)}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
+224
View File
@@ -0,0 +1,224 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { User } from "@shared/schema";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Loader2, Share2, Check, Search } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useTranslation } from "react-i18next";
interface ShareTaskModalProps {
taskId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ShareTaskModal({ taskId, open, onOpenChange }: ShareTaskModalProps) {
const { toast } = useToast();
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [userToConfirm, setUserToConfirm] = useState<{ id: string; username: string } | null>(null);
const { data: users, isLoading: searchLoading } = useQuery<Pick<User, "id" | "username">[]>({
queryKey: ["/api/users/search", debouncedQuery],
queryFn: async () => {
if (debouncedQuery.length < 2) return [];
const res = await fetch(`/api/users/search?q=${encodeURIComponent(debouncedQuery)}`);
if (!res.ok) throw new Error("Failed to search users");
return res.json();
},
enabled: debouncedQuery.length >= 2,
});
const { data: sharedUsers, refetch: refetchShared, isLoading: sharedLoading } = useQuery<Pick<User, "id" | "username">[]>({
queryKey: [`/api/tasks/${taskId}/shared-users`], // Needs to be dependent on taskId
enabled: open, // Only fetch when open
});
const shareMutation = useMutation({
mutationFn: async (targetUserId: string) => {
const res = await apiRequest("POST", `/api/tasks/${taskId}/share`, { targetUserId });
return res.json();
},
onSuccess: () => {
toast({ title: t('share.successTask') });
setUserToConfirm(null);
setSearchQuery("");
setDebouncedQuery(""); // Clear search
refetchShared(); // Update list
},
onError: () => {
toast({ title: t('share.errorTask'), variant: "destructive" });
},
});
const unshareMutation = useMutation({
mutationFn: async (targetUserId: string) => {
const res = await apiRequest("DELETE", `/api/tasks/${taskId}/share/${targetUserId}`);
return res.json();
},
onSuccess: () => {
toast({ title: t('share.removeSuccess') });
refetchShared();
},
onError: () => {
toast({ title: t('share.errorTask'), variant: "destructive" });
},
});
// Confirmation State
const confirmShare = () => {
if (userToConfirm) {
shareMutation.mutate(userToConfirm.id);
}
};
return (
<Dialog open={open} onOpenChange={(val) => {
if (!val) {
setUserToConfirm(null); // Reset on close
setSearchQuery("");
setDebouncedQuery("");
}
onOpenChange(val);
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Share2 className="w-5 h-5" />
{t('share.taskTitle')}
</DialogTitle>
</DialogHeader>
{userToConfirm ? (
<div className="space-y-4 py-4">
<div className="bg-muted/50 p-4 rounded-lg text-center">
<Avatar className="h-16 w-16 mx-auto mb-2">
<AvatarFallback className="text-lg">{userToConfirm.username.substring(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<h3 className="font-semibold text-lg">{userToConfirm.username}</h3>
<p className="text-muted-foreground mt-2">
{t('share.confirmShareDesc', { username: userToConfirm.username })}
</p>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setUserToConfirm(null)}>
{t('share.cancel')}
</Button>
<Button onClick={confirmShare} disabled={shareMutation.isPending}>
{shareMutation.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{t('share.confirm')}
</Button>
</div>
</div>
) : (
<div className="space-y-6 py-4">
{/* Current Shared Users */}
<div>
<h4 className="text-sm font-medium mb-3">{t('share.sharedWith')}</h4>
<div className="space-y-2">
{sharedLoading ? (
<div className="flex justify-center py-2"><Loader2 className="w-4 h-4 animate-spin text-muted-foreground" /></div>
) : sharedUsers && sharedUsers.length > 0 ? (
<div className="max-h-[150px] overflow-y-auto space-y-2 pr-1">
{sharedUsers.map(user => (
<div key={user.id} className="flex items-center justify-between p-2 bg-muted/40 rounded-lg">
<div className="flex items-center gap-3">
<Avatar className="h-6 w-6">
<AvatarFallback className="text-xs">{user.username.substring(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="text-sm">{user.username}</span>
</div>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => unshareMutation.mutate(user.id)}
disabled={unshareMutation.isPending}
>
<span className="text-xs">{t('share.unshare')}</span>
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground italic pl-1">Not shared with anyone yet.</p>
)}
</div>
</div>
<div className="relative">
<div className="absolute inset-x-0 border-t my-2" />
</div>
{/* Search to Add */}
<div className="space-y-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('share.searchPlaceholder')}
className="pl-9"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
// Simple manual debounce for now
}}
onKeyUp={(e) => {
// Debounce could be improved, but sufficient
setDebouncedQuery(searchQuery);
}}
/>
</div>
<ScrollArea className="h-[180px] rounded-md border p-2">
{searchLoading && debouncedQuery.length >= 2 ? (
<div className="flex justify-center p-4">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : users && users.length > 0 ? (
<div className="space-y-1">
{users.filter(u => !sharedUsers?.find(su => su.id === u.id)).map((user) => (
<div
key={user.id}
className="flex items-center justify-between p-2 hover:bg-muted cursor-pointer rounded-lg transition-colors"
onClick={() => setUserToConfirm(user)}
>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>{user.username.substring(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="font-medium">{user.username}</span>
</div>
<Button size="icon" variant="ghost" className="h-8 w-8">
<Share2 className="w-4 h-4 text-muted-foreground" />
</Button>
</div>
))}
{users.filter(u => !sharedUsers?.find(su => su.id === u.id)).length === 0 && (
<div className="text-center p-4 text-sm text-muted-foreground">
No new users found to share with.
</div>
)}
</div>
) : debouncedQuery.length >= 2 ? (
<div className="text-center p-4 text-sm text-muted-foreground">
{t('share.noUsers')}
</div>
) : (
<div className="text-center p-4 text-sm text-muted-foreground">
{t('share.typeToSearch')}
</div>
)}
</ScrollArea>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
+138 -15
View File
@@ -17,7 +17,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle, X } from "lucide-react";
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle, X, Lock } from "lucide-react";
import { Task, Label } from '@shared/schema';
import { useQuery } from '@tanstack/react-query';
import { Checkbox } from "@/components/ui/checkbox";
@@ -43,9 +43,85 @@ interface TaskCardProps {
isDragging?: boolean;
}
import { ShareTaskModal } from './ShareTaskModal';
import { Share2, Users } from "lucide-react";
import { User } from "@shared/schema";
function SharedTaskIcon({ task }: { task: Task }) {
const { t } = useTranslation();
const { data: user } = useQuery<User>({ queryKey: ["/api/user"], retry: false });
const isOwner = user?.id === task.userId;
// If owner, fetch shared users
const { data: sharedUsers } = useQuery<any[]>({
queryKey: [`/api/tasks/${task.id}/shared-users`],
enabled: !!isOwner && !!task.id,
retry: false
});
if (!user) return null;
// Case 1: Owner and task is shared
if (isOwner && sharedUsers && sharedUsers.length > 0) {
const names = sharedUsers.map(u => u.username).join(", ");
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-indigo-100 dark:bg-indigo-900 cursor-help">
<Share2 className="w-3 h-3 text-indigo-600 dark:text-indigo-300" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('share.sharedWith')}: {names}</p>
</TooltipContent>
</Tooltip>
);
}
// Case 2: Not owner (Shared WITH me)
if (!isOwner && task.userId) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-orange-100 dark:bg-orange-900 cursor-help">
<Users className="w-3 h-3 text-orange-600 dark:text-orange-300" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('share.sharedWithMe') || "Shared with me"}</p>
</TooltipContent>
</Tooltip>
);
}
return null;
}
function SharedMenuItem({ task, onShare }: { task: Task, onShare: () => void }) {
const { t } = useTranslation();
const { data: user } = useQuery<User>({ queryKey: ["/api/user"], retry: false });
// Only owner can share
if (user?.id !== task.userId) return null;
return (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onShare();
}}
>
<Share2 className="w-4 h-4 mr-2" />
{t('share.taskTitle')}
</DropdownMenuItem>
);
}
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, onUpdate, isDragging }: TaskCardProps) {
const { t } = useTranslation();
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const handleAIMagic = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -66,6 +142,14 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
}
};
const { data: allTasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
staleTime: 5 * 60 * 1000,
});
const blockingTasks = task.dependencies?.map(dId => allTasks.find(t => t.id === dId)).filter(t => t && t.status !== 'done') || [];
const isBlocked = blockingTasks.length > 0;
// Fetch labels to get the label color
const { data: labels = [] } = useQuery<Label[]>({
queryKey: ['/api/labels'],
@@ -116,6 +200,7 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
const handleToggleComplete = (e: React.MouseEvent) => {
e.stopPropagation();
if (isBlocked) return;
if (task.status === 'done') {
onStatusChange?.('todo');
} else {
@@ -129,6 +214,10 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
const handleDragEnd = async (event: any, info: PanInfo) => {
if (info.offset.x > 100) {
if (isBlocked) {
controls.start({ x: 0 });
return;
}
// Swiped right -> Complete
triggerConfetti(0.5, 0.5);
playSuccessSound();
@@ -173,10 +262,17 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
onClick={handleToggleComplete}
data-testid={`checkbox-complete-${task.id}`}
className="mt-0.5"
disabled={isBlocked}
/>
</TooltipTrigger>
<TooltipContent>
<p>{t('taskCard.toggleComplete')}</p>
{isBlocked ? (
<p className="text-destructive font-medium">
Blocked by: {blockingTasks.map(t => t?.title).join(", ")}
</p>
) : (
<p>{t('taskCard.toggleComplete')}</p>
)}
</TooltipContent>
</Tooltip>
@@ -213,6 +309,9 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
{t(`status.${task.status}`)}
</Badge>
{/* Shared Status Icon */}
<SharedTaskIcon task={task} />
{task.dueDate && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="w-3 h-3" />
@@ -232,6 +331,20 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
{task.estimatedDuration}m
</Badge>
)}
{isBlocked && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-xs border-destructive text-destructive gap-1 animate-pulse">
<Lock className="w-3 h-3" />
Blocked
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Waiting for: {blockingTasks.map(t => t?.title).join(", ")}</p>
</TooltipContent>
</Tooltip>
)}
</div>
{(task.timeTracked > 0 || task.isTracking) && (
@@ -308,21 +421,25 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</DropdownMenuItem>
{/* Share Option - Check if owner */}
<SharedMenuItem task={task} onShare={() => setIsShareModalOpen(true)} />
<DropdownMenuSeparator />
{task.status !== 'done' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('done');
console.log(`Mark task as done: ${task.title}`);
}}
data-testid={`menu-complete-${task.id}`}
>
<CheckCircle className="w-4 h-4 mr-2" />
{t('taskCard.markAsDone')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
if (isBlocked) return;
onStatusChange?.('done');
console.log(`Mark task as done: ${task.title}`);
}}
disabled={isBlocked}
data-testid={`menu-complete-${task.id}`}
>
{isBlocked ? <Lock className="w-4 h-4 mr-2" /> : <CheckCircle className="w-4 h-4 mr-2" />}
{t('taskCard.markAsDone')}
</DropdownMenuItem>
{task.status === 'todo' && (
<DropdownMenuItem
@@ -482,6 +599,12 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
)}
</ContextMenuContent>
</ContextMenu>
<ShareTaskModal
taskId={task.id}
open={isShareModalOpen}
onOpenChange={setIsShareModalOpen}
/>
</motion.div>
);
}
+181 -87
View File
@@ -7,7 +7,10 @@ import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { CalendarIcon, Plus, Tag } from 'lucide-react';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { CalendarIcon, Plus, Tag, Check, Link2 } from 'lucide-react';
import { Task, Label } from '@shared/schema';
import { useQuery } from '@tanstack/react-query';
import { parseTaskInput } from '../lib/nlp';
@@ -27,7 +30,10 @@ 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 [labelId, setLabelId] = useState<string | undefined>();
const [dependencies, setDependencies] = useState<string[]>([]);
const [isDependenciesOpen, setIsDependenciesOpen] = useState(false);
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -37,6 +43,10 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
queryKey: ['/api/labels'],
});
const { data: tasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
});
const handleSave = () => {
if (!title.trim()) {
setError(t('taskCreation.titleRequired') || 'Title is required');
@@ -51,6 +61,7 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
estimatedDuration,
dueDate,
labelId,
dependencies,
status: 'todo',
timeTracked: 0,
isTracking: false
@@ -64,7 +75,9 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
setDescription('');
setPriority('medium');
setDueDate(undefined);
setDueDate(undefined);
setLabelId(undefined);
setDependencies([]);
setError(null);
onClose();
};
@@ -81,14 +94,16 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
setDescription('');
setPriority('medium');
setDueDate(undefined);
setDueDate(undefined);
setLabelId(undefined);
setDependencies([]);
setError(null);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md mx-4">
<Dialog open={isOpen} onOpenChange={handleClose} >
<DialogContent className="sm:max-w-md mx-4 max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="w-4 h-4" />
@@ -99,7 +114,7 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
<div className="space-y-4">
<div>
<Input
placeholder={t('taskCreation.titlePlaceholder')}
placeholder={t('smartTask.placeholder')}
value={title}
onChange={(e) => {
setTitle(e.target.value);
@@ -118,10 +133,12 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
autoFocus
/>
<div className="flex items-center gap-1 mt-1">
{title.includes('!') || title.includes('#') || title.toLowerCase().includes('tomorrow') ? (
{title.includes('!') || title.includes('#') ||
title.toLowerCase().includes('tomorrow') || title.toLowerCase().includes('morgen') ||
title.toLowerCase().includes('today') || title.toLowerCase().includes('heute') ? (
<div className="bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-300 text-xs px-2 py-0.5 rounded-full flex items-center gap-1 animate-pulse">
<Sparkles className="w-3 h-3" />
Smart Input Active
{t('taskCreation.smartInputActive')}
</div>
) : null}
{error && <p className="text-sm text-destructive">{error}</p>}
@@ -139,108 +156,185 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
/>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<Select value={priority} onValueChange={(value: 'low' | 'medium' | 'high') => setPriority(value)}>
<SelectTrigger data-testid="select-task-priority">
<SelectValue placeholder={t('taskCreation.priority')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{t('priority.low')}</SelectItem>
<SelectItem value="medium">{t('priority.medium')}</SelectItem>
<SelectItem value="high">{t('priority.high')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<Select value={priority} onValueChange={(value: 'low' | 'medium' | 'high') => setPriority(value)}>
<SelectTrigger data-testid="select-task-priority">
<SelectValue placeholder={t('taskCreation.priority')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{t('priority.low')}</SelectItem>
<SelectItem value="medium">{t('priority.medium')}</SelectItem>
<SelectItem value="high">{t('priority.high')}</SelectItem>
</SelectContent>
</Select>
<div className="flex-1">
<Select value={energyLevel} onValueChange={(value: 'low' | 'medium' | 'high') => setEnergyLevel(value)}>
<SelectTrigger>
<SelectValue placeholder="Energy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low"> Low Energy</SelectItem>
<SelectItem value="medium"> Medium Energy</SelectItem>
<SelectItem value="high"> High Energy</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={energyLevel} onValueChange={(value: 'low' | 'medium' | 'high') => setEnergyLevel(value)}>
<SelectTrigger>
<SelectValue placeholder={t('taskCreation.energy')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{t('gamification.energy.low')}</SelectItem>
<SelectItem value="medium">{t('gamification.energy.medium')}</SelectItem>
<SelectItem value="high">{t('gamification.energy.high')}</SelectItem>
</SelectContent>
</Select>
<div className="w-24">
<div className="col-span-1">
<Input
type="number"
placeholder="Min"
placeholder={t('taskCreation.minutesPlaceholder')}
value={estimatedDuration || ''}
onChange={(e) => setEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
className="text-sm"
/>
</div>
<div className="flex-1">
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
<SelectTrigger data-testid="select-task-label">
<SelectValue placeholder={t('taskCreation.label')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('taskCreation.noLabel')}</SelectItem>
{labels.map((label) => (
<SelectItem key={label.id} value={label.id}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: label.color }}
/>
{label.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
<SelectTrigger data-testid="select-task-label">
<SelectValue placeholder={t('taskCreation.label')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('taskCreation.noLabel')}</SelectItem>
{labels.map((label) => (
<SelectItem key={label.id} value={label.id}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: label.color }}
/>
{label.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<div className="col-span-2">
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
data-testid="button-due-date"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dueDate ? dueDate.toLocaleDateString() : t('taskCreation.dueDate')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dueDate}
onSelect={(date) => {
setDueDate(date);
setIsCalendarOpen(false);
}}
disabled={(date) => date < new Date()}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
{/* Dependencies Selector */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<Link2 className="w-4 h-4" />
{t('dependencies.label')}
</label>
<Popover open={isDependenciesOpen} onOpenChange={setIsDependenciesOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2"
data-testid="button-due-date"
role="combobox"
className="w-full justify-between h-auto min-h-[40px]"
>
<CalendarIcon className="w-4 h-4" />
{dueDate ? dueDate.toLocaleDateString() : t('taskCreation.dueDate')}
<div className="flex flex-wrap gap-1">
{dependencies.length > 0 ? (
dependencies.map((depId) => {
const task = tasks.find((t) => t.id === depId);
return (
<Badge key={depId} variant="secondary" className="mr-1">
{task?.title || 'Unknown Task'}
<div
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
setDependencies(dependencies.filter((id) => id !== depId));
}}
>
<Plus className="h-3 w-3 rotate-45 hover:text-destructive" />
</div>
</Badge>
);
})
) : (
<span className="text-muted-foreground">{t('dependencies.selectPlaceholder')}</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={dueDate}
onSelect={(date) => {
setDueDate(date);
setIsCalendarOpen(false);
}}
disabled={(date) => date < new Date()}
initialFocus
/>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search tasks..." />
<CommandList>
<CommandEmpty>No task found.</CommandEmpty>
<CommandGroup heading="Available Tasks">
{tasks
.filter(t => t.status !== 'done') // Only active tasks
.map((task) => (
<CommandItem
key={task.id}
value={task.title}
onSelect={() => {
if (dependencies.includes(task.id)) {
setDependencies(dependencies.filter((id) => id !== task.id));
} else {
setDependencies([...dependencies, task.id]);
}
// Keep the popover open for multiple selection
}}
>
<div className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
dependencies.includes(task.id)
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}>
<Check className={cn("h-4 w-4")} />
</div>
<span>{task.title}</span>
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1 py-0 capitalize">
{task.status.replace('_', ' ')}
</Badge>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={handleClose}
className="flex-1"
data-testid="button-cancel"
>
{t('taskCreation.cancel')}
</Button>
<Button
onClick={handleSave}
className="flex-1"
data-testid="button-save-task"
>
{t('taskCreation.create')}
</Button>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={handleClose}
className="flex-1"
data-testid="button-cancel"
>
{t('taskCreation.cancel')}
</Button>
<Button
onClick={handleSave}
className="flex-1"
data-testid="button-save-task"
>
{t('taskCreation.create')}
</Button>
</div>
</DialogContent>
</Dialog>
+123 -31
View File
@@ -8,7 +8,11 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar, Tag } from 'lucide-react';
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 { useQuery } from '@tanstack/react-query';
import { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar, Tag, Link2, Check } from 'lucide-react';
import { Task, Label } from '@shared/schema';
import { useTranslation } from 'react-i18next';
@@ -27,15 +31,15 @@ interface TaskDetailsModalProps {
labels?: Label[];
}
export default function TaskDetailsModal({
isOpen,
onClose,
task,
export default function TaskDetailsModal({
isOpen,
onClose,
task,
onSave,
labels = []
}: TaskDetailsModalProps) {
const { t } = useTranslation();
// Edit form state
const [editedTitle, setEditedTitle] = useState('');
const [editedDescription, setEditedDescription] = useState('');
@@ -43,11 +47,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 [editedDependencies, setEditedDependencies] = useState<string[]>([]);
const [isDependenciesOpen, setIsDependenciesOpen] = useState(false);
const { data: tasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
});
// Notes state
const [notes, setNotes] = useState('');
const [isEditingNotes, setIsEditingNotes] = useState(false);
// Time reporting state
const [isAddingTime, setIsAddingTime] = useState(false);
const [hours, setHours] = useState(0);
@@ -55,7 +65,7 @@ export default function TaskDetailsModal({
const [manualHours, setManualHours] = useState('');
const [timeDescription, setTimeDescription] = useState('');
const [activeTimeTab, setActiveTimeTab] = useState('clock');
// Time entries state - simulate from timeTracked for display
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
@@ -68,7 +78,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] : '');
setEditedDependencies(task.dependencies || []);
setNotes(task.notes || '');
// Create a single time entry from timeTracked for display purposes
if (task.timeTracked > 0) {
@@ -105,22 +116,23 @@ export default function TaskDetailsModal({
const handleSaveTaskEdit = () => {
if (!task) return;
const updatedTask: Task = {
const updatedTask: Task = {
...task,
title: editedTitle,
description: editedDescription || null,
status: editedStatus,
priority: editedPriority,
labelId: editedLabelId,
dueDate: editedDueDate ? new Date(editedDueDate) : null
dueDate: editedDueDate ? new Date(editedDueDate) : null,
dependencies: editedDependencies
};
onSave(updatedTask);
};
const handleSaveNotes = () => {
if (!task) return;
const updatedTask = { ...task, notes };
onSave(updatedTask);
setIsEditingNotes(false);
@@ -130,7 +142,7 @@ export default function TaskDetailsModal({
if (!task) return;
let totalMinutes = 0;
if (activeTimeTab === 'clock') {
totalMinutes = hours * 60 + minutes;
} else {
@@ -149,22 +161,22 @@ export default function TaskDetailsModal({
const updatedTimeEntries = [...timeEntries, newTimeEntry];
const updatedTimeTracked = (task.timeTracked || 0) + totalMinutes;
const updatedTask = {
...task,
const updatedTask = {
...task,
timeTracked: updatedTimeTracked
};
setTimeEntries(updatedTimeEntries);
onSave(updatedTask);
// Reset time input
setHours(0);
setMinutes(0);
setManualHours('');
setTimeDescription('');
setIsAddingTime(false);
console.log(`Time entry saved: ${totalMinutes} minutes for task: ${task.title}`);
};
@@ -173,18 +185,18 @@ export default function TaskDetailsModal({
// Don't allow deleting the main tracked time entry
return;
}
const entryToDelete = timeEntries.find(entry => entry.id === entryId);
if (!entryToDelete) return;
const updatedTimeEntries = timeEntries.filter(entry => entry.id !== entryId);
const updatedTimeTracked = Math.max(0, (task.timeTracked || 0) - entryToDelete.timeSpent);
const updatedTask = {
...task,
const updatedTask = {
...task,
timeTracked: updatedTimeTracked
};
setTimeEntries(updatedTimeEntries);
onSave(updatedTask);
};
@@ -251,13 +263,13 @@ export default function TaskDetailsModal({
<h3 className="font-semibold text-lg" data-testid="text-task-details-title">
{task.title}
</h3>
{task.description && (
<p className="text-sm text-muted-foreground" data-testid="text-task-details-description">
{task.description}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className={`text-xs ${getPriorityColor(task.priority)}`}>
{t(`taskDetails.priorityValue.${task.priority}`)}
@@ -384,6 +396,86 @@ export default function TaskDetailsModal({
/>
</div>
{/* Dependencies */}
<div>
<label className="text-sm font-medium flex items-center gap-2 mb-2">
<Link2 className="w-4 h-4" />
Blocked By
</label>
<Popover open={isDependenciesOpen} onOpenChange={setIsDependenciesOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between h-auto min-h-[40px]"
>
<div className="flex flex-wrap gap-1">
{editedDependencies.length > 0 ? (
editedDependencies.map((depId) => {
const depTask = tasks.find((t) => t.id === depId);
return (
<Badge key={depId} variant="secondary" className="mr-1">
{depTask?.title || 'Unknown Task'}
<div
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
setEditedDependencies(editedDependencies.filter((id) => id !== depId));
}}
>
<Plus className="h-3 w-3 rotate-45 hover:text-destructive" />
</div>
</Badge>
);
})
) : (
<span className="text-muted-foreground">{t('dependencies.selectPlaceholder')}</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search tasks..." />
<CommandList>
<CommandEmpty>No task found.</CommandEmpty>
<CommandGroup heading="Available Tasks">
{tasks
.filter(t => t.id !== task.id && t.status !== 'done') // Cannot depend on self or done tasks (optional logic)
.map((tItem) => (
<CommandItem
key={tItem.id}
value={tItem.title}
onSelect={() => {
if (editedDependencies.includes(tItem.id)) {
setEditedDependencies(editedDependencies.filter((id) => id !== tItem.id));
} else {
setEditedDependencies([...editedDependencies, tItem.id]);
}
}}
>
<div className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
editedDependencies.includes(tItem.id)
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}>
<Check className={cn("h-4 w-4")} />
</div>
<span>{tItem.title}</span>
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1 py-0 capitalize">
{tItem.status.replace('_', ' ')}
</Badge>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Save Button */}
<div className="flex gap-2 pt-2">
<Button
@@ -522,7 +614,7 @@ export default function TaskDetailsModal({
{formatTime(hours, minutes)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Hours Picker */}
<div className="space-y-2">
@@ -542,7 +634,7 @@ export default function TaskDetailsModal({
))}
</div>
</div>
{/* Minutes Picker */}
<div className="space-y-2">
<label className="text-sm font-medium text-center block">Minutes</label>
@@ -572,7 +664,7 @@ export default function TaskDetailsModal({
{getCurrentTimeDisplay()}
</div>
</div>
<div>
<label className="text-sm font-medium block mb-2">Hours (decimal)</label>
<Input
+17 -17
View File
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search, Filter, SortAsc } from 'lucide-react';
import { Task } from './TaskCard';
import { Task } from '@shared/schema';
import TaskCard from './TaskCard';
interface TaskListProps {
@@ -32,10 +32,10 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
.filter(task => {
// Search filter
const matchesSearch = task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.description?.toLowerCase().includes(searchQuery.toLowerCase());
task.description?.toLowerCase().includes(searchQuery.toLowerCase());
if (!matchesSearch) return false;
// Status filter
switch (filterBy) {
case 'overdue':
@@ -53,18 +53,18 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return a.dueDate.getTime() - b.dueDate.getTime();
case 'priority':
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority] - priorityOrder[a.priority];
const priorityOrder: Record<string, number> = { high: 3, medium: 2, low: 1 };
return (priorityOrder[b.priority] || 0) - (priorityOrder[a.priority] || 0);
case 'title':
return a.title.localeCompare(b.title);
case 'status':
const statusOrder = { todo: 1, inProgress: 2, done: 3 };
return statusOrder[a.status] - statusOrder[b.status];
const statusOrder: Record<string, number> = { todo: 1, inProgress: 2, done: 3 };
return (statusOrder[a.status] || 0) - (statusOrder[b.status] || 0);
default:
return 0;
}
@@ -108,7 +108,7 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
data-testid="input-search-tasks"
/>
</div>
<div className="flex gap-3">
<Select value={filterBy} onValueChange={(value: FilterOption) => {
setFilterBy(value);
@@ -128,7 +128,7 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
<SelectItem value="overdue">{t('taskList.filter.overdue')} ({getFilterCount('overdue')})</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(value: SortOption) => {
setSortBy(value);
console.log('Sort changed to:', value);
@@ -154,7 +154,7 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
{filteredAndSortedTasks.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground" data-testid="text-no-tasks">
{searchQuery || filterBy !== 'all'
{searchQuery || filterBy !== 'all'
? t('taskList.noMatchingTasks')
: t('taskList.noTasks')
}
@@ -165,11 +165,11 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
<TaskCard
key={task.id}
task={task}
onPlay={() => {
onStartTimer={() => {
onTaskUpdate?.(task.id, { isTracking: true, timeTracked: task.timeTracked });
console.log(`Timer started for ${task.title}`);
}}
onPause={() => {
onStopTimer={() => {
onTaskUpdate?.(task.id, { isTracking: false });
console.log(`Timer paused for ${task.title}`);
}}
+5 -6
View File
@@ -214,7 +214,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
};
return (
<div className="space-y-4">
<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="space-y-3">
@@ -282,8 +282,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
</div>
</div>
{/* Unscheduled Task List */}
<div className="space-y-3">
<div className="space-y-3 flex-1">
{unscheduledTasks.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground" data-testid="text-no-tasks">
@@ -295,7 +294,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
</div>
) : (
<>
<div className="text-sm font-medium text-muted-foreground mb-2">
<div className="text-sm font-medium text-muted-foreground mb-2 mt-8 px-1">
{t('taskList.unscheduledTasksLabel')}
</div>
{unscheduledTasks.map((task) => (
@@ -337,8 +336,8 @@ 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">
<div className="p-3 max-h-[32vh] overflow-y-auto">
<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 Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
@@ -0,0 +1,106 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { User } from '@shared/schema';
const profileSchema = z.object({
email: z.string().email("Invalid email address"),
});
type ProfileFormValues = z.infer<typeof profileSchema>;
interface UpdateProfileModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user: User;
}
export function UpdateProfileModal({ open, onOpenChange, user }: UpdateProfileModalProps) {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
email: user.email,
},
});
const mutation = useMutation({
mutationFn: async (data: ProfileFormValues) => {
const res = await apiRequest("PATCH", "/api/user/profile", data);
if (!res.ok) {
const text = await res.text();
throw new Error(text || "Failed to update profile");
}
return res.json();
},
onSuccess: (updatedUser) => {
queryClient.setQueryData(['/api/user'], updatedUser);
toast({ title: "Profile updated successfully" });
onOpenChange(false);
},
onError: (error: Error) => {
toast({
title: "Update failed",
description: error.message,
variant: "destructive",
});
},
});
const onSubmit = (data: ProfileFormValues) => {
mutation.mutate(data);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Update Profile</DialogTitle>
<DialogDescription>
Update your account information.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} type="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,130 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { Loader2, Bot } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
export function AiSettingsCard() {
const { t } = useTranslation();
const { toast } = useToast();
// Fetch settings
const { data: settings, isLoading } = useQuery<Record<string, string>>({
queryKey: ['/api/admin/settings'],
});
const [provider, setProvider] = useState("openai");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("gpt-4o");
const [baseUrl, setBaseUrl] = useState("");
useEffect(() => {
if (settings) {
setProvider(settings.ai_provider || "openai");
setApiKey(settings.ai_api_key || "");
setModel(settings.ai_model || "gpt-4o");
setBaseUrl(settings.ai_base_url || "");
}
}, [settings]);
const mutation = useMutation({
mutationFn: async (data: any) => {
const res = await apiRequest("POST", "/api/admin/settings", data);
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/admin/settings'] });
toast({ title: "Settings saved" });
},
onError: () => {
toast({ title: "Failed to save settings", variant: "destructive" });
}
});
const handleSave = () => {
mutation.mutate({
ai_provider: provider,
ai_api_key: apiKey,
ai_model: model,
ai_base_url: baseUrl
});
};
if (isLoading) return <Loader2 className="animate-spin" />;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bot className="w-5 h-5" />
{t('settings.ai.title')}
</CardTitle>
<CardDescription>{t('settings.ai.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<Label>{t('settings.ai.provider')}</Label>
<Select value={provider} onValueChange={(v) => {
setProvider(v);
if (v === 'openai' && model === 'claude-3-5-sonnet') setModel('gpt-4o');
if (v === 'anthropic' && model === 'gpt-4o') setModel('claude-3-5-sonnet');
if (v === 'ollama') {
setBaseUrl('http://localhost:11434');
setModel('llama3');
}
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="google">Google Gemini</SelectItem>
<SelectItem value="ollama">Ollama (Local)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>{t('settings.ai.apiKey')}</Label>
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider === 'ollama' ? 'Optional for Ollama' : 'sk-...'}
/>
</div>
<div className="grid gap-2">
<Label>{t('settings.ai.model')}</Label>
<Input
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="e.g. gpt-4, claude-3-opus, llama3"
/>
</div>
<div className="grid gap-2">
<Label>{t('settings.ai.baseUrl')}</Label>
<Input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder={provider === 'ollama' ? 'http://localhost:11434' : 'Optional override'}
/>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</CardFooter>
</Card>
);
}
@@ -0,0 +1,103 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
export function SMTPSettingsCard() {
const { t } = useTranslation();
const { toast } = useToast();
const { data: settings } = useQuery<{
smtp_host: string;
smtp_port: string;
smtp_user: string;
smtp_pass: string;
smtp_from: string;
smtp_secure: boolean;
}>({
queryKey: ["/api/admin/settings"],
});
const [emailSettings, setEmailSettings] = useState({
smtp_host: '',
smtp_port: '',
smtp_user: '',
smtp_pass: '',
smtp_from: '',
smtp_secure: false
});
useEffect(() => {
if (settings) {
setEmailSettings({
smtp_host: settings.smtp_host || '',
smtp_port: settings.smtp_port || '',
smtp_user: settings.smtp_user || '',
smtp_pass: settings.smtp_pass || '',
smtp_from: settings.smtp_from || '',
smtp_secure: settings.smtp_secure || false
});
}
}, [settings]);
const updateSettingsMutation = useMutation({
mutationFn: (data: Partial<typeof settings>) => apiRequest("POST", "/api/admin/settings", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/settings"] });
toast({ title: t('smtp.saveSuccess', "Settings updated") });
},
});
return (
<Card>
<CardHeader>
<CardTitle>{t('settings.smtp.title')}</CardTitle>
<CardDescription>{t('settings.smtp.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('settings.smtp.host')}</Label>
<Input placeholder="smtp.example.com" value={emailSettings.smtp_host} onChange={e => setEmailSettings({ ...emailSettings, smtp_host: e.target.value })} />
</div>
<div className="space-y-2">
<Label>{t('settings.smtp.port')}</Label>
<Input placeholder="587" value={emailSettings.smtp_port} onChange={e => setEmailSettings({ ...emailSettings, smtp_port: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('settings.smtp.user')}</Label>
<Input placeholder="user@example.com" value={emailSettings.smtp_user} onChange={e => setEmailSettings({ ...emailSettings, smtp_user: e.target.value })} />
</div>
<div className="space-y-2">
<Label>{t('settings.smtp.password')}</Label>
<Input type="password" placeholder="••••••" value={emailSettings.smtp_pass} onChange={e => setEmailSettings({ ...emailSettings, smtp_pass: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<Label>{t('settings.smtp.from')}</Label>
<Input placeholder='"TaskFlow" <noreply@taskflow.local>' value={emailSettings.smtp_from} onChange={e => setEmailSettings({ ...emailSettings, smtp_from: e.target.value })} />
</div>
<div className="flex items-center justify-between">
<Label>{t('settings.smtp.secure')}</Label>
<Switch checked={emailSettings.smtp_secure} onCheckedChange={(c) => setEmailSettings({ ...emailSettings, smtp_secure: c })} />
</div>
<Button
onClick={() => updateSettingsMutation.mutate(emailSettings)}
disabled={updateSettingsMutation.isPending}
className="w-full"
>
{updateSettingsMutation.isPending ? t('settings.smtp.saving') : t('settings.smtp.save')}
</Button>
</CardContent>
</Card>
);
}
+107 -49
View File
@@ -1,63 +1,121 @@
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { icons } from "lucide-react";
import { Card } from "@/components/ui/card";
import { useTranslation } from "react-i18next";
import { Reward } from "@shared/schema";
import { Loader2, Coffee, Gamepad2, Palette, Gift, type LucideIcon } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { useState } from "react";
// We don't have types shared for specific API responses yet, so using any for now or defining locally
type Reward = {
id: string;
title: string;
description: string | null;
cost: number;
icon: string;
type: string;
owned?: boolean;
};
interface RewardCardProps {
reward: Reward & { owned?: boolean };
type RewardCardProps = {
reward: Reward;
userXp: number;
onBuy: (rewardId: string) => void;
isBuying?: boolean;
}
userId: string;
};
export function RewardCard({ reward, userXp, onBuy, isBuying }: RewardCardProps) {
const iconMap: Record<string, LucideIcon> = {
"coffee": Coffee,
"gamepad-2": Gamepad2,
"palette": Palette,
"gift": Gift
};
export const RewardCard = ({ reward, userXp, userId }: RewardCardProps) => {
const { t } = useTranslation();
// Dynamic icon
const Icon = (icons as any)[reward.icon] || (icons as any).Gift;
const { toast } = useToast();
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const canAfford = userXp >= reward.cost;
const isOwned = reward.owned;
const IconComponent = iconMap[reward.icon] || Gift; // Fallback
const handleBuy = async () => {
if (!canAfford && !isOwned) return;
setIsLoading(true);
try {
const res = await apiRequest("POST", "/api/rewards/purchase", {
rewardId: reward.id
});
await res.json();
toast({
title: t("rewards.buySuccess"), // Need to add this key or use generic
description: t("rewards.itemPurchased", { item: t(reward.title) }),
});
// Refetch rewards and user (to update XP)
queryClient.invalidateQueries({ queryKey: ["/api/rewards"] });
queryClient.invalidateQueries({ queryKey: ["/api/user"] }); // Assuming user fetch uses this key
queryClient.invalidateQueries({ queryKey: ["/api/users/current"] }); // Or this
} catch (error) {
toast({
title: t("rewards.buyFailed"),
description: t("rewards.insufficientFunds"),
variant: "destructive"
});
} finally {
setIsLoading(false);
}
};
return (
<Card className={`flex flex-col h-full ${isOwned ? 'opacity-80' : ''}`}>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div className="p-2 rounded-lg bg-primary/10">
<Icon className="h-6 w-6 text-primary" />
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card className={`relative overflow-hidden p-6 transition-colors ${isOwned ? "bg-primary/10 border-primary/20" : "bg-card hover:border-primary/50"
}`}>
<div className="flex flex-col items-center gap-4 text-center">
<div className={`p-4 rounded-full ${isOwned ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
}`}>
<IconComponent className="h-8 w-8" />
</div>
<div className="space-y-1">
<h3 className="font-bold text-lg">{t(reward.title)}</h3>
<p className="text-sm text-muted-foreground">{t(reward.description || "")}</p>
</div>
<div className="mt-2 w-full">
{isOwned ? (
<Button variant="outline" className="w-full cursor-default disabled:opacity-100" disabled>
{t("rewards.owned")}
</Button>
) : (
<Button
className="w-full gap-2"
disabled={!canAfford || isLoading}
onClick={handleBuy}
variant={canAfford ? "default" : "secondary"}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
{t("rewards.buy")}
<span className="font-mono text-xs bg-black/20 px-2 py-0.5 rounded">
{reward.cost} XP
</span>
</>
)}
</Button>
)}
</div>
{isOwned && <Badge variant="secondary">{t('rewards.owned')}</Badge>}
</div>
<CardTitle className="mt-4 text-lg">
{reward.isSystem ? t(reward.title) : reward.title}
</CardTitle>
</CardHeader>
<CardContent className="flex-1">
<p className="text-sm text-muted-foreground">
{reward.isSystem ? t(reward.description) : reward.description}
</p>
</CardContent>
<CardFooter className="flex justify-between items-center border-t pt-4">
<div className="font-bold text-lg">
{reward.cost} <span className="text-xs font-normal text-muted-foreground">XP</span>
</div>
{isOwned ? (
<Button disabled variant="outline" size="sm">
{t('rewards.owned')}
</Button>
) : (
<Button
onClick={() => onBuy(reward.id)}
disabled={!canAfford || isBuying}
size="sm"
variant={canAfford ? "default" : "secondary"}
>
{isBuying ? t('rewards.processing') : t('rewards.buy')}
</Button>
)}
</CardFooter>
</Card>
</Card>
</motion.div>
);
}
};
+1
View File
@@ -21,6 +21,7 @@ const buttonVariants = cva(
secondary: "border bg-secondary text-secondary-foreground border border-secondary-border ",
// Add a transparent border so that when someone toggles a border on later, it doesn't shift layout/size.
ghost: "border border-transparent",
link: "text-primary underline-offset-4 hover:underline",
},
// Heights are set as "min" heights, because sometimes Ai will place large amount of content
// inside buttons. With a min-height they will look appropriate with small amounts of content,