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:
@@ -288,7 +288,9 @@ function Dashboard() {
|
||||
handleStatusChange(id, status);
|
||||
setSelectedTask(null);
|
||||
}}
|
||||
onTaskUpdated={refresh}
|
||||
hasToken={hasToken}
|
||||
token={token}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Task, TaskStatus, TaskPriority } from "../lib/types";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { Task, TaskStatus, TaskPriority, TaskSource } from "../lib/types";
|
||||
import { updateTask } from "../lib/api";
|
||||
|
||||
const priorityColors: Record<TaskPriority, string> = {
|
||||
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" }],
|
||||
};
|
||||
|
||||
const allPriorities: TaskPriority[] = ["critical", "high", "medium", "low"];
|
||||
const allSources: TaskSource[] = ["donovan", "david", "hammer", "heartbeat", "cron", "other"];
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString(undefined, {
|
||||
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 }) {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const displayId = taskNumber ? `HQ-${taskNumber}` : null;
|
||||
@@ -167,12 +238,28 @@ interface TaskDetailPanelProps {
|
||||
task: Task;
|
||||
onClose: () => void;
|
||||
onStatusChange: (id: string, status: TaskStatus) => void;
|
||||
onTaskUpdated: () => void;
|
||||
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 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 (
|
||||
<>
|
||||
@@ -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="flex items-start justify-between gap-3">
|
||||
<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 && (
|
||||
<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="relative inline-flex rounded-full h-3 w-3 bg-amber-500"></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]}`}>
|
||||
{statusIcons[task.status]} {task.status.toUpperCase()}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${priorityColors[task.priority]}`}>
|
||||
{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>
|
||||
{saving && <span className="text-xs text-blue-500 animate-pulse">Saving...</span>}
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-900 leading-snug">
|
||||
{task.taskNumber && <span className="text-amber-600 mr-2">HQ-{task.taskNumber}</span>}
|
||||
{task.title}
|
||||
</h2>
|
||||
{hasToken ? (
|
||||
<EditableText
|
||||
value={task.title}
|
||||
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>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -224,13 +316,77 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, hasToken }: Tas
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Description */}
|
||||
{task.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>
|
||||
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">{task.description}</p>
|
||||
{/* Priority & Source */}
|
||||
<div className="px-6 py-4 border-b border-gray-100">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="px-6 py-4 border-b border-gray-100">
|
||||
|
||||
Reference in New Issue
Block a user