Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
943 lines
26 KiB
TypeScript
943 lines
26 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|
import type { ReactElement } from "react";
|
|
import { useSearchParams, useRouter } from "next/navigation";
|
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
|
import type {
|
|
DropResult,
|
|
DroppableProvided,
|
|
DraggableProvided,
|
|
DraggableStateSnapshot,
|
|
} from "@hello-pangea/dnd";
|
|
|
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
|
import { fetchTasks, updateTask, createTask, type TaskFilters } from "@/lib/api/tasks";
|
|
import { fetchProjects, type Project } from "@/lib/api/projects";
|
|
import { useWorkspaceId } from "@/lib/hooks";
|
|
import type { Task } from "@mosaic/shared";
|
|
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
Column configuration
|
|
--------------------------------------------------------------------------- */
|
|
|
|
interface ColumnConfig {
|
|
status: TaskStatus;
|
|
label: string;
|
|
accent: string;
|
|
}
|
|
|
|
const COLUMNS: ColumnConfig[] = [
|
|
{ status: TaskStatus.NOT_STARTED, label: "To Do", accent: "var(--ms-blue-400)" },
|
|
{ status: TaskStatus.IN_PROGRESS, label: "In Progress", accent: "var(--ms-amber-400)" },
|
|
{ status: TaskStatus.PAUSED, label: "Paused", accent: "var(--ms-purple-400)" },
|
|
{ status: TaskStatus.COMPLETED, label: "Done", accent: "var(--ms-teal-400)" },
|
|
{ status: TaskStatus.ARCHIVED, label: "Archived", accent: "var(--muted)" },
|
|
];
|
|
|
|
const PRIORITY_OPTIONS: { value: string; label: string }[] = [
|
|
{ value: "", label: "All Priorities" },
|
|
{ value: TaskPriority.HIGH, label: "High" },
|
|
{ value: TaskPriority.MEDIUM, label: "Medium" },
|
|
{ value: TaskPriority.LOW, label: "Low" },
|
|
];
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
Filter select shared styles
|
|
--------------------------------------------------------------------------- */
|
|
|
|
const selectStyle: React.CSSProperties = {
|
|
padding: "6px 10px",
|
|
borderRadius: "var(--r)",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--surface)",
|
|
color: "var(--text)",
|
|
fontSize: "0.83rem",
|
|
outline: "none",
|
|
minWidth: 130,
|
|
};
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
...selectStyle,
|
|
minWidth: 180,
|
|
};
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
Priority badge helper
|
|
--------------------------------------------------------------------------- */
|
|
|
|
interface PriorityStyle {
|
|
label: string;
|
|
bg: string;
|
|
color: string;
|
|
}
|
|
|
|
function getPriorityStyle(priority: TaskPriority): PriorityStyle {
|
|
switch (priority) {
|
|
case TaskPriority.HIGH:
|
|
return { label: "High", bg: "rgba(229,72,77,0.15)", color: "var(--danger)" };
|
|
case TaskPriority.MEDIUM:
|
|
return { label: "Medium", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
|
|
case TaskPriority.LOW:
|
|
return { label: "Low", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
|
default:
|
|
return { label: String(priority), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
|
}
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
Task Card
|
|
--------------------------------------------------------------------------- */
|
|
|
|
interface TaskCardProps {
|
|
task: Task;
|
|
provided: DraggableProvided;
|
|
snapshot: DraggableStateSnapshot;
|
|
columnAccent: string;
|
|
}
|
|
|
|
function TaskCard({ task, provided, snapshot, columnAccent }: TaskCardProps): ReactElement {
|
|
const [hovered, setHovered] = useState(false);
|
|
const priorityStyle = getPriorityStyle(task.priority);
|
|
|
|
return (
|
|
<div
|
|
ref={provided.innerRef}
|
|
{...provided.draggableProps}
|
|
{...provided.dragHandleProps}
|
|
onMouseEnter={() => {
|
|
setHovered(true);
|
|
}}
|
|
onMouseLeave={() => {
|
|
setHovered(false);
|
|
}}
|
|
style={{
|
|
background: "var(--surface)",
|
|
border: `1px solid ${hovered || snapshot.isDragging ? columnAccent : "var(--border)"}`,
|
|
borderRadius: "var(--r)",
|
|
padding: 12,
|
|
marginBottom: 8,
|
|
cursor: "grab",
|
|
transition: "border-color 0.15s, box-shadow 0.15s",
|
|
boxShadow: snapshot.isDragging ? "var(--shadow-lg)" : "none",
|
|
...provided.draggableProps.style,
|
|
}}
|
|
>
|
|
{/* Title */}
|
|
<div
|
|
style={{
|
|
fontWeight: 600,
|
|
color: "var(--text)",
|
|
fontSize: "0.875rem",
|
|
marginBottom: 6,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{task.title}
|
|
</div>
|
|
|
|
{/* Priority badge */}
|
|
<span
|
|
style={{
|
|
display: "inline-block",
|
|
padding: "1px 8px",
|
|
borderRadius: "var(--r-sm)",
|
|
background: priorityStyle.bg,
|
|
color: priorityStyle.color,
|
|
fontSize: "0.7rem",
|
|
fontWeight: 500,
|
|
marginBottom: 6,
|
|
}}
|
|
>
|
|
{priorityStyle.label}
|
|
</span>
|
|
|
|
{/* Description */}
|
|
{task.description && (
|
|
<p
|
|
style={{
|
|
color: "var(--muted)",
|
|
fontSize: "0.8rem",
|
|
margin: 0,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
display: "-webkit-box",
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: "vertical",
|
|
lineHeight: 1.4,
|
|
}}
|
|
>
|
|
{task.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
Kanban Column
|
|
--------------------------------------------------------------------------- */
|
|
|
|
interface KanbanColumnProps {
|
|
config: ColumnConfig;
|
|
tasks: Task[];
|
|
onAddTask: (status: TaskStatus, title: string, projectId?: string) => Promise<void>;
|
|
projectId?: string;
|
|
}
|
|
|
|
function KanbanColumn({ config, tasks, onAddTask, projectId }: KanbanColumnProps): ReactElement {
|
|
const [showAddForm, setShowAddForm] = useState(false);
|
|
const [inputValue, setInputValue] = useState("");
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Focus input when form is shown
|
|
useEffect(() => {
|
|
if (showAddForm && inputRef.current) {
|
|
inputRef.current.focus();
|
|
}
|
|
}, [showAddForm]);
|
|
|
|
const handleSubmit = async (e: React.SyntheticEvent): Promise<void> => {
|
|
e.preventDefault();
|
|
if (!inputValue.trim() || isSubmitting) {
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
await onAddTask(config.status, inputValue.trim(), projectId);
|
|
setInputValue("");
|
|
setShowAddForm(false);
|
|
} catch (err) {
|
|
console.error("[KanbanColumn] Failed to add task:", err);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
|
if (e.key === "Escape") {
|
|
setShowAddForm(false);
|
|
setInputValue("");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
minWidth: 280,
|
|
maxWidth: 340,
|
|
flex: "1 0 280px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
background: "var(--bg-mid)",
|
|
borderRadius: "var(--r-lg)",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{/* Column header */}
|
|
<div
|
|
style={{
|
|
borderTop: `3px solid ${config.accent}`,
|
|
padding: "12px 16px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontWeight: 600,
|
|
fontSize: "0.85rem",
|
|
color: "var(--text)",
|
|
}}
|
|
>
|
|
{config.label}
|
|
</span>
|
|
<span
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
minWidth: 22,
|
|
height: 22,
|
|
padding: "0 6px",
|
|
borderRadius: "var(--r)",
|
|
background: `color-mix(in srgb, ${config.accent} 15%, transparent)`,
|
|
color: config.accent,
|
|
fontSize: "0.75rem",
|
|
fontWeight: 600,
|
|
fontFamily: "var(--mono)",
|
|
}}
|
|
>
|
|
{tasks.length}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Droppable area */}
|
|
<Droppable droppableId={config.status}>
|
|
{(provided: DroppableProvided) => (
|
|
<div
|
|
ref={provided.innerRef}
|
|
{...provided.droppableProps}
|
|
style={{
|
|
padding: "8px 12px 12px",
|
|
flex: 1,
|
|
minHeight: 80,
|
|
overflowY: "auto",
|
|
}}
|
|
>
|
|
{tasks.map((task, index) => (
|
|
<Draggable key={task.id} draggableId={task.id} index={index}>
|
|
{(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => (
|
|
<TaskCard
|
|
task={task}
|
|
provided={dragProvided}
|
|
snapshot={dragSnapshot}
|
|
columnAccent={config.accent}
|
|
/>
|
|
)}
|
|
</Draggable>
|
|
))}
|
|
{provided.placeholder}
|
|
</div>
|
|
)}
|
|
</Droppable>
|
|
|
|
{/* Add Task Form */}
|
|
{!showAddForm ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowAddForm(true);
|
|
}}
|
|
style={{
|
|
padding: "10px 16px",
|
|
border: "none",
|
|
background: "transparent",
|
|
color: "var(--muted)",
|
|
fontSize: "0.8rem",
|
|
cursor: "pointer",
|
|
textAlign: "left",
|
|
transition: "color 0.15s",
|
|
width: "100%",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.color = "var(--text)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.color = "var(--muted)";
|
|
}}
|
|
>
|
|
+ Add task
|
|
</button>
|
|
) : (
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
style={{ padding: "8px 12px 12px", borderTop: "1px solid var(--border)" }}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={inputValue}
|
|
onChange={(e) => {
|
|
setInputValue(e.target.value);
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Task title..."
|
|
disabled={isSubmitting}
|
|
style={{
|
|
width: "100%",
|
|
padding: "8px 10px",
|
|
borderRadius: "var(--r)",
|
|
border: `1px solid ${inputValue ? "var(--primary)" : "var(--border)"}`,
|
|
background: "var(--surface)",
|
|
color: "var(--text)",
|
|
fontSize: "0.85rem",
|
|
outline: "none",
|
|
opacity: isSubmitting ? 0.6 : 1,
|
|
}}
|
|
autoFocus
|
|
/>
|
|
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting || !inputValue.trim()}
|
|
style={{
|
|
padding: "6px 12px",
|
|
borderRadius: "var(--r)",
|
|
border: "1px solid var(--primary)",
|
|
background: "var(--primary)",
|
|
color: "#fff",
|
|
fontSize: "0.8rem",
|
|
fontWeight: 500,
|
|
cursor: isSubmitting || !inputValue.trim() ? "not-allowed" : "pointer",
|
|
opacity: isSubmitting || !inputValue.trim() ? 0.5 : 1,
|
|
}}
|
|
>
|
|
✓ Add
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowAddForm(false);
|
|
setInputValue("");
|
|
}}
|
|
disabled={isSubmitting}
|
|
style={{
|
|
padding: "6px 12px",
|
|
borderRadius: "var(--r)",
|
|
border: "1px solid var(--border)",
|
|
background: "transparent",
|
|
color: "var(--muted)",
|
|
fontSize: "0.8rem",
|
|
cursor: isSubmitting ? "not-allowed" : "pointer",
|
|
opacity: isSubmitting ? 0.5 : 1,
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<div style={{ marginTop: 6, fontSize: "0.75rem", color: "var(--muted)" }}>
|
|
Press{" "}
|
|
<kbd
|
|
style={{
|
|
padding: "2px 4px",
|
|
background: "var(--bg-mid)",
|
|
borderRadius: "2px",
|
|
fontFamily: "var(--mono)",
|
|
}}
|
|
>
|
|
Enter
|
|
</kbd>{" "}
|
|
to save,{" "}
|
|
<kbd
|
|
style={{
|
|
padding: "2px 4px",
|
|
background: "var(--bg-mid)",
|
|
borderRadius: "2px",
|
|
fontFamily: "var(--mono)",
|
|
}}
|
|
>
|
|
Escape
|
|
</kbd>{" "}
|
|
to cancel
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
Filter Bar
|
|
--------------------------------------------------------------------------- */
|
|
|
|
interface FilterBarProps {
|
|
projects: Project[];
|
|
projectId: string;
|
|
priority: string;
|
|
search: string;
|
|
myTasks: boolean;
|
|
onProjectChange: (value: string) => void;
|
|
onPriorityChange: (value: string) => void;
|
|
onSearchChange: (value: string) => void;
|
|
onMyTasksToggle: () => void;
|
|
onClear: () => void;
|
|
hasActiveFilters: boolean;
|
|
}
|
|
|
|
function FilterBar({
|
|
projects,
|
|
projectId,
|
|
priority,
|
|
search,
|
|
myTasks,
|
|
onProjectChange,
|
|
onPriorityChange,
|
|
onSearchChange,
|
|
onMyTasksToggle,
|
|
onClear,
|
|
hasActiveFilters,
|
|
}: FilterBarProps): ReactElement {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
flexWrap: "wrap",
|
|
gap: 8,
|
|
padding: "10px 14px",
|
|
background: "var(--surface)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--r-lg)",
|
|
marginBottom: 16,
|
|
}}
|
|
>
|
|
{/* Search */}
|
|
<input
|
|
type="text"
|
|
placeholder="Search tasks..."
|
|
value={search}
|
|
onChange={(e): void => {
|
|
onSearchChange(e.target.value);
|
|
}}
|
|
style={inputStyle}
|
|
/>
|
|
|
|
{/* Project filter */}
|
|
<select
|
|
value={projectId}
|
|
onChange={(e): void => {
|
|
onProjectChange(e.target.value);
|
|
}}
|
|
style={selectStyle}
|
|
>
|
|
<option value="">All Projects</option>
|
|
{projects.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Priority filter */}
|
|
<select
|
|
value={priority}
|
|
onChange={(e): void => {
|
|
onPriorityChange(e.target.value);
|
|
}}
|
|
style={selectStyle}
|
|
>
|
|
{PRIORITY_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* My Tasks toggle */}
|
|
<button
|
|
type="button"
|
|
onClick={onMyTasksToggle}
|
|
style={{
|
|
padding: "6px 12px",
|
|
borderRadius: "var(--r)",
|
|
border: myTasks ? "1px solid var(--primary)" : "1px solid var(--border)",
|
|
background: myTasks ? "var(--primary)" : "transparent",
|
|
color: myTasks ? "#fff" : "var(--text-2)",
|
|
fontSize: "0.83rem",
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
transition: "all 0.12s ease",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
My Tasks
|
|
</button>
|
|
|
|
{/* Clear filters */}
|
|
{hasActiveFilters && (
|
|
<button
|
|
type="button"
|
|
onClick={onClear}
|
|
style={{
|
|
padding: "6px 12px",
|
|
borderRadius: "var(--r)",
|
|
border: "1px solid var(--border)",
|
|
background: "transparent",
|
|
color: "var(--muted)",
|
|
fontSize: "0.83rem",
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
Kanban Board Page
|
|
--------------------------------------------------------------------------- */
|
|
|
|
export default function KanbanPage(): ReactElement {
|
|
const workspaceId = useWorkspaceId();
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
|
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Read filters from URL params
|
|
const filterProject = searchParams.get("project") ?? "";
|
|
const filterPriority = searchParams.get("priority") ?? "";
|
|
const filterSearch = searchParams.get("q") ?? "";
|
|
const filterMyTasks = searchParams.get("my") === "1";
|
|
|
|
const hasActiveFilters =
|
|
filterProject !== "" || filterPriority !== "" || filterSearch !== "" || filterMyTasks;
|
|
|
|
/** Update a single URL param (preserving others) */
|
|
const setParam = useCallback(
|
|
(key: string, value: string) => {
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
if (value) {
|
|
params.set(key, value);
|
|
} else {
|
|
params.delete(key);
|
|
}
|
|
router.replace(`/kanban?${params.toString()}`, { scroll: false });
|
|
},
|
|
[searchParams, router]
|
|
);
|
|
|
|
const handleProjectChange = useCallback(
|
|
(value: string) => {
|
|
setParam("project", value);
|
|
},
|
|
[setParam]
|
|
);
|
|
|
|
const handlePriorityChange = useCallback(
|
|
(value: string) => {
|
|
setParam("priority", value);
|
|
},
|
|
[setParam]
|
|
);
|
|
|
|
const handleSearchChange = useCallback(
|
|
(value: string) => {
|
|
setParam("q", value);
|
|
},
|
|
[setParam]
|
|
);
|
|
|
|
const handleMyTasksToggle = useCallback(() => {
|
|
setParam("my", filterMyTasks ? "" : "1");
|
|
}, [setParam, filterMyTasks]);
|
|
|
|
const handleClearFilters = useCallback(() => {
|
|
router.replace("/kanban", { scroll: false });
|
|
}, [router]);
|
|
|
|
/* --- data fetching --- */
|
|
|
|
const loadTasks = useCallback(async (wsId: string | null): Promise<void> => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const filters = wsId !== null ? { workspaceId: wsId } : {};
|
|
const data = await fetchTasks(filters);
|
|
setTasks(data);
|
|
} catch (err: unknown) {
|
|
console.error("[Kanban] Failed to fetch tasks:", err);
|
|
setError(
|
|
err instanceof Error
|
|
? err.message
|
|
: "Something went wrong loading tasks. You could try again when ready."
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!workspaceId) {
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
const ac = new AbortController();
|
|
|
|
async function load(): Promise<void> {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const filters: TaskFilters = {};
|
|
if (workspaceId) filters.workspaceId = workspaceId;
|
|
const [taskData, projectData] = await Promise.all([
|
|
fetchTasks(filters),
|
|
fetchProjects(workspaceId ?? undefined),
|
|
]);
|
|
if (ac.signal.aborted) return;
|
|
setTasks(taskData);
|
|
setProjects(projectData);
|
|
} catch (err: unknown) {
|
|
console.error("[Kanban] Failed to fetch tasks:", err);
|
|
if (ac.signal.aborted) return;
|
|
setError(
|
|
err instanceof Error
|
|
? err.message
|
|
: "Something went wrong loading tasks. You could try again when ready."
|
|
);
|
|
} finally {
|
|
if (!ac.signal.aborted) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void load();
|
|
|
|
return (): void => {
|
|
ac.abort();
|
|
};
|
|
}, [workspaceId]);
|
|
|
|
/* --- apply client-side filters --- */
|
|
|
|
const filteredTasks = useMemo(() => {
|
|
let result = tasks;
|
|
|
|
if (filterProject) {
|
|
result = result.filter((t) => t.projectId === filterProject);
|
|
}
|
|
|
|
if (filterPriority) {
|
|
result = result.filter((t) => t.priority === (filterPriority as TaskPriority));
|
|
}
|
|
|
|
if (filterSearch) {
|
|
const q = filterSearch.toLowerCase();
|
|
result = result.filter(
|
|
(t) => t.title.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q)
|
|
);
|
|
}
|
|
|
|
if (filterMyTasks) {
|
|
// "My Tasks" filters to tasks assigned to the current user.
|
|
// Since we don't have the current userId readily available,
|
|
// filter by assigneeId being non-null (assigned tasks).
|
|
// A proper implementation would compare against the logged-in user's ID.
|
|
result = result.filter((t) => t.assigneeId !== null);
|
|
}
|
|
|
|
return result;
|
|
}, [tasks, filterProject, filterPriority, filterSearch, filterMyTasks]);
|
|
|
|
/* --- group tasks by status --- */
|
|
|
|
function groupByStatus(allTasks: Task[]): Record<TaskStatus, Task[]> {
|
|
const grouped: Record<TaskStatus, Task[]> = {
|
|
[TaskStatus.NOT_STARTED]: [],
|
|
[TaskStatus.IN_PROGRESS]: [],
|
|
[TaskStatus.PAUSED]: [],
|
|
[TaskStatus.COMPLETED]: [],
|
|
[TaskStatus.ARCHIVED]: [],
|
|
};
|
|
|
|
for (const task of allTasks) {
|
|
grouped[task.status].push(task);
|
|
}
|
|
|
|
return grouped;
|
|
}
|
|
|
|
const grouped = groupByStatus(filteredTasks);
|
|
|
|
/* --- drag-and-drop handler --- */
|
|
|
|
const handleDragEnd = useCallback(
|
|
(result: DropResult) => {
|
|
const { source, destination, draggableId } = result;
|
|
|
|
// Dropped outside a droppable area
|
|
if (!destination) return;
|
|
|
|
// Dropped in same position
|
|
if (source.droppableId === destination.droppableId && source.index === destination.index) {
|
|
return;
|
|
}
|
|
|
|
const newStatus = destination.droppableId as TaskStatus;
|
|
const taskId = draggableId;
|
|
|
|
// Optimistic update: move card in local state
|
|
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)));
|
|
|
|
// Persist to API
|
|
const wsId = workspaceId ?? undefined;
|
|
updateTask(taskId, { status: newStatus }, wsId).catch((err: unknown) => {
|
|
console.error("[Kanban] Failed to update task status:", err);
|
|
// Revert on failure by re-fetching
|
|
void loadTasks(workspaceId);
|
|
});
|
|
},
|
|
[workspaceId, loadTasks]
|
|
);
|
|
|
|
/* --- retry handler --- */
|
|
|
|
function handleRetry(): void {
|
|
void loadTasks(workspaceId);
|
|
}
|
|
|
|
/* --- add task handler --- */
|
|
|
|
const handleAddTask = useCallback(
|
|
async (status: TaskStatus, title: string, projectId?: string) => {
|
|
try {
|
|
const wsId = workspaceId ?? undefined;
|
|
const taskData: { title: string; status: TaskStatus; projectId?: string } = {
|
|
title,
|
|
status,
|
|
};
|
|
if (projectId) {
|
|
taskData.projectId = projectId;
|
|
}
|
|
const newTask = await createTask(taskData, wsId);
|
|
// Optimistically add to local state
|
|
setTasks((prev) => [...prev, newTask]);
|
|
} catch (err: unknown) {
|
|
console.error("[Kanban] Failed to create task:", err);
|
|
// Re-fetch on error to get consistent state
|
|
void loadTasks(workspaceId);
|
|
}
|
|
},
|
|
[workspaceId, loadTasks]
|
|
);
|
|
|
|
/* --- render --- */
|
|
|
|
return (
|
|
<main style={{ padding: "32px 24px", minHeight: "100%" }}>
|
|
{/* Page header */}
|
|
<div style={{ marginBottom: 16 }}>
|
|
<h1
|
|
style={{
|
|
fontSize: "1.875rem",
|
|
fontWeight: 700,
|
|
color: "var(--text)",
|
|
margin: 0,
|
|
}}
|
|
>
|
|
Kanban Board
|
|
</h1>
|
|
<p
|
|
style={{
|
|
fontSize: "0.9rem",
|
|
color: "var(--muted)",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
Visualize and manage task progress across stages
|
|
</p>
|
|
</div>
|
|
|
|
{/* Filter bar */}
|
|
<FilterBar
|
|
projects={projects}
|
|
projectId={filterProject}
|
|
priority={filterPriority}
|
|
search={filterSearch}
|
|
myTasks={filterMyTasks}
|
|
onProjectChange={handleProjectChange}
|
|
onPriorityChange={handlePriorityChange}
|
|
onSearchChange={handleSearchChange}
|
|
onMyTasksToggle={handleMyTasksToggle}
|
|
onClear={handleClearFilters}
|
|
hasActiveFilters={hasActiveFilters}
|
|
/>
|
|
|
|
{/* Loading state */}
|
|
{isLoading ? (
|
|
<div className="flex justify-center py-16">
|
|
<MosaicSpinner label="Loading tasks..." />
|
|
</div>
|
|
) : error !== null ? (
|
|
/* Error state */
|
|
<div
|
|
style={{
|
|
background: "var(--surface)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--r-lg)",
|
|
padding: 32,
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
<p style={{ color: "var(--danger)", margin: "0 0 16px" }}>{error}</p>
|
|
<button
|
|
onClick={handleRetry}
|
|
style={{
|
|
padding: "8px 16px",
|
|
background: "var(--danger)",
|
|
border: "none",
|
|
borderRadius: "var(--r)",
|
|
color: "#fff",
|
|
fontSize: "0.85rem",
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
) : filteredTasks.length === 0 && tasks.length > 0 ? (
|
|
/* No results (filtered) */
|
|
<div
|
|
style={{
|
|
background: "var(--surface)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--r-lg)",
|
|
padding: 48,
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
<p style={{ color: "var(--muted)", margin: 0, fontSize: "0.9rem" }}>
|
|
No tasks match your filters.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={handleClearFilters}
|
|
style={{
|
|
marginTop: 12,
|
|
padding: "6px 14px",
|
|
borderRadius: "var(--r)",
|
|
border: "1px solid var(--border)",
|
|
background: "transparent",
|
|
color: "var(--text-2)",
|
|
fontSize: "0.83rem",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Clear filters
|
|
</button>
|
|
</div>
|
|
) : (
|
|
/* Board (always render columns to allow adding first task) */
|
|
<DragDropContext onDragEnd={handleDragEnd}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 16,
|
|
overflowX: "auto",
|
|
paddingBottom: 16,
|
|
minHeight: 400,
|
|
}}
|
|
>
|
|
{COLUMNS.map((col) => (
|
|
<KanbanColumn
|
|
key={col.status}
|
|
config={col}
|
|
tasks={grouped[col.status]}
|
|
onAddTask={handleAddTask}
|
|
projectId={filterProject}
|
|
/>
|
|
))}
|
|
</div>
|
|
</DragDropContext>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|