feat: editable task fields in detail panel

- Click-to-edit title and description (inline editing)
- Clickable priority selector (toggle between critical/high/medium/low)
- Clickable source selector (toggle between all sources)
- Saving indicator
- EditableText component with Escape to cancel, Enter/blur to save
- Pass token and onTaskUpdated props from App
This commit is contained in:
2026-01-29 00:59:21 +00:00
parent 76b9c61b09
commit 210fba6027
2 changed files with 178 additions and 20 deletions

View File

@@ -288,7 +288,9 @@ function Dashboard() {
handleStatusChange(id, status); handleStatusChange(id, status);
setSelectedTask(null); setSelectedTask(null);
}} }}
onTaskUpdated={refresh}
hasToken={hasToken} hasToken={hasToken}
token={token}
/> />
)} )}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import type { Task, TaskStatus, TaskPriority } from "../lib/types"; import type { Task, TaskStatus, TaskPriority, TaskSource } from "../lib/types";
import { updateTask } from "../lib/api";
const priorityColors: Record<TaskPriority, string> = { const priorityColors: Record<TaskPriority, string> = {
critical: "bg-red-500 text-white", critical: "bg-red-500 text-white",
@@ -61,6 +62,9 @@ const statusActions: Record<TaskStatus, { label: string; next: TaskStatus; color
cancelled: [{ label: "🔄 Requeue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }], cancelled: [{ label: "🔄 Requeue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }],
}; };
const allPriorities: TaskPriority[] = ["critical", "high", "medium", "low"];
const allSources: TaskSource[] = ["donovan", "david", "hammer", "heartbeat", "cron", "other"];
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString(undefined, { return new Date(dateStr).toLocaleString(undefined, {
month: "short", month: "short",
@@ -118,6 +122,73 @@ function ElapsedTimer({ since }: { since: string }) {
); );
} }
function EditableText({
value,
onSave,
multiline = false,
className = "",
placeholder = "Click to edit...",
}: {
value: string;
onSave: (val: string) => void;
multiline?: boolean;
className?: string;
placeholder?: string;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const ref = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
useEffect(() => { setDraft(value); }, [value]);
useEffect(() => { if (editing) ref.current?.focus(); }, [editing]);
const save = () => {
setEditing(false);
if (draft.trim() !== value) onSave(draft.trim());
};
if (!editing) {
return (
<div
onClick={() => setEditing(true)}
className={`cursor-pointer rounded-md px-2 py-1 -mx-2 -my-1 hover:bg-gray-100 transition group ${className}`}
title="Click to edit"
>
{value || <span className="text-gray-400 italic">{placeholder}</span>}
<span className="text-gray-300 opacity-0 group-hover:opacity-100 ml-1 text-xs"></span>
</div>
);
}
if (multiline) {
return (
<textarea
ref={ref as React.RefObject<HTMLTextAreaElement>}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={save}
onKeyDown={(e) => { if (e.key === "Escape") { setDraft(value); setEditing(false); } }}
className={`w-full rounded-md border border-blue-300 bg-white px-2 py-1 -mx-2 -my-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 resize-y min-h-[60px] ${className}`}
rows={3}
/>
);
}
return (
<input
ref={ref as React.RefObject<HTMLInputElement>}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={save}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") { setDraft(value); setEditing(false); }
}}
className={`w-full rounded-md border border-blue-300 bg-white px-2 py-1 -mx-2 -my-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 ${className}`}
/>
);
}
function CopyableId({ id, taskNumber }: { id: string; taskNumber?: number }) { function CopyableId({ id, taskNumber }: { id: string; taskNumber?: number }) {
const [copied, setCopied] = useState<string | null>(null); const [copied, setCopied] = useState<string | null>(null);
const displayId = taskNumber ? `HQ-${taskNumber}` : null; const displayId = taskNumber ? `HQ-${taskNumber}` : null;
@@ -167,12 +238,28 @@ interface TaskDetailPanelProps {
task: Task; task: Task;
onClose: () => void; onClose: () => void;
onStatusChange: (id: string, status: TaskStatus) => void; onStatusChange: (id: string, status: TaskStatus) => void;
onTaskUpdated: () => void;
hasToken: boolean; hasToken: boolean;
token: string;
} }
export function TaskDetailPanel({ task, onClose, onStatusChange, hasToken }: TaskDetailPanelProps) { export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, hasToken, token }: TaskDetailPanelProps) {
const actions = statusActions[task.status] || []; const actions = statusActions[task.status] || [];
const isActive = task.status === "active"; const isActive = task.status === "active";
const [saving, setSaving] = useState(false);
const handleFieldSave = async (field: string, value: string) => {
if (!hasToken) return;
setSaving(true);
try {
await updateTask(task.id, { [field]: value }, token);
onTaskUpdated();
} catch (e) {
console.error("Failed to update:", e);
} finally {
setSaving(false);
}
};
return ( return (
<> <>
@@ -188,27 +275,32 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, hasToken }: Tas
<div className={`px-6 py-4 border-b ${isActive ? "bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200" : "bg-gray-50 border-gray-200"}`}> <div className={`px-6 py-4 border-b ${isActive ? "bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200" : "bg-gray-50 border-gray-200"}`}>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2 flex-wrap">
{isActive && ( {isActive && (
<span className="relative flex h-3 w-3"> <span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-amber-500"></span> <span className="relative inline-flex rounded-full h-3 w-3 bg-amber-500"></span>
</span> </span>
)} )}
{task.taskNumber && (
<span className="text-xs font-bold text-amber-700 bg-amber-100 px-2 py-0.5 rounded font-mono">
HQ-{task.taskNumber}
</span>
)}
<span className={`text-xs px-2.5 py-1 rounded-full font-semibold border ${statusColors[task.status]}`}> <span className={`text-xs px-2.5 py-1 rounded-full font-semibold border ${statusColors[task.status]}`}>
{statusIcons[task.status]} {task.status.toUpperCase()} {statusIcons[task.status]} {task.status.toUpperCase()}
</span> </span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${priorityColors[task.priority]}`}> {saving && <span className="text-xs text-blue-500 animate-pulse">Saving...</span>}
{priorityIcons[task.priority]} {task.priority}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${sourceColors[task.source] || sourceColors.other}`}>
{task.source}
</span>
</div> </div>
<h2 className="text-lg font-bold text-gray-900 leading-snug"> {hasToken ? (
{task.taskNumber && <span className="text-amber-600 mr-2">HQ-{task.taskNumber}</span>} <EditableText
{task.title} value={task.title}
</h2> onSave={(val) => handleFieldSave("title", val)}
className="text-lg font-bold text-gray-900 leading-snug"
/>
) : (
<h2 className="text-lg font-bold text-gray-900 leading-snug">{task.title}</h2>
)}
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
@@ -224,13 +316,77 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, hasToken }: Tas
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{/* Description */} {/* Priority & Source */}
{task.description && ( <div className="px-6 py-4 border-b border-gray-100">
<div className="px-6 py-4 border-b border-gray-100"> <div className="grid grid-cols-2 gap-4">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Description</h3> <div>
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">{task.description}</p> <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Priority</h3>
{hasToken ? (
<div className="flex flex-wrap gap-1.5">
{allPriorities.map((p) => (
<button
key={p}
onClick={() => handleFieldSave("priority", p)}
className={`text-xs px-2.5 py-1 rounded-full font-medium transition ${
p === task.priority
? priorityColors[p]
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
}`}
>
{priorityIcons[p]} {p}
</button>
))}
</div>
) : (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${priorityColors[task.priority]}`}>
{priorityIcons[task.priority]} {task.priority}
</span>
)}
</div>
<div>
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Source</h3>
{hasToken ? (
<div className="flex flex-wrap gap-1.5">
{allSources.map((s) => (
<button
key={s}
onClick={() => handleFieldSave("source", s)}
className={`text-xs px-2.5 py-1 rounded-full font-medium transition ${
s === task.source
? sourceColors[s] || sourceColors.other
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
}`}
>
{s}
</button>
))}
</div>
) : (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${sourceColors[task.source] || sourceColors.other}`}>
{task.source}
</span>
)}
</div>
</div> </div>
)} </div>
{/* Description */}
<div className="px-6 py-4 border-b border-gray-100">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Description</h3>
{hasToken ? (
<EditableText
value={task.description || ""}
onSave={(val) => handleFieldSave("description", val)}
multiline
className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap"
placeholder="Add a description..."
/>
) : (
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">
{task.description || <span className="text-gray-400 italic">No description</span>}
</p>
)}
</div>
{/* Time Info */} {/* Time Info */}
<div className="px-6 py-4 border-b border-gray-100"> <div className="px-6 py-4 border-b border-gray-100">