Squashed commit of the following:
commit 38f68d5ed489f416ea91630aea3496ab15365e66 Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 16:16:28 2025 -0400 Fix click and drag commit eb5c41cf31fd8032fe88fd47fe5f3e7f517f6d30 Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 15:57:13 2025 -0400 Update tasks commit 979d4b15373df3193eb1bd41c18ece1dbe044eba Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 15:19:20 2025 -0400 Status commit fa26f1fa8fefe1d84b5b2153327c7e8c0132952a Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 14:54:48 2025 -0400 Cleanup project card commit 14d7a1d7d7574dd8745167b280c04603ba22b189 Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 14:49:19 2025 -0400 Improve existing vs new repo commit 277e1f05ef68e5c67d73b246557a6df2ab23d32c Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 13:01:21 2025 -0400 Make repo path unique commit f80ef55f2ba16836276a81844fc33639872bcc53 Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 12:52:20 2025 -0400 Fix styles commit 077869458fcab199a10ef0fe2fe39f9f4216ce5b Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 12:41:48 2025 -0400 First select repo commit 1b0d9c0280e4cb75294348bb53b2a534458a2e37 Author: Louis Knight-Webb <louis@bloop.ai> Date: Mon Jun 16 11:45:19 2025 -0400 Init
This commit is contained in:
@@ -1,88 +1,267 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Project, CreateProject, UpdateProject } from 'shared/types'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { makeAuthenticatedRequest } from '@/lib/auth'
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FolderPicker } from "@/components/ui/folder-picker";
|
||||
import { Project, CreateProject, UpdateProject } from "shared/types";
|
||||
import { AlertCircle, Folder } from "lucide-react";
|
||||
import { makeAuthenticatedRequest } from "@/lib/auth";
|
||||
|
||||
interface ProjectFormProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
project?: Project | null
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
project?: Project | null;
|
||||
}
|
||||
|
||||
export function ProjectForm({ open, onClose, onSuccess, project }: ProjectFormProps) {
|
||||
const [name, setName] = useState(project?.name || '')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
export function ProjectForm({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
project,
|
||||
}: ProjectFormProps) {
|
||||
const [name, setName] = useState(project?.name || "");
|
||||
const [gitRepoPath, setGitRepoPath] = useState(project?.git_repo_path || "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showFolderPicker, setShowFolderPicker] = useState(false);
|
||||
const [repoMode, setRepoMode] = useState<"existing" | "new">("existing");
|
||||
const [parentPath, setParentPath] = useState("");
|
||||
const [folderName, setFolderName] = useState("");
|
||||
|
||||
const isEditing = !!project
|
||||
const isEditing = !!project;
|
||||
|
||||
// Auto-populate project name from directory name
|
||||
const handleGitRepoPathChange = (path: string) => {
|
||||
setGitRepoPath(path);
|
||||
|
||||
// Only auto-populate name for new projects
|
||||
if (!isEditing && path) {
|
||||
// Extract the last part of the path (directory name)
|
||||
const dirName = path.split("/").filter(Boolean).pop() || "";
|
||||
if (dirName) {
|
||||
// Clean up the directory name for a better project name
|
||||
const cleanName = dirName
|
||||
.replace(/[-_]/g, " ") // Replace hyphens and underscores with spaces
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase()); // Capitalize first letter of each word
|
||||
setName(cleanName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
let finalGitRepoPath = gitRepoPath;
|
||||
|
||||
// For new repo mode, construct the full path
|
||||
if (!isEditing && repoMode === "new") {
|
||||
finalGitRepoPath = `${parentPath}/${folderName}`.replace(/\/+/g, "/");
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
const updateData: UpdateProject = { name }
|
||||
const response = await makeAuthenticatedRequest(`/api/projects/${project.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData),
|
||||
})
|
||||
|
||||
const updateData: UpdateProject = {
|
||||
name,
|
||||
git_repo_path: finalGitRepoPath,
|
||||
};
|
||||
const response = await makeAuthenticatedRequest(
|
||||
`/api/projects/${project.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(updateData),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update project')
|
||||
throw new Error("Failed to update project");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || "Failed to update project");
|
||||
}
|
||||
} else {
|
||||
const createData: CreateProject = {
|
||||
name
|
||||
}
|
||||
const response = await makeAuthenticatedRequest('/api/projects', {
|
||||
method: 'POST',
|
||||
const createData: CreateProject = {
|
||||
name,
|
||||
git_repo_path: finalGitRepoPath,
|
||||
use_existing_repo: repoMode === "existing",
|
||||
};
|
||||
const response = await makeAuthenticatedRequest("/api/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(createData),
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create project')
|
||||
throw new Error("Failed to create project");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || "Failed to create project");
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess()
|
||||
setName('')
|
||||
onSuccess();
|
||||
setName("");
|
||||
setGitRepoPath("");
|
||||
setParentPath("");
|
||||
setFolderName("");
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
setError(error instanceof Error ? error.message : "An error occurred");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName(project?.name || '')
|
||||
setError('')
|
||||
onClose()
|
||||
}
|
||||
setName(project?.name || "");
|
||||
setGitRepoPath(project?.git_repo_path || "");
|
||||
setError("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? 'Edit Project' : 'Create New Project'}
|
||||
{isEditing ? "Edit Project" : "Create New Project"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? 'Make changes to your project here. Click save when you\'re done.'
|
||||
: 'Add a new project to your workspace. You can always edit it later.'
|
||||
}
|
||||
{isEditing
|
||||
? "Make changes to your project here. Click save when you're done."
|
||||
: "Choose whether to use an existing git repository or create a new one."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!isEditing && (
|
||||
<div className="space-y-3">
|
||||
<Label>Repository Type</Label>
|
||||
<div className="flex space-x-4">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="repoMode"
|
||||
value="existing"
|
||||
checked={repoMode === "existing"}
|
||||
onChange={(e) =>
|
||||
setRepoMode(e.target.value as "existing" | "new")
|
||||
}
|
||||
className="text-primary"
|
||||
/>
|
||||
<span className="text-sm">Use existing repository</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="repoMode"
|
||||
value="new"
|
||||
checked={repoMode === "new"}
|
||||
onChange={(e) =>
|
||||
setRepoMode(e.target.value as "existing" | "new")
|
||||
}
|
||||
className="text-primary"
|
||||
/>
|
||||
<span className="text-sm">Create new repository</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{repoMode === "existing" || isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="git-repo-path">Git Repository Path</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="git-repo-path"
|
||||
type="text"
|
||||
value={gitRepoPath}
|
||||
onChange={(e) => handleGitRepoPathChange(e.target.value)}
|
||||
placeholder="/path/to/your/existing/repo"
|
||||
required
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowFolderPicker(true)}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a folder that already contains a git repository
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parent-path">Parent Directory</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="parent-path"
|
||||
type="text"
|
||||
value={parentPath}
|
||||
onChange={(e) => setParentPath(e.target.value)}
|
||||
placeholder="/path/to/parent/directory"
|
||||
required
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowFolderPicker(true)}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose where to create the new repository
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="folder-name">Repository Folder Name</Label>
|
||||
<Input
|
||||
id="folder-name"
|
||||
type="text"
|
||||
value={folderName}
|
||||
onChange={(e) => {
|
||||
setFolderName(e.target.value);
|
||||
if (e.target.value) {
|
||||
setName(
|
||||
e.target.value
|
||||
.replace(/[-_]/g, " ")
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase())
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="my-awesome-project"
|
||||
required
|
||||
className="flex-1"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The project name will be auto-populated from this folder name
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Project Name</Label>
|
||||
<Input
|
||||
@@ -98,9 +277,7 @@ export function ProjectForm({ open, onClose, onSuccess, project }: ProjectFormPr
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{error}
|
||||
</AlertDescription>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -113,12 +290,49 @@ export function ProjectForm({ open, onClose, onSuccess, project }: ProjectFormPr
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !name.trim()}>
|
||||
{loading ? 'Saving...' : isEditing ? 'Save Changes' : 'Create Project'}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
loading ||
|
||||
!name.trim() ||
|
||||
(repoMode === "existing" || isEditing
|
||||
? !gitRepoPath.trim()
|
||||
: !parentPath.trim() || !folderName.trim())
|
||||
}
|
||||
>
|
||||
{loading
|
||||
? "Saving..."
|
||||
: isEditing
|
||||
? "Save Changes"
|
||||
: "Create Project"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
<FolderPicker
|
||||
open={showFolderPicker}
|
||||
onClose={() => setShowFolderPicker(false)}
|
||||
onSelect={(path) => {
|
||||
if (repoMode === "existing" || isEditing) {
|
||||
handleGitRepoPathChange(path);
|
||||
} else {
|
||||
setParentPath(path);
|
||||
}
|
||||
setShowFolderPicker(false);
|
||||
}}
|
||||
value={repoMode === "existing" || isEditing ? gitRepoPath : parentPath}
|
||||
title={
|
||||
repoMode === "existing" || isEditing
|
||||
? "Select Git Repository"
|
||||
: "Select Parent Directory"
|
||||
}
|
||||
description={
|
||||
repoMode === "existing" || isEditing
|
||||
? "Choose an existing git repository"
|
||||
: "Choose where to create the new repository"
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Project, ApiResponse } from 'shared/types'
|
||||
import { ProjectForm } from './project-form'
|
||||
import { makeAuthenticatedRequest } from '@/lib/auth'
|
||||
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, CheckSquare } from 'lucide-react'
|
||||
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, MoreHorizontal, ExternalLink } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
export function ProjectList() {
|
||||
const navigate = useNavigate()
|
||||
@@ -118,54 +124,64 @@ export function ProjectList() {
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<Card key={project.id} className="hover:shadow-md transition-shadow">
|
||||
<Card
|
||||
key={project.id}
|
||||
className="hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => navigate(`/projects/${project.id}/tasks`)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle
|
||||
className="text-lg cursor-pointer hover:text-primary"
|
||||
onClick={() => navigate(`/projects/${project.id}`)}
|
||||
>
|
||||
<CardTitle className="text-lg">
|
||||
{project.name}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
Active
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
Active
|
||||
</Badge>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate(`/projects/${project.id}`)
|
||||
}}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View Project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit(project)
|
||||
}}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(project.id, project.name)
|
||||
}}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="flex items-center">
|
||||
<Calendar className="mr-1 h-3 w-3" />
|
||||
Created {new Date(project.created_at).toLocaleDateString()}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => navigate(`/projects/${project.id}/tasks`)}
|
||||
className="h-8"
|
||||
>
|
||||
<CheckSquare className="mr-1 h-3 w-3" />
|
||||
Tasks
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(project)}
|
||||
className="h-8"
|
||||
>
|
||||
<Edit className="mr-1 h-3 w-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(project.id, project.name)}
|
||||
className="h-8 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
92
frontend/src/components/tasks/TaskCard.tsx
Normal file
92
frontend/src/components/tasks/TaskCard.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { KanbanCard } from '@/components/ui/shadcn-io/kanban'
|
||||
import { MoreHorizontal, Trash2, Edit } from 'lucide-react'
|
||||
import type { TaskStatus } from 'shared/types'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: TaskStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task
|
||||
index: number
|
||||
status: string
|
||||
onEdit: (task: Task) => void
|
||||
onDelete: (taskId: string) => void
|
||||
onViewDetails: (task: Task) => void
|
||||
}
|
||||
|
||||
export function TaskCard({ task, index, status, onEdit, onDelete, onViewDetails }: TaskCardProps) {
|
||||
return (
|
||||
<KanbanCard
|
||||
key={task.id}
|
||||
id={task.id}
|
||||
name={task.title}
|
||||
index={index}
|
||||
parent={status}
|
||||
onClick={() => onViewDetails(task)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 pr-2">
|
||||
<h4 className="font-medium text-sm">
|
||||
{task.title}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* Actions Menu */}
|
||||
<div
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-gray-100"
|
||||
>
|
||||
<MoreHorizontal className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(task)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(task.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{task.description && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</KanbanCard>
|
||||
)
|
||||
}
|
||||
71
frontend/src/components/tasks/TaskCreateDialog.tsx
Normal file
71
frontend/src/components/tasks/TaskCreateDialog.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface TaskCreateDialogProps {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreateTask: (title: string, description: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function TaskCreateDialog({ isOpen, onOpenChange, onCreateTask }: TaskCreateDialogProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!title.trim()) return
|
||||
|
||||
await onCreateTask(title, description)
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter task title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter task description (optional)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate}>Create Task</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
235
frontend/src/components/tasks/TaskDetailsDialog.tsx
Normal file
235
frontend/src/components/tasks/TaskDetailsDialog.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { makeAuthenticatedRequest } from '@/lib/auth'
|
||||
import type { TaskStatus, TaskAttempt, TaskAttemptActivity } from 'shared/types'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: TaskStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean
|
||||
data: T | null
|
||||
message: string | null
|
||||
}
|
||||
|
||||
interface TaskDetailsDialogProps {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
task: Task | null
|
||||
projectId: string
|
||||
onError: (error: string) => void
|
||||
}
|
||||
|
||||
const statusLabels: Record<TaskStatus, string> = {
|
||||
todo: 'To Do',
|
||||
inprogress: 'In Progress',
|
||||
inreview: 'In Review',
|
||||
done: 'Done',
|
||||
cancelled: 'Cancelled'
|
||||
}
|
||||
|
||||
export function TaskDetailsDialog({ isOpen, onOpenChange, task, projectId, onError }: TaskDetailsDialogProps) {
|
||||
const [taskAttempts, setTaskAttempts] = useState<TaskAttempt[]>([])
|
||||
const [taskAttemptsLoading, setTaskAttemptsLoading] = useState(false)
|
||||
const [selectedAttempt, setSelectedAttempt] = useState<TaskAttempt | null>(null)
|
||||
const [attemptActivities, setAttemptActivities] = useState<TaskAttemptActivity[]>([])
|
||||
const [activitiesLoading, setActivitiesLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && task) {
|
||||
fetchTaskAttempts(task.id)
|
||||
}
|
||||
}, [isOpen, task])
|
||||
|
||||
const fetchTaskAttempts = async (taskId: string) => {
|
||||
try {
|
||||
setTaskAttemptsLoading(true)
|
||||
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${taskId}/attempts`)
|
||||
|
||||
if (response.ok) {
|
||||
const result: ApiResponse<TaskAttempt[]> = await response.json()
|
||||
if (result.success && result.data) {
|
||||
setTaskAttempts(result.data)
|
||||
}
|
||||
} else {
|
||||
onError('Failed to load task attempts')
|
||||
}
|
||||
} catch (err) {
|
||||
onError('Failed to load task attempts')
|
||||
} finally {
|
||||
setTaskAttemptsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAttemptActivities = async (attemptId: string) => {
|
||||
if (!task) return
|
||||
|
||||
try {
|
||||
setActivitiesLoading(true)
|
||||
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities`)
|
||||
|
||||
if (response.ok) {
|
||||
const result: ApiResponse<TaskAttemptActivity[]> = await response.json()
|
||||
if (result.success && result.data) {
|
||||
setAttemptActivities(result.data)
|
||||
}
|
||||
} else {
|
||||
onError('Failed to load attempt activities')
|
||||
}
|
||||
} catch (err) {
|
||||
onError('Failed to load attempt activities')
|
||||
} finally {
|
||||
setActivitiesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAttemptClick = (attempt: TaskAttempt) => {
|
||||
setSelectedAttempt(attempt)
|
||||
fetchAttemptActivities(attempt.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Task Details: {task?.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
{/* Task Info */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Task Information</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Title</Label>
|
||||
<p className="text-sm text-muted-foreground">{task?.title}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Status</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{task ? statusLabels[task.status] : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{task?.description && (
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<p className="text-sm text-muted-foreground">{task.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Attempts */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Task Attempts</h3>
|
||||
{taskAttemptsLoading ? (
|
||||
<div className="text-center py-4">Loading attempts...</div>
|
||||
) : taskAttempts.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
No attempts found for this task
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{taskAttempts.map((attempt) => (
|
||||
<Card
|
||||
key={attempt.id}
|
||||
className={`cursor-pointer transition-colors ${
|
||||
selectedAttempt?.id === attempt.id ? 'bg-blue-50 border-blue-200' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => handleAttemptClick(attempt)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium">Worktree: {attempt.worktree_path}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Created: {new Date(attempt.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<Label className="text-xs">Base Commit</Label>
|
||||
<p className="text-muted-foreground">
|
||||
{attempt.base_commit || 'None'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Merge Commit</Label>
|
||||
<p className="text-muted-foreground">
|
||||
{attempt.merge_commit || 'None'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Activity History */}
|
||||
{selectedAttempt && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Activity History for Attempt: {selectedAttempt.worktree_path}
|
||||
</h3>
|
||||
{activitiesLoading ? (
|
||||
<div className="text-center py-4">Loading activities...</div>
|
||||
) : attemptActivities.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
No activities found for this attempt
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{attemptActivities.map((activity) => (
|
||||
<Card key={activity.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
activity.status === 'init' ? 'bg-gray-100 text-gray-800' :
|
||||
activity.status === 'inprogress' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{activity.status === 'init' ? 'Init' :
|
||||
activity.status === 'inprogress' ? 'In Progress' :
|
||||
'Paused'}
|
||||
</span>
|
||||
</div>
|
||||
{activity.note && (
|
||||
<p className="text-sm text-muted-foreground">{activity.note}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(activity.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
115
frontend/src/components/tasks/TaskEditDialog.tsx
Normal file
115
frontend/src/components/tasks/TaskEditDialog.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import type { TaskStatus } from 'shared/types'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: TaskStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface TaskEditDialogProps {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
task: Task | null
|
||||
onUpdateTask: (title: string, description: string, status: TaskStatus) => Promise<void>
|
||||
}
|
||||
|
||||
export function TaskEditDialog({ isOpen, onOpenChange, task, onUpdateTask }: TaskEditDialogProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [status, setStatus] = useState<TaskStatus>('todo')
|
||||
|
||||
useEffect(() => {
|
||||
if (task) {
|
||||
setTitle(task.title)
|
||||
setDescription(task.description || '')
|
||||
setStatus(task.status)
|
||||
}
|
||||
}, [task])
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!title.trim()) return
|
||||
|
||||
await onUpdateTask(title, description, status)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-title">Title</Label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter task title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter task description (optional)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-status">Status</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => setStatus(value as TaskStatus)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="inprogress">In Progress</SelectItem>
|
||||
<SelectItem value="inreview">In Review</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdate}>Update Task</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
95
frontend/src/components/tasks/TaskKanbanBoard.tsx
Normal file
95
frontend/src/components/tasks/TaskKanbanBoard.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
KanbanProvider,
|
||||
KanbanBoard,
|
||||
KanbanHeader,
|
||||
KanbanCards,
|
||||
type DragEndEvent
|
||||
} from '@/components/ui/shadcn-io/kanban'
|
||||
import { TaskCard } from './TaskCard'
|
||||
import type { TaskStatus } from 'shared/types'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: TaskStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface TaskKanbanBoardProps {
|
||||
tasks: Task[]
|
||||
onDragEnd: (event: DragEndEvent) => void
|
||||
onEditTask: (task: Task) => void
|
||||
onDeleteTask: (taskId: string) => void
|
||||
onViewTaskDetails: (task: Task) => void
|
||||
}
|
||||
|
||||
const allTaskStatuses: TaskStatus[] = ['todo', 'inprogress', 'inreview', 'done', 'cancelled']
|
||||
|
||||
const statusLabels: Record<TaskStatus, string> = {
|
||||
todo: 'To Do',
|
||||
inprogress: 'In Progress',
|
||||
inreview: 'In Review',
|
||||
done: 'Done',
|
||||
cancelled: 'Cancelled'
|
||||
}
|
||||
|
||||
const statusBoardColors: Record<TaskStatus, string> = {
|
||||
todo: '#64748b',
|
||||
inprogress: '#3b82f6',
|
||||
inreview: '#f59e0b',
|
||||
done: '#22c55e',
|
||||
cancelled: '#ef4444'
|
||||
}
|
||||
|
||||
export function TaskKanbanBoard({ tasks, onDragEnd, onEditTask, onDeleteTask, onViewTaskDetails }: TaskKanbanBoardProps) {
|
||||
const groupTasksByStatus = () => {
|
||||
const groups: Record<TaskStatus, Task[]> = {} as Record<TaskStatus, Task[]>
|
||||
|
||||
// Initialize groups for all possible statuses
|
||||
allTaskStatuses.forEach(status => {
|
||||
groups[status] = []
|
||||
})
|
||||
|
||||
tasks.forEach(task => {
|
||||
// Convert old capitalized status to lowercase if needed
|
||||
const normalizedStatus = task.status.toLowerCase() as TaskStatus
|
||||
if (groups[normalizedStatus]) {
|
||||
groups[normalizedStatus].push(task)
|
||||
} else {
|
||||
// Default to todo if status doesn't match any expected value
|
||||
groups['todo'].push(task)
|
||||
}
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
return (
|
||||
<KanbanProvider onDragEnd={onDragEnd}>
|
||||
{Object.entries(groupTasksByStatus()).map(([status, statusTasks]) => (
|
||||
<KanbanBoard key={status} id={status as TaskStatus}>
|
||||
<KanbanHeader
|
||||
name={statusLabels[status as TaskStatus]}
|
||||
color={statusBoardColors[status as TaskStatus]}
|
||||
/>
|
||||
<KanbanCards>
|
||||
{statusTasks.map((task, index) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
index={index}
|
||||
status={status}
|
||||
onEdit={onEditTask}
|
||||
onDelete={onDeleteTask}
|
||||
onViewDetails={onViewTaskDetails}
|
||||
/>
|
||||
))}
|
||||
</KanbanCards>
|
||||
</KanbanBoard>
|
||||
))}
|
||||
</KanbanProvider>
|
||||
)
|
||||
}
|
||||
5
frontend/src/components/tasks/index.ts
Normal file
5
frontend/src/components/tasks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { TaskCreateDialog } from './TaskCreateDialog'
|
||||
export { TaskEditDialog } from './TaskEditDialog'
|
||||
export { TaskDetailsDialog } from './TaskDetailsDialog'
|
||||
export { TaskCard } from './TaskCard'
|
||||
export { TaskKanbanBoard } from './TaskKanbanBoard'
|
||||
248
frontend/src/components/ui/folder-picker.tsx
Normal file
248
frontend/src/components/ui/folder-picker.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Folder, FolderOpen, File, AlertCircle, Home, ChevronUp } from 'lucide-react'
|
||||
import { makeAuthenticatedRequest } from '@/lib/auth'
|
||||
import { DirectoryEntry } from 'shared/types'
|
||||
|
||||
interface FolderPickerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (path: string) => void
|
||||
value?: string
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function FolderPicker({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
value = '',
|
||||
title = 'Select Folder',
|
||||
description = 'Choose a folder for your project'
|
||||
}: FolderPickerProps) {
|
||||
const [currentPath, setCurrentPath] = useState<string>('')
|
||||
const [entries, setEntries] = useState<DirectoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [manualPath, setManualPath] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setManualPath(value)
|
||||
loadDirectory()
|
||||
}
|
||||
}, [open, value])
|
||||
|
||||
const loadDirectory = async (path?: string) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const queryParam = path ? `?path=${encodeURIComponent(path)}` : ''
|
||||
const response = await makeAuthenticatedRequest(`/api/filesystem/list${queryParam}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load directory')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setEntries(data.data || [])
|
||||
const newPath = path || data.message || ''
|
||||
setCurrentPath(newPath)
|
||||
// Update manual path if we have a specific path (not for initial home directory load)
|
||||
if (path) {
|
||||
setManualPath(newPath)
|
||||
}
|
||||
} else {
|
||||
setError(data.message || 'Failed to load directory')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load directory')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFolderClick = (entry: DirectoryEntry) => {
|
||||
if (entry.is_directory) {
|
||||
loadDirectory(entry.path)
|
||||
setManualPath(entry.path) // Auto-populate the manual path field
|
||||
}
|
||||
}
|
||||
|
||||
const handleParentDirectory = () => {
|
||||
const parentPath = currentPath.split('/').slice(0, -1).join('/')
|
||||
const newPath = parentPath || '/'
|
||||
loadDirectory(newPath)
|
||||
setManualPath(newPath)
|
||||
}
|
||||
|
||||
const handleHomeDirectory = () => {
|
||||
loadDirectory()
|
||||
// Don't set manual path here since home directory path varies by system
|
||||
}
|
||||
|
||||
const handleManualPathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setManualPath(e.target.value)
|
||||
}
|
||||
|
||||
const handleManualPathSubmit = () => {
|
||||
loadDirectory(manualPath)
|
||||
}
|
||||
|
||||
const handleSelectCurrent = () => {
|
||||
onSelect(manualPath || currentPath)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSelectManual = () => {
|
||||
onSelect(manualPath)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setError('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[600px] w-full h-[500px] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 flex flex-col space-y-4 overflow-hidden">
|
||||
{/* Legend */}
|
||||
<div className="text-xs text-muted-foreground border-b pb-2">
|
||||
Click folder names to navigate • Use action buttons to select
|
||||
</div>
|
||||
|
||||
{/* Manual path input */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Enter path manually:</div>
|
||||
<div className="flex space-x-2 min-w-0">
|
||||
<Input
|
||||
value={manualPath}
|
||||
onChange={handleManualPathChange}
|
||||
placeholder="/path/to/your/project"
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleManualPathSubmit}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Go
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
<Button
|
||||
onClick={handleHomeDirectory}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleParentDirectory}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!currentPath || currentPath === '/'}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground flex-1 truncate min-w-0">
|
||||
{currentPath || 'Home'}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSelectCurrent}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!currentPath}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Select Current
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Directory listing */}
|
||||
<div className="flex-1 border rounded-md overflow-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert variant="destructive" className="m-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
No folders found
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{entries.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center space-x-2 p-2 rounded cursor-pointer hover:bg-accent ${
|
||||
!entry.is_directory ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
onClick={() => entry.is_directory && handleFolderClick(entry)}
|
||||
title={entry.name} // Show full name on hover
|
||||
>
|
||||
{entry.is_directory ? (
|
||||
entry.is_git_repo ? (
|
||||
<FolderOpen className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 text-blue-600 flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<File className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm flex-1 truncate min-w-0">{entry.name}</span>
|
||||
{entry.is_git_repo && (
|
||||
<span className="text-xs text-green-600 bg-green-100 px-2 py-1 rounded flex-shrink-0">
|
||||
git repo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSelectManual}
|
||||
disabled={!manualPath.trim()}
|
||||
>
|
||||
Select Path
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
rectIntersection,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
} from '@dnd-kit/core';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import type { ReactNode } from 'react';
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type { DragEndEvent } from '@dnd-kit/core';
|
||||
export type { DragEndEvent } from "@dnd-kit/core";
|
||||
|
||||
export type Status = {
|
||||
id: string;
|
||||
@@ -28,7 +31,7 @@ export type Feature = {
|
||||
};
|
||||
|
||||
export type KanbanBoardProps = {
|
||||
id: Status['id'];
|
||||
id: Status["id"];
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
@@ -39,8 +42,8 @@ export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full min-h-40 flex-col gap-2 rounded-md border bg-secondary p-2 text-xs shadow-sm outline outline-2 transition-all',
|
||||
isOver ? 'outline-primary' : 'outline-transparent',
|
||||
"flex h-full min-h-40 flex-col gap-2 rounded-md border bg-secondary p-2 text-xs shadow-sm outline outline-2 transition-all",
|
||||
isOver ? "outline-primary" : "outline-transparent",
|
||||
className
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
@@ -50,11 +53,12 @@ export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export type KanbanCardProps = Pick<Feature, 'id' | 'name'> & {
|
||||
export type KanbanCardProps = Pick<Feature, "id" | "name"> & {
|
||||
index: number;
|
||||
parent: string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const KanbanCard = ({
|
||||
@@ -64,6 +68,7 @@ export const KanbanCard = ({
|
||||
parent,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
}: KanbanCardProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
@@ -74,18 +79,19 @@ export const KanbanCard = ({
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'rounded-md p-3 shadow-sm',
|
||||
isDragging && 'cursor-grabbing',
|
||||
"rounded-md p-3 shadow-sm",
|
||||
isDragging && "cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
transform: transform
|
||||
? `translateX(${transform.x}px) translateY(${transform.y}px)`
|
||||
: 'none',
|
||||
: "none",
|
||||
}}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
ref={setNodeRef}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children ?? <p className="m-0 font-medium text-sm">{name}</p>}
|
||||
</Card>
|
||||
@@ -98,7 +104,7 @@ export type KanbanCardsProps = {
|
||||
};
|
||||
|
||||
export const KanbanCards = ({ children, className }: KanbanCardsProps) => (
|
||||
<div className={cn('flex flex-1 flex-col gap-2', className)}>{children}</div>
|
||||
<div className={cn("flex flex-1 flex-col gap-2", className)}>{children}</div>
|
||||
);
|
||||
|
||||
export type KanbanHeaderProps =
|
||||
@@ -106,16 +112,16 @@ export type KanbanHeaderProps =
|
||||
children: ReactNode;
|
||||
}
|
||||
| {
|
||||
name: Status['name'];
|
||||
color: Status['color'];
|
||||
name: Status["name"];
|
||||
color: Status["color"];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const KanbanHeader = (props: KanbanHeaderProps) =>
|
||||
'children' in props ? (
|
||||
"children" in props ? (
|
||||
props.children
|
||||
) : (
|
||||
<div className={cn('flex shrink-0 items-center gap-2', props.className)}>
|
||||
<div className={cn("flex shrink-0 items-center gap-2", props.className)}>
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: props.color }}
|
||||
@@ -134,12 +140,27 @@ export const KanbanProvider = ({
|
||||
children,
|
||||
onDragEnd,
|
||||
className,
|
||||
}: KanbanProviderProps) => (
|
||||
<DndContext collisionDetection={rectIntersection} onDragEnd={onDragEnd}>
|
||||
<div
|
||||
className={cn('grid w-full auto-cols-fr grid-flow-col gap-4', className)}
|
||||
}: KanbanProviderProps) => {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={rectIntersection}
|
||||
onDragEnd={onDragEnd}
|
||||
sensors={sensors}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full auto-cols-fr grid-flow-col gap-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,39 +2,14 @@ import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { ArrowLeft, Plus, MoreHorizontal, Trash2, Edit } from 'lucide-react'
|
||||
import { ArrowLeft, Plus } from 'lucide-react'
|
||||
import { makeAuthenticatedRequest } from '@/lib/auth'
|
||||
import {
|
||||
KanbanProvider,
|
||||
KanbanBoard,
|
||||
KanbanHeader,
|
||||
KanbanCards,
|
||||
KanbanCard,
|
||||
type DragEndEvent
|
||||
} from '@/components/ui/shadcn-io/kanban'
|
||||
import { TaskCreateDialog } from '@/components/tasks/TaskCreateDialog'
|
||||
import { TaskEditDialog } from '@/components/tasks/TaskEditDialog'
|
||||
import { TaskDetailsDialog } from '@/components/tasks/TaskDetailsDialog'
|
||||
import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard'
|
||||
import type { TaskStatus } from 'shared/types'
|
||||
import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
@@ -62,24 +37,7 @@ interface ApiResponse<T> {
|
||||
|
||||
|
||||
|
||||
// All possible task statuses from shared types
|
||||
const allTaskStatuses: TaskStatus[] = ['todo', 'inprogress', 'inreview', 'done', 'cancelled']
|
||||
|
||||
const statusLabels: Record<TaskStatus, string> = {
|
||||
todo: 'To Do',
|
||||
inprogress: 'In Progress',
|
||||
inreview: 'In Review',
|
||||
done: 'Done',
|
||||
cancelled: 'Cancelled'
|
||||
}
|
||||
|
||||
const statusBoardColors: Record<TaskStatus, string> = {
|
||||
todo: '#64748b',
|
||||
inprogress: '#3b82f6',
|
||||
inreview: '#f59e0b',
|
||||
done: '#22c55e',
|
||||
cancelled: '#ef4444'
|
||||
}
|
||||
|
||||
export function ProjectTasks() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
@@ -91,13 +49,9 @@ export function ProjectTasks() {
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null)
|
||||
const [isTaskDetailsDialogOpen, setIsTaskDetailsDialogOpen] = useState(false)
|
||||
|
||||
// Form states
|
||||
const [newTaskTitle, setNewTaskTitle] = useState('')
|
||||
const [newTaskDescription, setNewTaskDescription] = useState('')
|
||||
const [editTaskTitle, setEditTaskTitle] = useState('')
|
||||
const [editTaskDescription, setEditTaskDescription] = useState('')
|
||||
const [editTaskStatus, setEditTaskStatus] = useState<Task['status']>('todo')
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
@@ -144,24 +98,19 @@ export function ProjectTasks() {
|
||||
}
|
||||
}
|
||||
|
||||
const createTask = async () => {
|
||||
if (!newTaskTitle.trim()) return
|
||||
|
||||
const handleCreateTask = async (title: string, description: string) => {
|
||||
try {
|
||||
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
project_id: projectId,
|
||||
title: newTaskTitle,
|
||||
description: newTaskDescription || null
|
||||
title,
|
||||
description: description || null
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchTasks()
|
||||
setNewTaskTitle('')
|
||||
setNewTaskDescription('')
|
||||
setIsCreateDialogOpen(false)
|
||||
} else {
|
||||
setError('Failed to create task')
|
||||
}
|
||||
@@ -170,23 +119,22 @@ export function ProjectTasks() {
|
||||
}
|
||||
}
|
||||
|
||||
const updateTask = async () => {
|
||||
if (!editingTask || !editTaskTitle.trim()) return
|
||||
const handleUpdateTask = async (title: string, description: string, status: TaskStatus) => {
|
||||
if (!editingTask) return
|
||||
|
||||
try {
|
||||
const response = await makeAuthenticatedRequest(`/api/projects/${projectId}/tasks/${editingTask.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
title: editTaskTitle,
|
||||
description: editTaskDescription || null,
|
||||
status: editTaskStatus
|
||||
title,
|
||||
description: description || null,
|
||||
status
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchTasks()
|
||||
setEditingTask(null)
|
||||
setIsEditDialogOpen(false)
|
||||
} else {
|
||||
setError('Failed to update task')
|
||||
}
|
||||
@@ -195,7 +143,7 @@ export function ProjectTasks() {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTask = async (taskId: string) => {
|
||||
const handleDeleteTask = async (taskId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this task?')) return
|
||||
|
||||
try {
|
||||
@@ -213,14 +161,16 @@ export function ProjectTasks() {
|
||||
}
|
||||
}
|
||||
|
||||
const openEditDialog = (task: Task) => {
|
||||
const handleEditTask = (task: Task) => {
|
||||
setEditingTask(task)
|
||||
setEditTaskTitle(task.title)
|
||||
setEditTaskDescription(task.description || '')
|
||||
setEditTaskStatus(task.status)
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleViewTaskDetails = (task: Task) => {
|
||||
setSelectedTask(task)
|
||||
setIsTaskDetailsDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
@@ -264,27 +214,7 @@ export function ProjectTasks() {
|
||||
}
|
||||
}
|
||||
|
||||
const groupTasksByStatus = () => {
|
||||
const groups: Record<TaskStatus, Task[]> = {} as Record<TaskStatus, Task[]>
|
||||
|
||||
// Initialize groups for all possible statuses
|
||||
allTaskStatuses.forEach(status => {
|
||||
groups[status] = []
|
||||
})
|
||||
|
||||
tasks.forEach(task => {
|
||||
// Convert old capitalized status to lowercase if needed
|
||||
const normalizedStatus = task.status.toLowerCase() as TaskStatus
|
||||
if (groups[normalizedStatus]) {
|
||||
groups[normalizedStatus].push(task)
|
||||
} else {
|
||||
// Default to todo if status doesn't match any expected value
|
||||
groups['todo'].push(task)
|
||||
}
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-8">Loading tasks...</div>
|
||||
@@ -324,43 +254,11 @@ export function ProjectTasks() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
placeholder="Enter task title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={newTaskDescription}
|
||||
onChange={(e) => setNewTaskDescription(e.target.value)}
|
||||
placeholder="Enter task description (optional)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={createTask}>Create Task</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TaskCreateDialog
|
||||
isOpen={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
onCreateTask={handleCreateTask}
|
||||
/>
|
||||
|
||||
{/* Tasks View */}
|
||||
{tasks.length === 0 ? (
|
||||
@@ -377,139 +275,29 @@ export function ProjectTasks() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<KanbanProvider onDragEnd={handleDragEnd}>
|
||||
{Object.entries(groupTasksByStatus()).map(([status, statusTasks]) => (
|
||||
<KanbanBoard key={status} id={status as Task['status']}>
|
||||
<KanbanHeader
|
||||
name={statusLabels[status as Task['status']]}
|
||||
color={statusBoardColors[status as Task['status']]}
|
||||
/>
|
||||
<KanbanCards>
|
||||
{statusTasks.map((task, index) => (
|
||||
<KanbanCard
|
||||
key={task.id}
|
||||
id={task.id}
|
||||
name={task.title}
|
||||
index={index}
|
||||
parent={status}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div
|
||||
className="flex-1 cursor-pointer pr-2"
|
||||
onClick={() => openEditDialog(task)}
|
||||
>
|
||||
<h4 className="font-medium text-sm">
|
||||
{task.title}
|
||||
</h4>
|
||||
</div>
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 hover:bg-gray-100"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openEditDialog(task)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => deleteTask(task.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
{task.description && (
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() => openEditDialog(task)}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</KanbanCard>
|
||||
))}
|
||||
</KanbanCards>
|
||||
</KanbanBoard>
|
||||
))}
|
||||
</KanbanProvider>
|
||||
<TaskKanbanBoard
|
||||
tasks={tasks}
|
||||
onDragEnd={handleDragEnd}
|
||||
onEditTask={handleEditTask}
|
||||
onDeleteTask={handleDeleteTask}
|
||||
onViewTaskDetails={handleViewTaskDetails}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Task Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-title">Title</Label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
value={editTaskTitle}
|
||||
onChange={(e) => setEditTaskTitle(e.target.value)}
|
||||
placeholder="Enter task title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
value={editTaskDescription}
|
||||
onChange={(e) => setEditTaskDescription(e.target.value)}
|
||||
placeholder="Enter task description (optional)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-status">Status</Label>
|
||||
<Select
|
||||
value={editTaskStatus}
|
||||
onValueChange={(value) => setEditTaskStatus(value as Task['status'])}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="inprogress">In Progress</SelectItem>
|
||||
<SelectItem value="inreview">In Review</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsEditDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={updateTask}>Update Task</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TaskEditDialog
|
||||
isOpen={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
task={editingTask}
|
||||
onUpdateTask={handleUpdateTask}
|
||||
/>
|
||||
|
||||
<TaskDetailsDialog
|
||||
isOpen={isTaskDetailsDialogOpen}
|
||||
onOpenChange={setIsTaskDetailsDialogOpen}
|
||||
task={selectedTask}
|
||||
projectId={projectId!}
|
||||
onError={setError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user