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);
|
handleStatusChange(id, status);
|
||||||
setSelectedTask(null);
|
setSelectedTask(null);
|
||||||
}}
|
}}
|
||||||
|
onTaskUpdated={refresh}
|
||||||
hasToken={hasToken}
|
hasToken={hasToken}
|
||||||
|
token={token}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{/* 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 */}
|
{/* Description */}
|
||||||
{task.description && (
|
|
||||||
<div className="px-6 py-4 border-b border-gray-100">
|
<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>
|
<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>
|
{hasToken ? (
|
||||||
</div>
|
<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">
|
||||||
|
|||||||
Reference in New Issue
Block a user