feat: Implement AI Chat Agent, Email Notifications, and UI enhancements
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user