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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user