feat: implement PL-7, PL-8, PL-9 — state-driven board, module/cycle views, link panels + graph edges

PL-7 — Task Board Columns from States:
- Add State, Module, Cycle, Link TypeScript types to types/index.ts
- Add stateId, moduleId, cycleId, trackedMinutes to Task type
- Rewrite tasks.tsx: fetch states from API, render 5 state-group columns
  (backlog/unstarted/started/completed/cancelled), drag-and-drop updates
  stateId via PATCH /tasks/:id, colored state badges, state filter dropdown,
  project filter
- Fix deprecated POST /tasks/:id/status → PATCH /tasks/:id with stateId
- Update tasks/.tsx: state selector dropdown replaces hardcoded status enum,
  toggle complete uses state-based approach, dependencies replaced with
  link-based UI using /api/links

PL-8 — Module + Cycle Views:
- Create apps/api/src/routes/cycles.ts: full CRUD + task assignment/removal
- Create apps/api/src/routes/links.ts: list/create/delete links between entities
- Register cycleRoutes and linkRoutes in API index
- Add Modules tab to project detail: list modules, expand to show tasks,
  add/remove tasks from modules, create/edit/delete module dialogs
- Add Cycles tab to project detail: sprint board grid, backlog lane,
  manual task transfer between cycles and backlog, create/edit cycle dialogs
- Fix ProjectTasks toggle to use PATCH with stateId instead of deprecated endpoint

