Files
TaskTracker/TaskTracker.Web/src/components/TaskDetailPanel.tsx

442 lines
18 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react'
import { motion } from 'framer-motion'
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()
// 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)
// 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
}
onClose()
}
},
[editingTitle, editingDesc, editingCategory, editingEstimate, onClose]
)
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])
// --- 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 */}
<motion.div
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Panel */}
<motion.div
className="fixed top-0 right-0 h-full w-[480px] z-50 bg-[var(--color-elevated)]/95 backdrop-blur-xl border-l border-[var(--color-border)] shadow-2xl flex flex-col"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
>
{isLoading || !task ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="animate-spin text-[var(--color-text-secondary)]" size={32} />
</div>
) : (
<>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto">
{/* Header */}
<div className="p-5 pb-4">
{/* Title row with close button inline */}
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
{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-[var(--color-page)] text-xl font-semibold text-[var(--color-text-primary)] px-3 py-2 rounded border border-[var(--color-accent)] outline-none"
/>
) : (
<h2
className="text-xl font-semibold text-[var(--color-text-primary)] cursor-pointer hover:text-[var(--color-accent)] transition-colors"
onClick={() => {
setTitleValue(task.title)
setEditingTitle(true)
}}
>
{task.title}
</h2>
)}
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-white/10 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors shrink-0 mt-0.5"
>
<X size={18} />
</button>
</div>
{/* Status badge + Category */}
<div className="flex items-center gap-2 mt-3">
{statusConfig && (
<span
className="text-[10px] 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-[var(--color-page)] text-xs text-[var(--color-text-primary)] px-2 py-1 rounded border border-[var(--color-accent)] outline-none w-28"
/>
) : (
<span
className="text-[11px] text-[var(--color-text-secondary)] cursor-pointer hover:text-[var(--color-text-primary)] 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-[var(--color-border)]" />
{/* Description */}
<div className="p-5">
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] 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-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-3 py-2 rounded border border-[var(--color-accent)] 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-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] italic'
}`}
onClick={() => {
setDescValue(task.description ?? '')
setEditingDesc(true)
}}
>
{task.description || 'Add a description...'}
</p>
)}
</div>
<div className="border-t border-[var(--color-border)]" />
{/* Time */}
<div className="p-5">
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] mb-3">
Time
</h3>
<div className="grid grid-cols-2 gap-4 mb-3">
<div>
<span className="text-[11px] text-[var(--color-text-secondary)] block mb-1">Elapsed</span>
<span className="text-sm text-[var(--color-text-primary)] font-medium">
{task.startedAt ? formatElapsed(task.startedAt, task.completedAt) : '--'}
</span>
</div>
<div>
<span className="text-[11px] text-[var(--color-text-secondary)] 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-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-2 py-1 rounded border border-[var(--color-accent)] outline-none"
/>
) : (
<span
className="text-sm text-[var(--color-text-primary)] font-medium cursor-pointer hover:text-[var(--color-accent)] 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-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)]'
}`}
style={{ width: `${Math.min(progressPercent, 100)}%` }}
/>
</div>
)}
</div>
<div className="border-t border-[var(--color-border)]" />
{/* Subtasks */}
<div className="p-5">
<SubtaskList taskId={taskId} subtasks={task.subTasks ?? []} />
</div>
<div className="border-t border-[var(--color-border)]" />
{/* 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-[var(--color-border)] 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-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 text-white text-sm font-medium transition-all 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-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 text-white text-sm font-medium transition-all 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>
)}
</>
)}
</motion.div>
</>
)
}