Add tasks
This commit is contained in:
@@ -4,6 +4,7 @@ import { LoginForm } from '@/components/auth/login-form'
|
||||
import { Navbar } from '@/components/layout/navbar'
|
||||
import { HomePage } from '@/pages/home'
|
||||
import { Projects } from '@/pages/projects'
|
||||
import { ProjectTasks } from '@/pages/project-tasks'
|
||||
import { Users } from '@/pages/users'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
|
||||
@@ -36,6 +37,7 @@ function AppContent() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/projects/:projectId" element={<Projects />} />
|
||||
<Route path="/projects/:projectId/tasks" element={<ProjectTasks />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Project, ApiResponse } from 'shared/types'
|
||||
import { ProjectForm } from './project-form'
|
||||
import { ArrowLeft, Edit, Trash2, Calendar, Clock, User, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { ArrowLeft, Edit, Trash2, Calendar, Clock, User, AlertCircle, Loader2, CheckSquare } from 'lucide-react'
|
||||
|
||||
interface ProjectDetailProps {
|
||||
projectId: string
|
||||
@@ -13,6 +14,7 @@ interface ProjectDetailProps {
|
||||
}
|
||||
|
||||
export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
||||
const navigate = useNavigate()
|
||||
const [project, setProject] = useState<Project | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showEditForm, setShowEditForm] = useState(false)
|
||||
@@ -111,6 +113,10 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => navigate(`/projects/${projectId}/tasks`)}>
|
||||
<CheckSquare className="mr-2 h-4 w-4" />
|
||||
View Tasks
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowEditForm(true)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Project, ApiResponse } from 'shared/types'
|
||||
import { ProjectForm } from './project-form'
|
||||
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { Plus, Edit, Trash2, Calendar, AlertCircle, Loader2, CheckSquare } from 'lucide-react'
|
||||
|
||||
export function ProjectList() {
|
||||
const navigate = useNavigate()
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
@@ -118,7 +120,12 @@ export function ProjectList() {
|
||||
<Card key={project.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg">{project.name}</CardTitle>
|
||||
<CardTitle
|
||||
className="text-lg cursor-pointer hover:text-primary"
|
||||
onClick={() => navigate(`/projects/${project.id}`)}
|
||||
>
|
||||
{project.name}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
Active
|
||||
</Badge>
|
||||
@@ -130,6 +137,14 @@ export function ProjectList() {
|
||||
</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"
|
||||
|
||||
198
frontend/src/components/ui/dropdown-menu.tsx
Normal file
198
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
160
frontend/src/components/ui/select.tsx
Normal file
160
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
22
frontend/src/components/ui/textarea.tsx
Normal file
22
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
418
frontend/src/pages/project-tasks.tsx
Normal file
418
frontend/src/pages/project-tasks.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 { getAuthHeaders } from '@/lib/auth'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: 'Todo' | 'InProgress' | 'Done' | 'Cancelled'
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
owner_id: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean
|
||||
data: T | null
|
||||
message: string | null
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
Todo: 'bg-slate-100 text-slate-800',
|
||||
InProgress: 'bg-blue-100 text-blue-800',
|
||||
Done: 'bg-green-100 text-green-800',
|
||||
Cancelled: 'bg-red-100 text-red-800'
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
Todo: 'To Do',
|
||||
InProgress: 'In Progress',
|
||||
Done: 'Done',
|
||||
Cancelled: 'Cancelled'
|
||||
}
|
||||
|
||||
export function ProjectTasks() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [project, setProject] = useState<Project | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = 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) {
|
||||
fetchProject()
|
||||
fetchTasks()
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result: ApiResponse<Project> = await response.json()
|
||||
if (result.success && result.data) {
|
||||
setProject(result.data)
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
setError('Project not found')
|
||||
navigate('/projects')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load project')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch(`/api/projects/${projectId}/tasks`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result: ApiResponse<Task[]> = await response.json()
|
||||
if (result.success && result.data) {
|
||||
setTasks(result.data)
|
||||
}
|
||||
} else {
|
||||
setError('Failed to load tasks')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load tasks')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createTask = async () => {
|
||||
if (!newTaskTitle.trim()) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
project_id: projectId,
|
||||
title: newTaskTitle,
|
||||
description: newTaskDescription || null
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchTasks()
|
||||
setNewTaskTitle('')
|
||||
setNewTaskDescription('')
|
||||
setIsCreateDialogOpen(false)
|
||||
} else {
|
||||
setError('Failed to create task')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to create task')
|
||||
}
|
||||
}
|
||||
|
||||
const updateTask = async () => {
|
||||
if (!editingTask || !editTaskTitle.trim()) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/tasks/${editingTask.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: editTaskTitle,
|
||||
description: editTaskDescription || null,
|
||||
status: editTaskStatus
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchTasks()
|
||||
setEditingTask(null)
|
||||
setIsEditDialogOpen(false)
|
||||
} else {
|
||||
setError('Failed to update task')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to update task')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTask = async (taskId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this task?')) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/tasks/${taskId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchTasks()
|
||||
} else {
|
||||
setError('Failed to delete task')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to delete task')
|
||||
}
|
||||
}
|
||||
|
||||
const openEditDialog = (task: Task) => {
|
||||
setEditingTask(task)
|
||||
setEditTaskTitle(task.title)
|
||||
setEditTaskDescription(task.description || '')
|
||||
setEditTaskStatus(task.status)
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-8">Loading tasks...</div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-center py-8 text-red-600">{error}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="flex items-center"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Projects
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{project?.name || 'Project'} Tasks
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage tasks for this project
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Task
|
||||
</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>
|
||||
|
||||
{/* Tasks Grid */}
|
||||
{tasks.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<p className="text-muted-foreground">No tasks found for this project.</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create First Task
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{tasks.map((task) => (
|
||||
<Card key={task.id} className="relative">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg">{task.title}</CardTitle>
|
||||
<Badge
|
||||
className={`mt-2 ${statusColors[task.status]}`}
|
||||
variant="secondary"
|
||||
>
|
||||
{statusLabels[task.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<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>
|
||||
</CardHeader>
|
||||
{task.description && (
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{task.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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="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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user