feat: add task detail slide-over panel with inline editing, subtasks, and notes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
12
TaskTracker.Web/src/api/notes.ts
Normal file
12
TaskTracker.Web/src/api/notes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { request } from './client'
|
||||
import type { TaskNote } from '../types'
|
||||
|
||||
export function useCreateNote() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ taskId, content, type }: { taskId: number; content: string; type: number }) =>
|
||||
request<TaskNote>({ method: 'POST', url: `/tasks/${taskId}/notes`, data: { content, type } }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
|
||||
})
|
||||
}
|
||||
130
TaskTracker.Web/src/components/NotesList.tsx
Normal file
130
TaskTracker.Web/src/components/NotesList.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { NoteType } from '../types/index.ts'
|
||||
import type { TaskNote } from '../types/index.ts'
|
||||
import { useCreateNote } from '../api/notes.ts'
|
||||
|
||||
interface NotesListProps {
|
||||
taskId: number
|
||||
notes: TaskNote[]
|
||||
}
|
||||
|
||||
const NOTE_TYPE_CONFIG: Record<number, { label: string; color: string }> = {
|
||||
[NoteType.PauseNote]: { label: 'Pause', color: '#f59e0b' },
|
||||
[NoteType.ResumeNote]: { label: 'Resume', color: '#6366f1' },
|
||||
[NoteType.General]: { label: 'General', color: '#64748b' },
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = Date.now()
|
||||
const diffMs = now - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60_000)
|
||||
|
||||
if (diffMins < 1) return 'just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
if (diffDays === 1) return 'yesterday'
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
const diffWeeks = Math.floor(diffDays / 7)
|
||||
if (diffWeeks < 5) return `${diffWeeks}w ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
export default function NotesList({ taskId, notes }: NotesListProps) {
|
||||
const [showInput, setShowInput] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const createNote = useCreateNote()
|
||||
|
||||
useEffect(() => {
|
||||
if (showInput) inputRef.current?.focus()
|
||||
}, [showInput])
|
||||
|
||||
// Chronological order (oldest first)
|
||||
const sortedNotes = [...notes].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
)
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter' && inputValue.trim()) {
|
||||
createNote.mutate(
|
||||
{ taskId, content: inputValue.trim(), type: NoteType.General },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setInputValue('')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setShowInput(false)
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b]">
|
||||
Notes
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowInput(true)}
|
||||
className="p-1 rounded hover:bg-white/5 text-[#64748b] hover:text-white transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{sortedNotes.map((note) => {
|
||||
const typeConfig = NOTE_TYPE_CONFIG[note.type] ?? NOTE_TYPE_CONFIG[NoteType.General]
|
||||
return (
|
||||
<div key={note.id} className="text-sm">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className="text-[10px] font-semibold uppercase px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: typeConfig.color + '20',
|
||||
color: typeConfig.color,
|
||||
}}
|
||||
>
|
||||
{typeConfig.label}
|
||||
</span>
|
||||
<span className="text-[11px] text-[#64748b]">
|
||||
{formatRelativeTime(note.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[#c4c9d4] leading-relaxed">{note.content}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{sortedNotes.length === 0 && !showInput && (
|
||||
<p className="text-sm text-[#64748b] italic">No notes yet</p>
|
||||
)}
|
||||
|
||||
{showInput && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => {
|
||||
if (!inputValue.trim()) {
|
||||
setShowInput(false)
|
||||
setInputValue('')
|
||||
}
|
||||
}}
|
||||
placeholder="Add a note..."
|
||||
className="w-full bg-[#0f1117] text-sm text-white px-3 py-2 rounded border border-transparent focus:border-indigo-500 outline-none placeholder-[#64748b]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
TaskTracker.Web/src/components/SubtaskList.tsx
Normal file
108
TaskTracker.Web/src/components/SubtaskList.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Plus, Square, CheckSquare } from 'lucide-react'
|
||||
import { WorkTaskStatus } from '../types/index.ts'
|
||||
import type { WorkTask } from '../types/index.ts'
|
||||
import { useCreateTask, useCompleteTask } from '../api/tasks.ts'
|
||||
|
||||
interface SubtaskListProps {
|
||||
taskId: number
|
||||
subtasks: WorkTask[]
|
||||
}
|
||||
|
||||
export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
|
||||
const [showInput, setShowInput] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const createTask = useCreateTask()
|
||||
const completeTask = useCompleteTask()
|
||||
|
||||
useEffect(() => {
|
||||
if (showInput) inputRef.current?.focus()
|
||||
}, [showInput])
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter' && inputValue.trim()) {
|
||||
createTask.mutate(
|
||||
{ title: inputValue.trim(), parentTaskId: taskId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setInputValue('')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setShowInput(false)
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(subtask: WorkTask) {
|
||||
if (subtask.status !== WorkTaskStatus.Completed) {
|
||||
completeTask.mutate(subtask.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b]">
|
||||
Subtasks
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowInput(true)}
|
||||
className="p-1 rounded hover:bg-white/5 text-[#64748b] hover:text-white transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{subtasks.map((subtask) => {
|
||||
const isCompleted = subtask.status === WorkTaskStatus.Completed
|
||||
return (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className="flex items-center gap-2 py-1.5 px-1 rounded hover:bg-white/5 cursor-pointer group"
|
||||
onClick={() => handleToggle(subtask)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckSquare size={16} className="text-emerald-400 flex-shrink-0" />
|
||||
) : (
|
||||
<Square size={16} className="text-[#64748b] group-hover:text-white flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={`text-sm ${
|
||||
isCompleted ? 'line-through text-[#64748b]' : 'text-white'
|
||||
}`}
|
||||
>
|
||||
{subtask.title}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{showInput && (
|
||||
<div className="flex items-center gap-2 py-1.5 px-1">
|
||||
<Square size={16} className="text-[#64748b] flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => {
|
||||
if (!inputValue.trim()) {
|
||||
setShowInput(false)
|
||||
setInputValue('')
|
||||
}
|
||||
}}
|
||||
placeholder="New subtask..."
|
||||
className="flex-1 bg-[#0f1117] text-sm text-white px-2 py-1 rounded border border-transparent focus:border-indigo-500 outline-none placeholder-[#64748b]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
448
TaskTracker.Web/src/components/TaskDetailPanel.tsx
Normal file
448
TaskTracker.Web/src/components/TaskDetailPanel.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { X, Loader2 } from 'lucide-react'
|
||||
import { WorkTaskStatus } from '../types/index.ts'
|
||||
import {
|
||||
useTask,
|
||||
useUpdateTask,
|
||||
useStartTask,
|
||||
usePauseTask,
|
||||
useResumeTask,
|
||||
useCompleteTask,
|
||||
useAbandonTask,
|
||||
} from '../api/tasks.ts'
|
||||
import { COLUMN_CONFIG } from '../lib/constants.ts'
|
||||
import SubtaskList from './SubtaskList.tsx'
|
||||
import NotesList from './NotesList.tsx'
|
||||
|
||||
interface TaskDetailPanelProps {
|
||||
taskId: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function formatElapsed(startedAt: string, completedAt: string | null): string {
|
||||
const start = new Date(startedAt).getTime()
|
||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now()
|
||||
const mins = Math.floor((end - start) / 60_000)
|
||||
if (mins < 60) return `${mins}m`
|
||||
const hours = Math.floor(mins / 60)
|
||||
const remainder = mins % 60
|
||||
if (hours < 24) return `${hours}h ${remainder}m`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ${hours % 24}h`
|
||||
}
|
||||
|
||||
export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProps) {
|
||||
const { data: task, isLoading } = useTask(taskId)
|
||||
const updateTask = useUpdateTask()
|
||||
const startTask = useStartTask()
|
||||
const pauseTask = usePauseTask()
|
||||
const resumeTask = useResumeTask()
|
||||
const completeTask = useCompleteTask()
|
||||
const abandonTask = useAbandonTask()
|
||||
|
||||
// Slide-in animation state
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
// Inline editing states
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleValue, setTitleValue] = useState('')
|
||||
const [editingDesc, setEditingDesc] = useState(false)
|
||||
const [descValue, setDescValue] = useState('')
|
||||
const [editingCategory, setEditingCategory] = useState(false)
|
||||
const [categoryValue, setCategoryValue] = useState('')
|
||||
const [editingEstimate, setEditingEstimate] = useState(false)
|
||||
const [estimateValue, setEstimateValue] = useState('')
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement>(null)
|
||||
const descInputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const categoryInputRef = useRef<HTMLInputElement>(null)
|
||||
const estimateInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Trigger slide-in
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true))
|
||||
}, [])
|
||||
|
||||
// Escape key handler
|
||||
const handleEscape = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
// If editing, cancel editing first
|
||||
if (editingTitle || editingDesc || editingCategory || editingEstimate) {
|
||||
setEditingTitle(false)
|
||||
setEditingDesc(false)
|
||||
setEditingCategory(false)
|
||||
setEditingEstimate(false)
|
||||
return
|
||||
}
|
||||
handleClose()
|
||||
}
|
||||
},
|
||||
[editingTitle, editingDesc, editingCategory, editingEstimate] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
|
||||
// Focus inputs when entering edit mode
|
||||
useEffect(() => {
|
||||
if (editingTitle) titleInputRef.current?.focus()
|
||||
}, [editingTitle])
|
||||
useEffect(() => {
|
||||
if (editingDesc) descInputRef.current?.focus()
|
||||
}, [editingDesc])
|
||||
useEffect(() => {
|
||||
if (editingCategory) categoryInputRef.current?.focus()
|
||||
}, [editingCategory])
|
||||
useEffect(() => {
|
||||
if (editingEstimate) estimateInputRef.current?.focus()
|
||||
}, [editingEstimate])
|
||||
|
||||
function handleClose() {
|
||||
setVisible(false)
|
||||
setTimeout(onClose, 200) // wait for slide-out animation
|
||||
}
|
||||
|
||||
// --- Save handlers ---
|
||||
function saveTitle() {
|
||||
if (task && titleValue.trim() && titleValue.trim() !== task.title) {
|
||||
updateTask.mutate({ id: taskId, title: titleValue.trim() })
|
||||
}
|
||||
setEditingTitle(false)
|
||||
}
|
||||
|
||||
function saveDescription() {
|
||||
if (task && descValue !== (task.description ?? '')) {
|
||||
updateTask.mutate({ id: taskId, description: descValue })
|
||||
}
|
||||
setEditingDesc(false)
|
||||
}
|
||||
|
||||
function saveCategory() {
|
||||
if (task && categoryValue.trim() !== (task.category ?? '')) {
|
||||
updateTask.mutate({ id: taskId, category: categoryValue.trim() || undefined })
|
||||
}
|
||||
setEditingCategory(false)
|
||||
}
|
||||
|
||||
function saveEstimate() {
|
||||
const val = estimateValue.trim() === '' ? undefined : parseInt(estimateValue, 10)
|
||||
if (task) {
|
||||
const newVal = val && !isNaN(val) ? val : undefined
|
||||
if (newVal !== (task.estimatedMinutes ?? undefined)) {
|
||||
updateTask.mutate({ id: taskId, estimatedMinutes: newVal })
|
||||
}
|
||||
}
|
||||
setEditingEstimate(false)
|
||||
}
|
||||
|
||||
// --- Status helpers ---
|
||||
const statusConfig = COLUMN_CONFIG.find((c) => c.status === task?.status)
|
||||
|
||||
// Progress percent
|
||||
let progressPercent: number | null = null
|
||||
if (task?.estimatedMinutes && task.startedAt) {
|
||||
const start = new Date(task.startedAt).getTime()
|
||||
const end = task.completedAt ? new Date(task.completedAt).getTime() : Date.now()
|
||||
const elapsedMins = (end - start) / 60_000
|
||||
progressPercent = Math.min(100, (elapsedMins / task.estimatedMinutes) * 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className={`fixed inset-0 z-40 transition-opacity duration-200 ${
|
||||
visible ? 'bg-black/50' : 'bg-black/0'
|
||||
}`}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-[400px] z-50 bg-[#1a1d27] shadow-2xl flex flex-col transition-transform duration-200 ease-out ${
|
||||
visible ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{isLoading || !task ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="animate-spin text-[#64748b]" size={32} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="p-5 pb-4">
|
||||
{/* Close button */}
|
||||
<div className="flex justify-end mb-3">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1 rounded hover:bg-white/10 text-[#64748b] hover:text-white transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
{editingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
value={titleValue}
|
||||
onChange={(e) => setTitleValue(e.target.value)}
|
||||
onBlur={saveTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveTitle()
|
||||
if (e.key === 'Escape') setEditingTitle(false)
|
||||
}}
|
||||
className="w-full bg-[#0f1117] text-lg font-semibold text-white px-3 py-2 rounded border border-indigo-500 outline-none"
|
||||
/>
|
||||
) : (
|
||||
<h2
|
||||
className="text-lg font-semibold text-white cursor-pointer hover:text-indigo-300 transition-colors"
|
||||
onClick={() => {
|
||||
setTitleValue(task.title)
|
||||
setEditingTitle(true)
|
||||
}}
|
||||
>
|
||||
{task.title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Status badge + Category */}
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
{statusConfig && (
|
||||
<span
|
||||
className="text-[11px] font-semibold uppercase px-2.5 py-1 rounded-full"
|
||||
style={{
|
||||
backgroundColor: statusConfig.color + '20',
|
||||
color: statusConfig.color,
|
||||
}}
|
||||
>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{editingCategory ? (
|
||||
<input
|
||||
ref={categoryInputRef}
|
||||
type="text"
|
||||
value={categoryValue}
|
||||
onChange={(e) => setCategoryValue(e.target.value)}
|
||||
onBlur={saveCategory}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveCategory()
|
||||
if (e.key === 'Escape') setEditingCategory(false)
|
||||
}}
|
||||
placeholder="Category..."
|
||||
className="bg-[#0f1117] text-xs text-white px-2 py-1 rounded border border-indigo-500 outline-none w-28"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-[11px] text-[#64748b] cursor-pointer hover:text-white transition-colors px-2.5 py-1 rounded-full bg-white/5"
|
||||
onClick={() => {
|
||||
setCategoryValue(task.category ?? '')
|
||||
setEditingCategory(true)
|
||||
}}
|
||||
>
|
||||
{task.category || 'Add category'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#2a2d37]" />
|
||||
|
||||
{/* Description */}
|
||||
<div className="p-5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b] mb-2">
|
||||
Description
|
||||
</h3>
|
||||
{editingDesc ? (
|
||||
<textarea
|
||||
ref={descInputRef}
|
||||
value={descValue}
|
||||
onChange={(e) => setDescValue(e.target.value)}
|
||||
onBlur={saveDescription}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setEditingDesc(false)
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
rows={4}
|
||||
className="w-full bg-[#0f1117] text-sm text-white px-3 py-2 rounded border border-indigo-500 outline-none resize-none"
|
||||
placeholder="Add a description..."
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className={`text-sm cursor-pointer rounded px-3 py-2 hover:bg-white/5 transition-colors ${
|
||||
task.description ? 'text-[#c4c9d4]' : 'text-[#64748b] italic'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setDescValue(task.description ?? '')
|
||||
setEditingDesc(true)
|
||||
}}
|
||||
>
|
||||
{task.description || 'Add a description...'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#2a2d37]" />
|
||||
|
||||
{/* Time */}
|
||||
<div className="p-5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b] mb-3">
|
||||
Time
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-3">
|
||||
<div>
|
||||
<span className="text-[11px] text-[#64748b] block mb-1">Elapsed</span>
|
||||
<span className="text-sm text-white font-medium">
|
||||
{task.startedAt ? formatElapsed(task.startedAt, task.completedAt) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[11px] text-[#64748b] block mb-1">Estimate</span>
|
||||
{editingEstimate ? (
|
||||
<input
|
||||
ref={estimateInputRef}
|
||||
type="number"
|
||||
value={estimateValue}
|
||||
onChange={(e) => setEstimateValue(e.target.value)}
|
||||
onBlur={saveEstimate}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveEstimate()
|
||||
if (e.key === 'Escape') setEditingEstimate(false)
|
||||
}}
|
||||
placeholder="min"
|
||||
className="w-full bg-[#0f1117] text-sm text-white px-2 py-1 rounded border border-indigo-500 outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm text-white font-medium cursor-pointer hover:text-indigo-300 transition-colors"
|
||||
onClick={() => {
|
||||
setEstimateValue(task.estimatedMinutes?.toString() ?? '')
|
||||
setEditingEstimate(true)
|
||||
}}
|
||||
>
|
||||
{task.estimatedMinutes ? `${task.estimatedMinutes}m` : '--'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{progressPercent !== null && (
|
||||
<div className="h-2 w-full bg-white/5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
progressPercent >= 100 ? 'bg-rose-500' : 'bg-indigo-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(progressPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#2a2d37]" />
|
||||
|
||||
{/* Subtasks */}
|
||||
<div className="p-5">
|
||||
<SubtaskList taskId={taskId} subtasks={task.subTasks ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#2a2d37]" />
|
||||
|
||||
{/* Notes */}
|
||||
<div className="p-5">
|
||||
<NotesList taskId={taskId} notes={task.notes ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons - fixed at bottom */}
|
||||
{task.status !== WorkTaskStatus.Completed && task.status !== WorkTaskStatus.Abandoned && (
|
||||
<div className="border-t border-[#2a2d37] p-5 space-y-2">
|
||||
{task.status === WorkTaskStatus.Pending && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => startTask.mutate(taskId)}
|
||||
disabled={startTask.isPending}
|
||||
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
onClick={() => abandonTask.mutate(taskId)}
|
||||
disabled={abandonTask.isPending}
|
||||
className="w-full py-2.5 rounded-lg bg-transparent border border-rose-500/30 hover:bg-rose-500/10 text-rose-400 text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Abandon
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{task.status === WorkTaskStatus.Active && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => pauseTask.mutate({ id: taskId })}
|
||||
disabled={pauseTask.isPending}
|
||||
className="w-full py-2.5 rounded-lg bg-amber-600 hover:bg-amber-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
<button
|
||||
onClick={() => completeTask.mutate(taskId)}
|
||||
disabled={completeTask.isPending}
|
||||
className="w-full py-2.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Complete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => abandonTask.mutate(taskId)}
|
||||
disabled={abandonTask.isPending}
|
||||
className="w-full py-2.5 rounded-lg bg-transparent border border-rose-500/30 hover:bg-rose-500/10 text-rose-400 text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Abandon
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{task.status === WorkTaskStatus.Paused && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => resumeTask.mutate({ id: taskId })}
|
||||
disabled={resumeTask.isPending}
|
||||
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<button
|
||||
onClick={() => completeTask.mutate(taskId)}
|
||||
disabled={completeTask.isPending}
|
||||
className="w-full py-2.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Complete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => abandonTask.mutate(taskId)}
|
||||
disabled={abandonTask.isPending}
|
||||
className="w-full py-2.5 rounded-lg bg-transparent border border-rose-500/30 hover:bg-rose-500/10 text-rose-400 text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Abandon
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import KanbanBoard from '../components/KanbanBoard.tsx'
|
||||
import TaskDetailPanel from '../components/TaskDetailPanel.tsx'
|
||||
|
||||
export default function Board() {
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null)
|
||||
|
||||
// selectedTaskId will be used by TaskDetailPanel in Task 6
|
||||
void selectedTaskId
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<KanbanBoard onTaskClick={(id) => setSelectedTaskId(id)} />
|
||||
{/* TaskDetailPanel will be added in Task 6 */}
|
||||
{selectedTaskId !== null && (
|
||||
<TaskDetailPanel taskId={selectedTaskId} onClose={() => setSelectedTaskId(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user