feat: add inline create task form in Pending column
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
125
TaskTracker.Web/src/components/CreateTaskForm.tsx
Normal file
125
TaskTracker.Web/src/components/CreateTaskForm.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useRef, useState, useEffect } from 'react'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { useCreateTask } from '../api/tasks.ts'
|
||||||
|
import { CATEGORY_COLORS } from '../lib/constants.ts'
|
||||||
|
|
||||||
|
interface CreateTaskFormProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = Object.keys(CATEGORY_COLORS).filter((k) => k !== 'Unknown')
|
||||||
|
|
||||||
|
export default function CreateTaskForm({ onClose }: CreateTaskFormProps) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [category, setCategory] = useState('')
|
||||||
|
const [estimatedMinutes, setEstimatedMinutes] = useState('')
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null)
|
||||||
|
const createTask = useCreateTask()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
titleRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
const trimmed = title.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
|
||||||
|
createTask.mutate(
|
||||||
|
{
|
||||||
|
title: trimmed,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
category: category || undefined,
|
||||||
|
estimatedMinutes: estimatedMinutes ? Number(estimatedMinutes) : undefined,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => onClose() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'TEXTAREA') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full rounded-md bg-[#0f1117] text-white text-sm px-3 py-2 border border-white/10 placeholder-[#64748b] focus:outline-none focus:ring-2 focus:ring-indigo-500/60 focus:border-transparent transition-colors'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg bg-[#1a1d27] p-3 space-y-3"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<input
|
||||||
|
ref={titleRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Task title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<textarea
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
rows={2}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className={`${inputClass} resize-none`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Category + Estimated Minutes row */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
className={`${inputClass} flex-1 appearance-none cursor-pointer`}
|
||||||
|
>
|
||||||
|
<option value="">Category</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat} value={cat}>
|
||||||
|
{cat}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Est. min"
|
||||||
|
min={1}
|
||||||
|
value={estimatedMinutes}
|
||||||
|
onChange={(e) => setEstimatedMinutes(e.target.value)}
|
||||||
|
className={`${inputClass} w-24`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-xs text-[#64748b] hover:text-white transition-colors px-2 py-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!title.trim() || createTask.isPending}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-medium text-white bg-indigo-600 hover:bg-indigo-500
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed
|
||||||
|
px-3 py-1.5 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{createTask.isPending && <Loader2 size={12} className="animate-spin" />}
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useDroppable } from '@dnd-kit/core'
|
import { useDroppable } from '@dnd-kit/core'
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import type { WorkTask } from '../types/index.ts'
|
import type { WorkTask } from '../types/index.ts'
|
||||||
import TaskCard from './TaskCard.tsx'
|
import TaskCard from './TaskCard.tsx'
|
||||||
|
import CreateTaskForm from './CreateTaskForm.tsx'
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
status: number
|
status: number
|
||||||
@@ -19,9 +21,9 @@ export default function KanbanColumn({
|
|||||||
color,
|
color,
|
||||||
tasks,
|
tasks,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
onAddTask,
|
|
||||||
}: KanbanColumnProps) {
|
}: KanbanColumnProps) {
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: `column-${status}` })
|
const { setNodeRef, isOver } = useDroppable({ id: `column-${status}` })
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
|
||||||
const taskIds = tasks.map((t) => t.id)
|
const taskIds = tasks.map((t) => t.id)
|
||||||
|
|
||||||
@@ -62,17 +64,23 @@ export default function KanbanColumn({
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add task button (Pending column only) */}
|
{/* Add task form / button (Pending column only) */}
|
||||||
{status === 0 && onAddTask && (
|
{status === 0 && (
|
||||||
<button
|
<div className="px-3 pb-3">
|
||||||
onClick={onAddTask}
|
{showForm ? (
|
||||||
className="flex items-center justify-center gap-1.5 mx-3 mb-3 py-2 rounded-lg
|
<CreateTaskForm onClose={() => setShowForm(false)} />
|
||||||
text-xs text-[#64748b] border border-dashed border-white/10
|
) : (
|
||||||
hover:text-white hover:border-white/20 transition-colors duration-150"
|
<button
|
||||||
>
|
onClick={() => setShowForm(true)}
|
||||||
<Plus size={14} />
|
className="flex items-center justify-center gap-1.5 w-full py-2 rounded-lg
|
||||||
Add Task
|
text-xs text-[#64748b] border border-dashed border-white/10
|
||||||
</button>
|
hover:text-white hover:border-white/20 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Add Task
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user