- Added estimatedHours column to tasks schema - Backend: create/update support for estimatedHours - New /api/tasks/stats/velocity endpoint: daily completions, weekly velocity, estimate totals - Dashboard: velocity chart with 7-day bar chart, this week count, avg/week, estimate summary - TaskDetailPanel: estimated hours input field - CreateTaskModal: estimated hours in advanced options - TaskCard, KanbanBoard, TaskPage: estimate badge display
244 lines
9.4 KiB
TypeScript
244 lines
9.4 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { fetchProjects } from "../lib/api";
|
|
import type { Project } from "../lib/types";
|
|
|
|
interface CreateTaskModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onCreate: (task: {
|
|
title: string;
|
|
description?: string;
|
|
source?: string;
|
|
priority?: string;
|
|
projectId?: string;
|
|
dueDate?: string;
|
|
estimatedHours?: number;
|
|
}) => void;
|
|
}
|
|
|
|
export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProps) {
|
|
const [title, setTitle] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [source, setSource] = useState("donovan");
|
|
const [priority, setPriority] = useState("medium");
|
|
const [projectId, setProjectId] = useState("");
|
|
const [dueDate, setDueDate] = useState("");
|
|
const [estimatedHours, setEstimatedHours] = useState("");
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
fetchProjects().then(setProjects).catch(() => {});
|
|
}
|
|
}, [open]);
|
|
|
|
// Keyboard shortcut: Escape to close
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
window.addEventListener("keydown", handleKey);
|
|
return () => window.removeEventListener("keydown", handleKey);
|
|
}, [open, onClose]);
|
|
|
|
if (!open) return null;
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!title.trim()) return;
|
|
onCreate({
|
|
title: title.trim(),
|
|
description: description.trim() || undefined,
|
|
source,
|
|
priority,
|
|
projectId: projectId || undefined,
|
|
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
|
|
estimatedHours: estimatedHours ? Number(estimatedHours) : undefined,
|
|
});
|
|
// Reset form
|
|
setTitle("");
|
|
setDescription("");
|
|
setSource("donovan");
|
|
setPriority("medium");
|
|
setProjectId("");
|
|
setDueDate("");
|
|
setEstimatedHours("");
|
|
setShowAdvanced(false);
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-bold">New Task</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-gray-600 p-1 rounded-lg hover:bg-gray-100 transition"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{/* Title */}
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-500 block mb-1">Title</label>
|
|
<input
|
|
type="text"
|
|
placeholder="What needs to be done?"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-amber-400"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-500 block mb-1">Description</label>
|
|
<textarea
|
|
placeholder="Context, requirements, links... (optional)"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={3}
|
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-amber-400 resize-y"
|
|
/>
|
|
</div>
|
|
|
|
{/* Priority & Source row */}
|
|
<div className="flex gap-3">
|
|
<div className="flex-1">
|
|
<label className="text-xs font-medium text-gray-500 block mb-1">Priority</label>
|
|
<div className="flex gap-1">
|
|
{(["critical", "high", "medium", "low"] as const).map((p) => (
|
|
<button
|
|
key={p}
|
|
type="button"
|
|
onClick={() => setPriority(p)}
|
|
className={`flex-1 text-xs py-2 rounded-lg font-medium transition border ${
|
|
priority === p
|
|
? p === "critical" ? "bg-red-500 text-white border-red-500"
|
|
: p === "high" ? "bg-orange-500 text-white border-orange-500"
|
|
: p === "medium" ? "bg-blue-500 text-white border-blue-500"
|
|
: "bg-gray-500 text-white border-gray-500"
|
|
: "bg-white text-gray-500 border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
{p === "critical" ? "🔴" : p === "high" ? "🟠" : p === "medium" ? "🔵" : "⚪"} {p[0].toUpperCase() + p.slice(1)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Project selector */}
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-500 block mb-1">Project</label>
|
|
<select
|
|
value={projectId}
|
|
onChange={(e) => setProjectId(e.target.value)}
|
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 bg-white"
|
|
>
|
|
<option value="">No project</option>
|
|
{projects.map((p) => (
|
|
<option key={p.id} value={p.id}>{p.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Advanced toggle */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
className="text-xs text-gray-400 hover:text-gray-600 flex items-center gap-1 transition"
|
|
>
|
|
{showAdvanced ? "▾" : "▸"} More options
|
|
</button>
|
|
|
|
{showAdvanced && (
|
|
<div className="space-y-3 pl-2 border-l-2 border-gray-100">
|
|
{/* Due Date */}
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-500 block mb-1">Due Date</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="datetime-local"
|
|
value={dueDate}
|
|
onChange={(e) => setDueDate(e.target.value)}
|
|
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-400 bg-white"
|
|
/>
|
|
{dueDate && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setDueDate("")}
|
|
className="text-xs text-gray-400 hover:text-red-500 transition"
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Estimated Hours */}
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-500 block mb-1">Estimated Hours</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
value={estimatedHours}
|
|
onChange={(e) => setEstimatedHours(e.target.value)}
|
|
placeholder="0"
|
|
className="w-24 text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-400 bg-white"
|
|
/>
|
|
<span className="text-sm text-gray-500">hours</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Source */}
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-500 block mb-1">Source</label>
|
|
<select
|
|
value={source}
|
|
onChange={(e) => setSource(e.target.value)}
|
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 bg-white"
|
|
>
|
|
<option value="donovan">Donovan</option>
|
|
<option value="david">David</option>
|
|
<option value="hammer">Hammer</option>
|
|
<option value="heartbeat">Heartbeat</option>
|
|
<option value="cron">Cron</option>
|
|
<option value="other">Other</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Submit buttons */}
|
|
<div className="flex gap-2 pt-2">
|
|
<button
|
|
type="submit"
|
|
disabled={!title.trim()}
|
|
className="flex-1 bg-amber-500 text-white rounded-lg py-2.5 text-sm font-semibold hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Create Task
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|