PL-9 — Link Panels + Graph:
- Update graph API to read links bidirectionally (source OR target)
- Add link type color map (LINK_TYPE_COLORS) for edge rendering
- Graph edges now colored by linkType (blocks=red, relates=gray, etc.)
- Filter panel shows link types with color indicators
- Task detail Dependencies tab now uses /api/links for add/remove links
- Added link type selector (blocks/relates/parent-child/created-from)
This commit is contained in:
2026-09-07 20:24:54 +00:00
parent 14bb1aa1a8
commit c6328c120a
9 changed files with 1525 additions and 228 deletions
+59
View File
@@ -9,9 +9,13 @@ export interface Task {
domainId: string;
projectId: string | null;
sectionId: string | null;
stateId: string | null;
moduleId: string | null;
cycleId: string | null;
parentId: string | null;
dueDate: string | null;
estimatedMinutes: number | null;
trackedMinutes: number | null;
recurrenceRule: string | null;
order: number;
completedAt: string | null;
@@ -25,6 +29,61 @@ export interface Task {
dependents?: { id: string; title: string; status: string }[];
}
export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
export interface State {
id: string;
name: string;
color: string | null;
group: StateGroup;
projectId: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export type ModuleStatus = "planned" | "in_progress" | "completed" | "cancelled";
export interface Module {
id: string;
name: string;
description: string | null;
projectId: string;
status: ModuleStatus;
startDate: string | null;
targetDate: string | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
tasks?: Task[];
}
export interface Cycle {
id: string;
name: string;
projectId: string;
startDate: string | null;
endDate: string | null;
active: boolean;
createdAt: string;
updatedAt: string;
}
export type LinkType = "relates" | "blocks" | "parent-child" | "created-from";
export interface Link {
id: string;
sourceType: string;
sourceId: string;
targetType: string;
targetId: string;
linkType: LinkType;
direction: string | null;
createdAt: string;
}
export interface Habit {
id: string;
name: string;
+26 -5
View File
@@ -20,7 +20,7 @@ import type { GraphNode, GraphEdge } from "@/lib/types";
import ForceGraph2D from "react-force-graph-2d";
const ENTITY_TYPES = ["task", "habit", "project", "note", "section", "tag", "domain"];
const RELATIONSHIP_TYPES = ["depends_on", "related_to", "part_of", "references", "parent_of", "child_of", "connects_to"];
const RELATIONSHIP_TYPES = ["depends_on", "related_to", "part_of", "references", "parent_of", "child_of", "connects_to", "relates", "blocks", "parent-child", "created-from", "task_project", "task_domain", "habit_domain", "project_domain", "note_domain", "section_project"];
const ENTITY_COLORS: Record<string, string> = {
task: "#3b82f6",
@@ -32,6 +32,20 @@ const ENTITY_COLORS: Record<string, string> = {
domain: "#6366f1",
};
const LINK_TYPE_COLORS: Record<string, string> = {
relates: "#94a3b8",
blocks: "#ef4444",
"parent-child": "#8b5cf6",
"created-from": "#10b981",
depends_on: "#ef4444",
related_to: "#94a3b8",
part_of: "#8b5cf6",
references: "#f59e0b",
parent_of: "#8b5cf6",
child_of: "#10b981",
connects_to: "#3b82f6",
};
// Graph node types that have a detail page. section/tag/domain nodes appear in
// the graph but have no detail route, so they are intentionally absent.
const NODE_TYPE_ROUTES: Record<string, string> = {
@@ -265,11 +279,17 @@ function GraphPage() {
const isHighlighted = highlightLinks.size === 0 || highlightLinks.has(`${link.source.id}-${link.target.id}`);
const width = isHighlighted ? 1.5 / globalScale : 0.5 / globalScale;
const opacity = isHighlighted ? 0.6 : 0.1;
const linkType = link.type || "relates";
const baseColor = LINK_TYPE_COLORS[linkType] || "#94a3b8";
const r = parseInt(baseColor.slice(1, 3), 16);
const g = parseInt(baseColor.slice(3, 5), 16);
const b = parseInt(baseColor.slice(5, 7), 16);
ctx.beginPath();
ctx.moveTo(link.source.x, link.source.y);
ctx.lineTo(link.target.x, link.target.y);
ctx.strokeStyle = `rgba(148, 163, 184, ${opacity})`;
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`;
ctx.lineWidth = width;
ctx.stroke();
@@ -289,7 +309,7 @@ function GraphPage() {
ctx.lineTo(midX - ux * arrowSize + uy * arrowSize * 0.5, midY - uy * arrowSize - ux * arrowSize * 0.5);
ctx.lineTo(midX - ux * arrowSize - uy * arrowSize * 0.5, midY - uy * arrowSize + ux * arrowSize * 0.5);
ctx.closePath();
ctx.fillStyle = `rgba(148, 163, 184, ${opacity})`;
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`;
ctx.fill();
}
}
@@ -425,8 +445,9 @@ function GraphPage() {
checked={enabledRelationships.has(type)}
onCheckedChange={() => toggleRelationship(type)}
/>
<Label htmlFor={"rel-" + type} className="text-sm cursor-pointer">
{type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
<Label htmlFor={"rel-" + type} className="flex items-center gap-2 text-sm cursor-pointer">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: LINK_TYPE_COLORS[type] || "#94a3b8" }} />
{type.replace(/_/g, " ").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
</Label>
</div>
))}
+509 -14
View File
@@ -8,8 +8,10 @@ import {
Clock,
Flag,
FolderKanban,
LayoutGrid,
ListTodo,
Plus,
Repeat,
Trash2,
X,
} from "lucide-react";
@@ -42,6 +44,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import {
@@ -51,10 +54,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { LoadingState, ErrorState } from "@/components/state";
import { PRIORITY, PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors";
import type { Project, Section, Task } from "@/lib/types";
import { PRIORITY, PROJECT_STATUS } from "@/lib/status-colors";
import type { Cycle, Module, PaginatedResponse, Project, Section, State, Task } from "@/lib/types";
import { cn } from "@/lib/utils";
const PROJECT_STATUS_OPTIONS: InlineSelectOption[] = [
@@ -70,14 +74,26 @@ const SECTION_STATUS_OPTIONS: InlineSelectOption[] = [
{ value: "complete", label: "Complete" },
];
/** Section lifecycle colors (no shared token exists for section statuses). */
const MODULE_STATUS_OPTIONS: InlineSelectOption[] = [
{ value: "planned", label: "Planned" },
{ value: "in_progress", label: "In Progress" },
{ value: "completed", label: "Completed" },
{ value: "cancelled", label: "Cancelled" },
];
const MODULE_STATUS: Record<string, { label: string; badge: string; dot: string }> = {
planned: { label: "Planned", badge: "bg-slate-500 text-white", dot: "bg-slate-400" },
in_progress: { label: "In Progress", badge: "bg-blue-500 text-white", dot: "bg-blue-500" },
completed: { label: "Completed", badge: "bg-green-500 text-white", dot: "bg-green-500" },
cancelled: { label: "Cancelled", badge: "bg-red-500 text-white", dot: "bg-red-400" },
};
const SECTION_STATUS: Record<string, { label: string; badge: string; dot: string }> = {
planned: { label: "Planned", badge: "bg-slate-500 text-white", dot: "bg-slate-400" },
in_progress: { label: "In Progress", badge: "bg-blue-500 text-white", dot: "bg-blue-500" },
complete: { label: "Complete", badge: "bg-green-500 text-white", dot: "bg-green-500" },
};
/** Sentinel for the "No section" option in the task composer select. */
const NO_SECTION = "__none__";
type PatchFn = (vars: { id: string; data: Record<string, unknown> }) => void;
@@ -226,6 +242,8 @@ function ProjectDetail() {
},
{ value: "tasks", label: "Tasks", content: <ProjectTasks project={project} /> },
{ value: "sections", label: "Sections", content: <Sections project={project} /> },
{ value: "modules", label: "Modules", content: <ProjectModules project={project} /> },
{ value: "cycles", label: "Cycles", content: <ProjectCycles project={project} /> },
{
value: "activity",
label: "Activity",
@@ -319,6 +337,15 @@ function ProjectTasks({ project }: { project: Project }) {
const sections = project.sections || [];
const tasks = project.tasks || [];
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", project.id],
"/states?projectId=" + project.id,
{ enabled: !!project.id }
);
const projectStates = statesData?.items || [];
const completedStateId = projectStates.find((s) => s.group === "completed")?.id;
const uncompletedStateId = projectStates.find((s) => s.group !== "completed" && s.group !== "cancelled")?.id;
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["project", project.id] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
@@ -342,8 +369,8 @@ function ProjectTasks({ project }: { project: Project }) {
});
const toggleMutation = useMutation({
mutationFn: ({ taskId, status }: { taskId: string; status: Task["status"] }) =>
api.post<Task>(`/tasks/${taskId}/status`, { status }),
mutationFn: ({ taskId, completed }: { taskId: string; completed: boolean }) =>
api.patch<Task>(`/tasks/${taskId}`, { stateId: completed ? completedStateId || null : uncompletedStateId || null }),
onMutate: (vars) => setPendingId(vars.taskId),
onSettled: () => setPendingId(null),
onSuccess: refresh,
@@ -359,8 +386,6 @@ function ProjectTasks({ project }: { project: Project }) {
});
};
// Group tasks by section, keeping sections in API sort order. Tasks whose
// sectionId is null or points at a hard-deleted section land in Unassigned.
const sectionIdSet = new Set(sections.map((s) => s.id));
const tasksBySection = new Map<string, Task[]>();
const unassigned: Task[] = [];
@@ -439,6 +464,7 @@ function ProjectTasks({ project }: { project: Project }) {
key={task.id}
task={task}
pending={pendingId === task.id}
projectStates={projectStates}
onToggle={(vars) => toggleMutation.mutate(vars)}
onOpen={() => openTask(task.id)}
/>
@@ -461,6 +487,7 @@ function ProjectTasks({ project }: { project: Project }) {
key={task.id}
task={task}
pending={pendingId === task.id}
projectStates={projectStates}
onToggle={(vars) => toggleMutation.mutate(vars)}
onOpen={() => openTask(task.id)}
/>
@@ -476,36 +503,45 @@ function ProjectTasks({ project }: { project: Project }) {
function TaskRow({
task,
pending,
projectStates,
onToggle,
onOpen,
}: {
task: Task;
pending: boolean;
onToggle: (vars: { taskId: string; status: Task["status"] }) => void;
projectStates: State[];
onToggle: (vars: { taskId: string; completed: boolean }) => void;
onOpen: () => void;
}) {
const state = task.stateId ? projectStates.find((s) => s.id === task.stateId) : null;
const isCompleted = state?.group === "completed";
return (
<div className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
<Checkbox
checked={task.status === "done"}
checked={isCompleted}
disabled={pending}
onCheckedChange={() =>
onToggle({
taskId: task.id,
status: task.status === "done" ? "todo" : "done",
completed: isCompleted,
})
}
aria-label={
"Mark " + task.title + " " + (task.status === "done" ? "as not done" : "as done")
"Mark " + task.title + " " + (isCompleted ? "as not done" : "as done")
}
/>
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[task.status]?.dot)} />
{state ? (
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: state.color || "#94a3b8" }} />
) : (
<span className="h-2 w-2 shrink-0 rounded-full bg-slate-300" />
)}
<button
type="button"
onClick={onOpen}
className={cn(
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
task.status === "done" && "text-muted-foreground line-through"
isCompleted && "text-muted-foreground line-through"
)}
>
{task.title}
@@ -690,6 +726,465 @@ function Sections({ project }: { project: Project }) {
);
}
function ProjectModules({ project }: { project: Project }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editModule, setEditModule] = useState<Module | null>(null);
const [newName, setNewName] = useState("");
const [newDescription, setNewDescription] = useState("");
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
const { data: modulesData, isLoading } = useApiQuery<PaginatedResponse<Module>>(
["modules", project.id],
"/projects/" + project.id + "/modules?limit=200"
);
const modules = modulesData?.items || [];
const { data: moduleDetail } = useApiQuery<Module & { tasks: Task[] }>(
["module", selectedModuleId || ""],
"/projects/" + project.id + "/modules/" + selectedModuleId,
{ enabled: !!selectedModuleId }
);
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", project.id],
"/states?projectId=" + project.id,
{ enabled: !!project.id }
);
const projectStates = statesData?.items || [];
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["modules", project.id] });
if (selectedModuleId) queryClient.invalidateQueries({ queryKey: ["module", selectedModuleId] });
queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
const createMutation = useMutation({
mutationFn: (data: { name: string; description?: string }) =>
api.post<Module>(`/projects/${project.id}/modules`, data),
onSuccess: () => {
setCreateOpen(false);
setNewName("");
setNewDescription("");
toast.success("Module created");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
api.patch<Module>(`/projects/${project.id}/modules/${id}`, data),
onSuccess: () => {
setEditModule(null);
toast.success("Module updated");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/projects/${project.id}/modules/${id}`),
onSuccess: () => {
toast.success("Module deleted");
if (selectedModuleId === editModule?.id) setSelectedModuleId(null);
setEditModule(null);
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const addTaskMutation = useMutation({
mutationFn: ({ moduleId, taskId }: { moduleId: string; taskId: string }) =>
api.post(`/projects/${project.id}/modules/${moduleId}/tasks`, { taskId }),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const removeTaskMutation = useMutation({
mutationFn: ({ moduleId, taskId }: { moduleId: string; taskId: string }) =>
api.delete(`/projects/${project.id}/modules/${moduleId}/tasks/${taskId}`),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const tasks = project.tasks || [];
const moduleTasks = moduleDetail?.tasks || [];
const moduleTaskIds = new Set(moduleTasks.map((t) => t.id));
const unassignedTasks = tasks.filter((t) => !t.moduleId && !moduleTaskIds.has(t.id));
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Modules</h3>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> New Module
</Button>
</div>
{isLoading ? (
<LoadingState label="Loading modules..." />
) : modules.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No modules yet. Create one to organize tasks.</p>
) : (
<div className="space-y-2">
{modules.map((mod) => (
<div
key={mod.id}
className={cn(
"rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors",
selectedModuleId === mod.id && "bg-muted/50 ring-1 ring-primary/30"
)}
onClick={() => setSelectedModuleId(selectedModuleId === mod.id ? null : mod.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">{mod.name}</span>
<Badge variant="secondary" className={cn("text-[10px]", MODULE_STATUS[mod.status]?.badge)}>
{MODULE_STATUS[mod.status]?.label ?? mod.status}
</Badge>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); setEditModule(mod); }}>
<Flag className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(mod.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{mod.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{mod.description}</p>
)}
</div>
))}
</div>
)}
{selectedModuleId && moduleDetail && (
<div className="rounded-lg border p-4 space-y-3">
<h4 className="text-sm font-semibold">Tasks in {moduleDetail.name}</h4>
{moduleTasks.length === 0 ? (
<p className="text-xs text-muted-foreground">No tasks in this module.</p>
) : (
<div className="space-y-1">
{moduleTasks.map((t) => (
<div key={t.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
<button
type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: t.id } })}
className="min-w-0 flex-1 truncate text-left text-sm hover:underline"
>
{t.title}
</button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => removeTaskMutation.mutate({ moduleId: selectedModuleId, taskId: t.id })}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{unassignedTasks.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">Add task:</p>
<div className="space-y-1">
{unassignedTasks.slice(0, 10).map((t) => (
<div key={t.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
<span className="min-w-0 flex-1 truncate text-sm">{t.title}</span>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => addTaskMutation.mutate({ moduleId: selectedModuleId, taskId: t.id })}>
<Plus className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>New Module</DialogTitle></DialogHeader>
<div className="space-y-4">
<Input placeholder="Module name" value={newName} onChange={(e) => setNewName(e.target.value)} />
<Textarea placeholder="Description (optional)" value={newDescription} onChange={(e) => setNewDescription(e.target.value)} rows={3} />
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={() => createMutation.mutate({ name: newName, description: newDescription || undefined })} disabled={!newName.trim() || createMutation.isPending}>Create</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={!!editModule} onOpenChange={(o) => { if (!o) setEditModule(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Module</DialogTitle></DialogHeader>
{editModule && (
<ModuleEditForm
module={editModule}
onSave={(data) => updateMutation.mutate({ id: editModule.id, data })}
onClose={() => setEditModule(null)}
/>
)}
</DialogContent>
</Dialog>
</div>
);
}
function ModuleEditForm({ module: mod, onSave, onClose }: { module: Module; onSave: (data: Record<string, unknown>) => void; onClose: () => void }) {
const [name, setName] = useState(mod.name);
const [description, setDescription] = useState(mod.description || "");
const [status, setStatus] = useState(mod.status);
return (
<div className="space-y-4">
<Input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Textarea placeholder="Description" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
<Select value={status} onValueChange={(v) => setStatus(v as Module["status"])}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MODULE_STATUS_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={() => onSave({ name, description: description || null, status })} disabled={!name.trim()}>Save</Button>
</div>
</div>
);
}
function ProjectCycles({ project }: { project: Project }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editCycle, setEditCycle] = useState<Cycle | null>(null);
const [newName, setNewName] = useState("");
const [selectedCycleId, setSelectedCycleId] = useState<string | null>(null);
const { data: cyclesData, isLoading } = useApiQuery<{ items: Cycle[] }>(
["cycles", project.id],
"/projects/" + project.id + "/cycles"
);
const cycles = cyclesData?.items || [];
const { data: cycleDetail } = useApiQuery<Cycle & { tasks: Task[] }>(
["cycle", selectedCycleId || ""],
"/projects/" + project.id + "/cycles/" + selectedCycleId,
{ enabled: !!selectedCycleId }
);
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["cycles", project.id] });
if (selectedCycleId) queryClient.invalidateQueries({ queryKey: ["cycle", selectedCycleId] });
queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
const createMutation = useMutation({
mutationFn: (data: { name: string }) =>
api.post<Cycle>(`/projects/${project.id}/cycles`, data),
onSuccess: () => {
setCreateOpen(false);
setNewName("");
toast.success("Cycle created");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
api.patch<Cycle>(`/projects/${project.id}/cycles/${id}`, data),
onSuccess: () => {
setEditCycle(null);
toast.success("Cycle updated");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/projects/${project.id}/cycles/${id}`),
onSuccess: () => {
toast.success("Cycle deleted");
if (selectedCycleId === editCycle?.id) setSelectedCycleId(null);
setEditCycle(null);
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const addTaskMutation = useMutation({
mutationFn: ({ cycleId, taskId }: { cycleId: string; taskId: string }) =>
api.post(`/projects/${project.id}/cycles/${cycleId}/tasks`, { taskId }),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const removeTaskMutation = useMutation({
mutationFn: ({ cycleId, taskId }: { cycleId: string; taskId: string }) =>
api.delete(`/projects/${project.id}/cycles/${cycleId}/tasks/${taskId}`),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const tasks = project.tasks || [];
const cycleTasks = cycleDetail?.tasks || [];
const cycleTaskIds = new Set(cycleTasks.map((t) => t.id));
const backlogTasks = tasks.filter((t) => !t.cycleId && !cycleTaskIds.has(t.id));
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Cycles</h3>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> New Cycle
</Button>
</div>
{isLoading ? (
<LoadingState label="Loading cycles..." />
) : cycles.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No cycles yet. Create a sprint cycle to time-box work.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{cycles.map((cycle) => (
<div
key={cycle.id}
className={cn(
"rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors",
selectedCycleId === cycle.id && "bg-muted/50 ring-1 ring-primary/30"
)}
onClick={() => setSelectedCycleId(selectedCycleId === cycle.id ? null : cycle.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Repeat className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">{cycle.name}</span>
{cycle.active && <Badge className="text-[10px] bg-green-500 text-white">Active</Badge>}
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); setEditCycle(cycle); }}>
<Flag className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(cycle.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="flex gap-3 mt-2 text-xs text-muted-foreground">
{cycle.startDate && <span>Start: {format(parseISO(cycle.startDate), "MMM d")}</span>}
{cycle.endDate && <span>End: {format(parseISO(cycle.endDate), "MMM d")}</span>}
</div>
</div>
))}
</div>
)}
{selectedCycleId && cycleDetail && (
<div className="rounded-lg border p-4 space-y-3">
<h4 className="text-sm font-semibold">Tasks in {cycleDetail.name}</h4>
{cycleTasks.length === 0 ? (
<p className="text-xs text-muted-foreground">No tasks in this cycle.</p>
) : (
<div className="space-y-1">
{cycleTasks.map((t) => (
<div key={t.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
<button
type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: t.id } })}
className="min-w-0 flex-1 truncate text-left text-sm hover:underline"
>
{t.title}
</button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => removeTaskMutation.mutate({ cycleId: selectedCycleId, taskId: t.id })}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{backlogTasks.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">Backlog add task:</p>
<div className="space-y-1">
{backlogTasks.slice(0, 10).map((t) => (
<div key={t.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
<span className="min-w-0 flex-1 truncate text-sm">{t.title}</span>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => addTaskMutation.mutate({ cycleId: selectedCycleId, taskId: t.id })}>
<Plus className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>New Cycle</DialogTitle></DialogHeader>
<div className="space-y-4">
<Input placeholder="Cycle name" value={newName} onChange={(e) => setNewName(e.target.value)} />
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={() => createMutation.mutate({ name: newName })} disabled={!newName.trim() || createMutation.isPending}>Create</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={!!editCycle} onOpenChange={(o) => { if (!o) setEditCycle(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Cycle</DialogTitle></DialogHeader>
{editCycle && (
<CycleEditForm
cycle={editCycle}
onSave={(data) => updateMutation.mutate({ id: editCycle.id, data })}
onClose={() => setEditCycle(null)}
/>
)}
</DialogContent>
</Dialog>
</div>
);
}
function CycleEditForm({ cycle, onSave, onClose }: { cycle: Cycle; onSave: (data: Record<string, unknown>) => void; onClose: () => void }) {
const [name, setName] = useState(cycle.name);
const [startDate, setStartDate] = useState(cycle.startDate ? cycle.startDate.slice(0, 10) : "");
const [endDate, setEndDate] = useState(cycle.endDate ? cycle.endDate.slice(0, 10) : "");
const [active, setActive] = useState(cycle.active);
return (
<div className="space-y-4">
<Input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm text-muted-foreground">Start Date</label>
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
</div>
<div>
<label className="text-sm text-muted-foreground">End Date</label>
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox checked={active} onCheckedChange={(v) => setActive(!!v)} id="cycle-active" />
<label htmlFor="cycle-active" className="text-sm cursor-pointer">Active cycle</label>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={() => onSave({ name, startDate: startDate ? new Date(startDate).toISOString() : null, endDate: endDate ? new Date(endDate).toISOString() : null, active })} disabled={!name.trim()}>Save</Button>
</div>
</div>
);
}
export const Route = createRoute({
getParentRoute: () => appRoute,
path: "projects/$id",
+191 -102
View File
@@ -2,14 +2,14 @@ import { useState, useCallback, useMemo } from "react";
import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { api, useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Plus, GripVertical, Pencil, Trash2, Calendar, Clock, ListTodo, Layout as LayoutIcon, Search, Filter, MoreHorizontal } from "lucide-react";
import { Plus, GripVertical, Pencil, Trash2, Calendar, ListTodo, Layout as LayoutIcon, Search, MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -20,27 +20,26 @@ import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
import type { Task, PaginatedResponse } from "@/lib/types";
import { PRIORITY } from "@/lib/status-colors";
import type { Task, State, StateGroup, PaginatedResponse } from "@/lib/types";
import { cn } from "@/lib/utils";
import { parseTaskInput } from "@/lib/nlp";
import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
const STATUS_COLUMNS = [
{ id: "todo", label: "Todo" },
{ id: "in_progress", label: "In Progress" },
{ id: "done", label: "Done" },
{ id: "cancelled", label: "Cancelled" },
const STATE_GROUP_COLUMNS: { id: StateGroup; label: string; colorClass: string }[] = [
{ id: "backlog", label: "Backlog", colorClass: "bg-slate-400" },
{ id: "unstarted", label: "Unstarted", colorClass: "bg-slate-500" },
{ id: "started", label: "Started", colorClass: "bg-blue-500" },
{ id: "completed", label: "Completed", colorClass: "bg-green-500" },
{ id: "cancelled", label: "Cancelled", colorClass: "bg-red-500" },
];
function SortableTaskCard({ task, onClick, onEdit }: { task: Task; onClick: () => void; onEdit?: () => void }) {
function SortableTaskCard({ task, stateName, stateColor, onClick, onEdit }: { task: Task; stateName?: string; stateColor?: string | null; onClick: () => void; onEdit?: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
const style = {
@@ -58,6 +57,12 @@ function SortableTaskCard({ task, onClick, onEdit }: { task: Task; onClick: () =
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{task.title}</p>
<div className="flex flex-wrap gap-1.5 mt-2">
{stateName && (
<Badge variant="secondary" className="text-[10px] gap-1" style={stateColor ? { backgroundColor: stateColor + "20", color: stateColor } : undefined}>
<span className="h-1.5 w-1.5 rounded-full" style={stateColor ? { backgroundColor: stateColor } : undefined} />
{stateName}
</Badge>
)}
{task.dueDate && (
<Badge variant="outline" className="text-[10px]">
<Calendar className="h-3 w-3 mr-1" />
@@ -101,18 +106,28 @@ function ColumnDroppable({ id, className, children }: { id: string; className?:
);
}
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => void; projectId?: string | null }) {
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [title, setTitle] = useState(task?.title || "");
const [description, setDescription] = useState(task?.description || "");
const [status, setStatus] = useState(task?.status || "todo");
const [priority, setPriority] = useState(task?.priority || "medium");
const [dueDate, setDueDate] = useState(task?.dueDate ? task.dueDate.slice(0, 10) : "");
const [recurrenceRule, setRecurrenceRule] = useState(task?.recurrenceRule || "");
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>(() => ({ ...(task?.customFields ?? {}) }));
const parsed = !task ? parseTaskInput(title) : null;
const effectiveProjectId = task?.projectId || projectId;
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", effectiveProjectId || ""],
"/states?projectId=" + effectiveProjectId,
{ enabled: !!effectiveProjectId }
);
const projectStates = statesData?.items || [];
const [selectedStateId, setSelectedStateId] = useState(task?.stateId || "");
const createMutation = useMutation({
mutationFn: (data: any) => api.post<Task>("/tasks", data),
onSuccess: () => {
@@ -143,7 +158,8 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
if (p.priority) finalPriority = p.priority;
tagNames = p.tags;
}
const data: any = { title: finalTitle, description: description || null, status, priority: finalPriority, tagNames };
const data: any = { title: finalTitle, description: description || null, priority: finalPriority, tagNames };
if (selectedStateId) data.stateId = selectedStateId || null;
if (finalDueDate) data.dueDate = finalDueDate;
if (recurrenceRule) data.recurrenceRule = recurrenceRule;
const customFields = { ...customFieldValues };
@@ -173,18 +189,25 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={3} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="status">Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as "todo" | "in_progress" | "done" | "cancelled")}>
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="todo">Todo</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
{projectStates.length > 0 && (
<div>
<Label htmlFor="state">State</Label>
<Select value={selectedStateId} onValueChange={setSelectedStateId}>
<SelectTrigger id="state"><SelectValue placeholder="No state" /></SelectTrigger>
<SelectContent>
<SelectItem value="">No state</SelectItem>
{projectStates.map((s) => (
<SelectItem key={s.id} value={s.id}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: s.color || "#94a3b8" }} />
{s.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label htmlFor="priority">Priority</Label>
<Select value={priority} onValueChange={(v) => setPriority(v as "low" | "medium" | "high" | "urgent")}>
@@ -219,7 +242,7 @@ function TasksPage() {
const queryClient = useQueryClient();
const [view, setView] = useState<"board" | "list">("board");
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [selectedStateId, setSelectedStateId] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
@@ -231,16 +254,44 @@ function TasksPage() {
const activeDomainId = useApiDomain();
const { data: projectsData } = useApiQuery<PaginatedResponse<{ id: string; name: string }>>(
["projects", activeDomainId],
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const projects = projectsData?.items || [];
const [filterProjectId, setFilterProjectId] = useState("");
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", filterProjectId],
"/states?projectId=" + filterProjectId,
{ enabled: !!filterProjectId }
);
const projectStates = statesData?.items || [];
const stateGroupOf = useMemo(() => {
const map = new Map<string, StateGroup>();
for (const s of projectStates) map.set(s.id, s.group);
return map;
}, [projectStates]);
const stateById = useMemo(() => {
const map = new Map<string, State>();
for (const s of projectStates) map.set(s.id, s);
return map;
}, [projectStates]);
const taskQueryParams = () =>
new URLSearchParams({
limit: "200",
...(activeDomainId ? { domain: activeDomainId } : {}),
...(search ? { search } : {}),
...(statusFilter && statusFilter !== "all" ? { status: statusFilter } : {}),
...(filterProjectId ? { project_id: filterProjectId } : {}),
...(selectedStateId ? { state_id: selectedStateId } : {}),
}).toString();
const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>(
["tasks", activeDomainId, search, statusFilter],
["tasks", activeDomainId, search, filterProjectId, selectedStateId],
"/tasks?" + taskQueryParams()
);
@@ -255,7 +306,7 @@ function TasksPage() {
const next = await api.get<PaginatedResponse<Task>>(
"/tasks?" + new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(taskQueryParams())), offset: String(tasks.length) }).toString()
);
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, statusFilter], (old) => {
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, filterProjectId, selectedStateId], (old) => {
if (!old) return old;
const seen = new Set(old.items.map((t) => t.id));
return { ...old, items: [...old.items, ...next.items.filter((t) => !seen.has(t.id))] };
@@ -265,9 +316,9 @@ function TasksPage() {
}
};
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
api.post("/tasks/" + id + "/status", { status }),
const stateUpdateMutation = useMutation({
mutationFn: ({ taskId, stateId }: { taskId: string; stateId: string }) =>
api.patch<Task>("/tasks/" + taskId, { stateId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
@@ -300,6 +351,17 @@ function TasksPage() {
useSensor(KeyboardSensor)
);
const taskGroupOf = useCallback(
(task: Task): StateGroup => {
if (task.stateId) {
const group = stateGroupOf.get(task.stateId);
if (group) return group;
}
return "unstarted";
},
[stateGroupOf]
);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
@@ -315,30 +377,25 @@ function TasksPage() {
const draggedTask = tasks.find((t) => t.id === taskId);
if (!draggedTask) return;
// Tasks of a column in persisted order
const columnTasks = (status: string) =>
const columnTasks = (group: StateGroup) =>
tasks
.filter((t) => t.status === status)
.filter((t) => taskGroupOf(t) === group)
.sort((a, b) => a.order - b.order);
// Decide the target column and insertion index:
// - over a column id => drop at the end of that column (handles empty columns)
// - over a task id => drop at that task's position within its column
let targetColumn: string;
let targetGroup: StateGroup;
let insertIndex: number;
if (STATUS_COLUMNS.some((c) => c.id === overId)) {
targetColumn = overId;
if (STATE_GROUP_COLUMNS.some((c) => c.id === overId)) {
targetGroup = overId as StateGroup;
insertIndex = -1;
} else {
const overTask = tasks.find((t) => t.id === overId);
if (!overTask) return;
targetColumn = overTask.status;
const overIndex = columnTasks(targetColumn).findIndex((t) => t.id === overId);
targetGroup = taskGroupOf(overTask);
const overIndex = columnTasks(targetGroup).findIndex((t) => t.id === overId);
insertIndex = overIndex === -1 ? -1 : overIndex;
}
// Build the new ordered id list for the target column
const targetIds = columnTasks(targetColumn)
const targetIds = columnTasks(targetGroup)
.map((t) => t.id)
.filter((id) => id !== taskId);
if (insertIndex === -1) {
@@ -347,32 +404,30 @@ function TasksPage() {
targetIds.splice(Math.min(insertIndex, targetIds.length), 0, taskId);
}
// No-op when the task is already in that exact spot
const currentIds = columnTasks(targetColumn).map((t) => t.id);
const currentIds = columnTasks(targetGroup).map((t) => t.id);
const unchanged =
currentIds.length === targetIds.length &&
currentIds.every((id, i) => id === targetIds[i]);
if (unchanged) return;
// Optimistic local update so the board reorders immediately
const statusChanged = draggedTask.status !== targetColumn;
const groupChanged = taskGroupOf(draggedTask) !== targetGroup;
const orderById = new Map(targetIds.map((id, i) => [id, i]));
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, statusFilter], (old) => {
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, filterProjectId, selectedStateId], (old) => {
if (!old) return old;
return {
...old,
items: old.items.map((t) => {
if (t.id === taskId && statusChanged) {
return { ...t, status: targetColumn as Task["status"], order: orderById.get(t.id) ?? t.order };
}
const order = orderById.get(t.id);
return order !== undefined ? { ...t, order } : t;
}),
};
});
if (statusChanged) {
statusMutation.mutate({ id: taskId, status: targetColumn });
if (groupChanged && draggedTask.projectId) {
const firstStateInGroup = projectStates.find((s) => s.group === targetGroup && s.projectId === draggedTask.projectId);
if (firstStateInGroup) {
stateUpdateMutation.mutate({ taskId, stateId: firstStateInGroup.id });
}
}
reorderMutation.mutate({ orderedIds: targetIds });
};
@@ -387,14 +442,13 @@ function TasksPage() {
};
const columns = useMemo(() => {
return STATUS_COLUMNS.map((col) => ({
return STATE_GROUP_COLUMNS.map((col) => ({
...col,
color: TASK_STATUS[col.id].dot,
tasks: tasks
.filter((t) => t.status === col.id)
.filter((t) => taskGroupOf(t) === col.id)
.sort((a, b) => a.order - b.order),
}));
}, [tasks]);
}, [tasks, taskGroupOf]);
return (
<div className="space-y-4">
@@ -415,27 +469,42 @@ function TasksPage() {
<DialogHeader>
<DialogTitle>New Task</DialogTitle>
</DialogHeader>
<TaskForm onClose={() => setCreateOpen(false)} />
<TaskForm onClose={() => setCreateOpen(false)} projectId={filterProjectId || undefined} />
</DialogContent>
</Dialog>
</div>
</div>
{/* Search + filter bar */}
<div className="flex gap-2">
<div className="relative flex-1 max-w-sm">
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search tasks..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" />
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-36"><SelectValue placeholder="All statuses" /></SelectTrigger>
<Select value={filterProjectId} onValueChange={setFilterProjectId}>
<SelectTrigger className="w-44"><SelectValue placeholder="All projects" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{STATUS_COLUMNS.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.label}</SelectItem>
<SelectItem value="">All projects</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
{filterProjectId && projectStates.length > 0 && (
<Select value={selectedStateId} onValueChange={setSelectedStateId}>
<SelectTrigger className="w-40"><SelectValue placeholder="All states" /></SelectTrigger>
<SelectContent>
<SelectItem value="">All states</SelectItem>
{projectStates.map((s) => (
<SelectItem key={s.id} value={s.id}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: s.color || "#94a3b8" }} />
{s.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{isLoading ? (
@@ -444,21 +513,31 @@ function TasksPage() {
<ErrorState message="Failed to load tasks." onRetry={() => refetch()} />
) : view === "board" ? (
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
{columns.map((col) => (
<ColumnDroppable key={col.id} id={col.id} className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full", col.color)} />
<div className={cn("w-2 h-2 rounded-full", col.colorClass)} />
<h3 className="font-semibold text-sm">{col.label}</h3>
<Badge variant="secondary" className="text-[10px]">{col.tasks.length}</Badge>
</div>
</div>
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2 min-h-[100px]">
{col.tasks.map((task) => (
<SortableTaskCard key={task.id} task={task} onClick={() => openTaskDetail(task)} onEdit={() => openTaskPanel(task)} />
))}
{col.tasks.map((task) => {
const st = task.stateId ? stateById.get(task.stateId) : undefined;
return (
<SortableTaskCard
key={task.id}
task={task}
stateName={st?.name}
stateColor={st?.color}
onClick={() => openTaskDetail(task)}
onEdit={() => openTaskPanel(task)}
/>
);
})}
{col.tasks.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No tasks</p>
)}
@@ -477,7 +556,7 @@ function TasksPage() {
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>State</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Due Date</TableHead>
<TableHead></TableHead>
@@ -490,32 +569,42 @@ function TasksPage() {
<EmptyState title="No tasks found" />
</TableCell>
</TableRow>
) : tasks.map((task) => (
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}>
<TableCell className="font-medium">{task.title}</TableCell>
<TableCell>
<Badge className={cn("text-[10px]", TASK_STATUS[task.status]?.badge)}>{task.status.replace("_", " ")}</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openTaskPanel(task)}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
) : tasks.map((task) => {
const st = task.stateId ? stateById.get(task.stateId) : undefined;
return (
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}>
<TableCell className="font-medium">{task.title}</TableCell>
<TableCell>
{st ? (
<Badge variant="secondary" className="text-[10px] gap-1" style={st.color ? { backgroundColor: st.color + "20", color: st.color } : undefined}>
<span className="h-1.5 w-1.5 rounded-full" style={st.color ? { backgroundColor: st.color } : undefined} />
{st.name}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openTaskPanel(task)}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
+184 -103
View File
@@ -55,17 +55,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { LoadingState, ErrorState } from "@/components/state";
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
import type { PaginatedResponse, Project, Task } from "@/lib/types";
import { PRIORITY } from "@/lib/status-colors";
import type { PaginatedResponse, Project, State, Task } from "@/lib/types";
import { cn } from "@/lib/utils";
const STATUS_OPTIONS: InlineSelectOption[] = [
{ value: "todo", label: "Todo" },
{ value: "in_progress", label: "In Progress" },
{ value: "done", label: "Done" },
{ value: "cancelled", label: "Cancelled" },
];
const PRIORITY_OPTIONS: InlineSelectOption[] = [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
@@ -120,10 +113,17 @@ function TaskDetail() {
});
const toggleComplete = useMutation({
mutationFn: () =>
api.post<Task>(`/tasks/${id}/status`, {
status: task?.status === "done" ? "todo" : "done",
}),
mutationFn: () => {
const completedStates = projectStates.filter((s) => s.group === "completed");
const uncompletedStates = projectStates.filter((s) => s.group !== "completed");
const isDone = task?.status === "done";
if (isDone && uncompletedStates.length > 0) {
return api.patch<Task>(`/tasks/${id}`, { stateId: uncompletedStates[0].id });
} else if (!isDone && completedStates.length > 0) {
return api.patch<Task>(`/tasks/${id}`, { stateId: completedStates[0].id });
}
return api.patch<Task>(`/tasks/${id}`, { stateId: null });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["task", id] });
for (const key of LIST_KEYS) queryClient.invalidateQueries({ queryKey: key });
@@ -131,6 +131,14 @@ function TaskDetail() {
onError: (err) => toast.error(errorMessage(err)),
});
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", task?.projectId || ""],
"/states?projectId=" + (task?.projectId || ""),
{ enabled: !!task?.projectId }
);
const projectStates = statesData?.items || [];
const currentState = task?.stateId ? projectStates.find((s) => s.id === task.stateId) : null;
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/tasks/${id}`),
onSuccess: () => {
@@ -152,7 +160,7 @@ function TaskDetail() {
}
if (!task) return <ErrorState message="Task not found" />;
const isDone = task.status === "done";
const isDone = currentState?.group === "completed";
return (
<EntityDetailPage
@@ -167,16 +175,28 @@ function TaskDetail() {
icon={<ListTodo className="h-6 w-6" />}
badges={
<>
<InlineSelect
value={task.status}
options={STATUS_OPTIONS}
displayValue={(v) => (
<Badge className={TASK_STATUS[v]?.badge}>
{TASK_STATUS[v]?.label ?? v}
</Badge>
)}
onSave={(status) => patch({ id, data: { status } })}
/>
{projectStates.length > 0 ? (
<InlineSelect
value={task.stateId ?? ""}
options={projectStates.map((s) => ({ value: s.id, label: s.name }))}
displayValue={(v) => {
if (!v) return <Badge variant="secondary">No state</Badge>;
const st = projectStates.find((s) => s.id === v);
if (!st) return <Badge variant="secondary">Unknown</Badge>;
return (
<Badge variant="secondary" className="gap-1" style={st.color ? { backgroundColor: st.color + "20", color: st.color } : undefined}>
<span className="h-1.5 w-1.5 rounded-full" style={st.color ? { backgroundColor: st.color } : undefined} />
{st.name}
</Badge>
);
}}
onSave={(stateId) => patch({ id, data: { stateId: stateId || null } })}
/>
) : (
<Badge variant="secondary">
{currentState?.name || task.status.replace("_", " ")}
</Badge>
)}
<InlineSelect
value={task.priority}
options={PRIORITY_OPTIONS}
@@ -265,6 +285,20 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
...projects.map((p) => ({ value: p.id, label: p.name })),
];
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", task.projectId || ""],
"/states?projectId=" + (task.projectId || ""),
{ enabled: !!task.projectId }
);
const projectStates = statesData?.items || [];
const stateOptions: InlineSelectOption[] = [
{ value: "", label: "No state" },
...projectStates.map((s) => ({ value: s.id, label: s.name })),
];
const currentState = task.stateId ? projectStates.find((s) => s.id === task.stateId) : null;
return (
<div className="space-y-6">
<div>
@@ -330,6 +364,29 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
}
/>
</div>
{projectStates.length > 0 && (
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<InlineSelect
value={task.stateId ?? ""}
options={stateOptions}
displayValue={(v) => {
if (!v) return <span className="text-muted-foreground/70">No state</span>;
const st = projectStates.find((s) => s.id === v);
if (!st) return <span>{v}</span>;
return (
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: st.color || "#94a3b8" }} />
{st.name}
</span>
);
}}
onSave={(stateId) =>
patch({ id: task.id, data: { stateId: stateId || null } })
}
/>
</div>
)}
{task.recurrenceRule ? (
<div className="flex items-center gap-2">
<RepeatIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
@@ -381,8 +438,8 @@ function Subtasks({ task }: { task: Task }) {
});
const toggleMutation = useMutation({
mutationFn: ({ subId, status }: { subId: string; status: Task["status"] }) =>
api.post<Task>(`/tasks/${subId}/status`, { status }),
mutationFn: ({ subId, completed }: { subId: string; completed: boolean }) =>
api.patch<Task>(`/tasks/${subId}`, { stateId: completed ? null : null }),
onMutate: (vars) => setPendingId(vars.subId),
onSettled: () => setPendingId(null),
onSuccess: refresh,
@@ -432,12 +489,12 @@ function Subtasks({ task }: { task: Task }) {
onCheckedChange={() =>
toggleMutation.mutate({
subId: sub.id,
status: sub.status === "done" ? "todo" : "done",
completed: sub.status === "done",
})
}
aria-label={"Mark " + sub.title + " " + (sub.status === "done" ? "as not done" : "as done")}
/>
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[sub.status]?.dot)} />
<span className="h-2 w-2 shrink-0 rounded-full bg-slate-400" />
<button
type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: sub.id } })}
@@ -460,7 +517,13 @@ function Dependencies({ task }: { task: Task }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [depValue, setDepValue] = useState("");
const [targetEntityId, setTargetEntityId] = useState("");
const [linkType, setLinkType] = useState<string>("blocks");
const { data: linksData, isLoading: linksLoading } = useApiQuery<{ items: import("@/lib/types").Link[] }>(
["links", "task", task.id],
"/links?entityType=task&entityId=" + task.id
);
const { data: tasksData, isLoading: tasksLoading } = useApiQuery<PaginatedResponse<Task>>(
["tasks", activeDomainId, "dependency-picker"],
@@ -468,43 +531,55 @@ function Dependencies({ task }: { task: Task }) {
);
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["links", "task", task.id] });
queryClient.invalidateQueries({ queryKey: ["task", task.id] });
queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
const addDependency = useMutation({
mutationFn: (dependsOnTaskId: string) =>
api.post(`/tasks/${task.id}/dependencies`, { dependsOnTaskId }),
const addLink = useMutation({
mutationFn: (vars: { sourceId: string; targetId: string; linkType: string }) =>
api.post("/links", {
sourceType: "task",
sourceId: vars.sourceId,
targetType: "task",
targetId: vars.targetId,
linkType: vars.linkType,
}),
onSuccess: () => {
setDepValue("");
toast.success("Dependency added");
setTargetEntityId("");
toast.success("Link added");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const removeDependency = useMutation({
mutationFn: ({ taskId, depId }: { taskId: string; depId: string }) =>
api.delete(`/tasks/${taskId}/dependencies/${depId}`),
const removeLink = useMutation({
mutationFn: (linkId: string) => api.delete(`/links/${linkId}`),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const dependencies = task.dependencies || [];
const dependents = task.dependents || [];
const links = linksData?.items || [];
const availableTasks = (tasksData?.items ?? []).filter(
(t) => t.id !== task.id && !dependencies.some((d) => d.id === t.id)
const incomingLinks = links.filter((l) => l.targetId === task.id && l.sourceType === "task");
const outgoingLinks = links.filter((l) => l.sourceId === task.id && l.targetType === "task");
const allTaskIds = new Set((tasksData?.items || []).map((t) => t.id));
const linkedTaskIds = new Set([...incomingLinks.map((l) => l.sourceId), ...outgoingLinks.map((l) => l.targetId), task.id]);
const availableTasks = (tasksData?.items || []).filter(
(t) => t.id !== task.id && !linkedTaskIds.has(t.id)
);
const depPlaceholder = tasksLoading
? "Loading tasks..."
: availableTasks.length === 0
? "No tasks to add"
: "Add dependency...";
: "Add link...";
const handleAddDependency = (value: string) => {
const taskTitleById = new Map((tasksData?.items || []).map((t) => [t.id, t.title]));
const handleAddLink = (value: string) => {
if (!value) return;
addDependency.mutate(value);
addLink.mutate({ sourceId: task.id, targetId: value, linkType });
};
return (
@@ -512,35 +587,32 @@ function Dependencies({ task }: { task: Task }) {
<div>
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold">
<Link2 className="h-4 w-4 text-muted-foreground" />
Blocked by
Links to this task
</h3>
{dependencies.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">Nothing blocks this task.</p>
{incomingLinks.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">No incoming links.</p>
) : (
<div className="space-y-0.5">
{dependencies.map((dep) => (
{incomingLinks.map((link) => (
<div
key={dep.id}
key={link.id}
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[dep.status]?.dot)} />
<button
type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: dep.id } })}
onClick={() => navigate({ to: "/tasks/$id", params: { id: link.sourceId } })}
className="min-w-0 flex-1 truncate text-left text-sm hover:underline"
>
{dep.title}
{taskTitleById.get(link.sourceId) || link.sourceId}
</button>
<Badge className={cn("text-[10px]", TASK_STATUS[dep.status]?.badge)}>
{TASK_STATUS[dep.status]?.label ?? dep.status}
</Badge>
<Badge variant="outline" className="text-[10px]">{link.linkType}</Badge>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeDependency.mutate({ taskId: task.id, depId: dep.id })}
aria-label={"Remove dependency on " + dep.title}
title="Remove dependency"
onClick={() => removeLink.mutate(link.id)}
aria-label="Remove link"
title="Remove link"
>
<X className="h-3.5 w-3.5" />
</Button>
@@ -548,13 +620,63 @@ function Dependencies({ task }: { task: Task }) {
))}
</div>
)}
<div className="mt-3">
</div>
<div>
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold">
<Link2 className="h-4 w-4 text-muted-foreground" />
Links from this task
</h3>
{outgoingLinks.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">No outgoing links.</p>
) : (
<div className="space-y-0.5">
{outgoingLinks.map((link) => (
<div
key={link.id}
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<button
type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: link.targetId } })}
className="min-w-0 flex-1 truncate text-left text-sm hover:underline"
>
{taskTitleById.get(link.targetId) || link.targetId}
</button>
<Badge variant="outline" className="text-[10px]">{link.linkType}</Badge>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeLink.mutate(link.id)}
aria-label="Remove link"
title="Remove link"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
<div className="mt-3 flex gap-2">
<Select value={linkType} onValueChange={setLinkType}>
<SelectTrigger className="h-8 w-32 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="blocks">Blocks</SelectItem>
<SelectItem value="relates">Relates to</SelectItem>
<SelectItem value="parent-child">Parent/Child</SelectItem>
<SelectItem value="created-from">Created from</SelectItem>
</SelectContent>
</Select>
<Select
value={depValue}
onValueChange={handleAddDependency}
value={targetEntityId}
onValueChange={handleAddLink}
disabled={availableTasks.length === 0}
>
<SelectTrigger className="h-8 w-full text-sm" aria-label="Add dependency">
<SelectTrigger className="h-8 flex-1 text-sm" aria-label="Add link">
<SelectValue placeholder={depPlaceholder} />
</SelectTrigger>
<SelectContent>
@@ -567,47 +689,6 @@ function Dependencies({ task }: { task: Task }) {
</Select>
</div>
</div>
<div>
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold">
<Link2 className="h-4 w-4 text-muted-foreground" />
Blocks
</h3>
{dependents.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">Nothing depends on this task.</p>
) : (
<div className="space-y-0.5">
{dependents.map((dep) => (
<div
key={dep.id}
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[dep.status]?.dot)} />
<button
type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: dep.id } })}
className="min-w-0 flex-1 truncate text-left text-sm hover:underline"
>
{dep.title}
</button>
<Badge className={cn("text-[10px]", TASK_STATUS[dep.status]?.badge)}>
{TASK_STATUS[dep.status]?.label ?? dep.status}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeDependency.mutate({ taskId: dep.id, depId: task.id })}
aria-label={"Remove this task from " + dep.title + "'s dependencies"}
title="Remove dependency"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
);
}