feat(tasks): project-centric task management redesign
- Task board uses per-project workflow state columns with status fallback when no project is selected; adds calendar view tab - Chip-based filter bar (search, project, state, priority, due date) with quick-add bar and slide-over task detail panel - Project detail switches to left-nav layout with progress summary and per-section counts; projects list gains search, status filter, and richer cards - Sidebar gains expandable active-projects sub-nav; calendar unified view gains project filter - API: task due_after/due_before filters, GET /tasks/grouped, GET /projects/:id/stats, calendar unified project_id filter - Apply Buzzbee design tokens; remove accidentally committed apps/web/node_modules self-symlink
This commit is contained in:
@@ -237,9 +237,12 @@ calendarRoutes.get("/unified", async (c) => {
|
|||||||
await requireWorkspaceAccess(c, domainId);
|
await requireWorkspaceAccess(c, domainId);
|
||||||
const from = fromStr ? new Date(fromStr) : new Date(new Date().setDate(new Date().getDate() - 7));
|
const from = fromStr ? new Date(fromStr) : new Date(new Date().setDate(new Date().getDate() - 7));
|
||||||
const to = toStr ? new Date(toStr) : new Date(new Date().setDate(new Date().getDate() + 30));
|
const to = toStr ? new Date(toStr) : new Date(new Date().setDate(new Date().getDate() + 30));
|
||||||
|
const projectId = url.searchParams.get("project_id");
|
||||||
|
const taskConds: any[] = [eq(tasks.domainId, domainId), isNull(tasks.deletedAt), gte(tasks.dueDate, from), lte(tasks.dueDate, to)];
|
||||||
|
if (projectId) taskConds.push(eq(tasks.projectId, projectId));
|
||||||
const [events, domainTasks, domainHabits] = await Promise.all([
|
const [events, domainTasks, domainHabits] = await Promise.all([
|
||||||
db.select().from(calendarEvents).where(and(eq(calendarEvents.domainId, domainId), gte(calendarEvents.startTime, from), lte(calendarEvents.startTime, to))).orderBy(asc(calendarEvents.startTime)),
|
db.select().from(calendarEvents).where(and(eq(calendarEvents.domainId, domainId), gte(calendarEvents.startTime, from), lte(calendarEvents.startTime, to))).orderBy(asc(calendarEvents.startTime)),
|
||||||
db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), gte(tasks.dueDate, from), lte(tasks.dueDate, to))).orderBy(asc(tasks.dueDate)),
|
db.select().from(tasks).where(and(...taskConds)).orderBy(asc(tasks.dueDate)),
|
||||||
db.select().from(habits).where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))),
|
db.select().from(habits).where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))),
|
||||||
]);
|
]);
|
||||||
const taskEvents = domainTasks.filter(t => t.dueDate).map(t => ({
|
const taskEvents = domainTasks.filter(t => t.dueDate).map(t => ({
|
||||||
|
|||||||
@@ -316,6 +316,60 @@ projectRoutes.get("/:id", async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/projects/:id/stats — Task counts by state group for board headers/progress
|
||||||
|
projectRoutes.get("/:id/stats", async (c) => {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth(c);
|
||||||
|
const id = c.req.param("id");
|
||||||
|
if (!isUuid(id)) {
|
||||||
|
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||||
|
}
|
||||||
|
const [project] = await db.select().from(projects)
|
||||||
|
.where(and(eq(projects.id, id), isNull(projects.deletedAt))).limit(1);
|
||||||
|
if (!project) {
|
||||||
|
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
|
||||||
|
}
|
||||||
|
await requireWorkspaceAccess(c, project.domainId);
|
||||||
|
|
||||||
|
const projectStates = await db.select().from(states)
|
||||||
|
.where(and(eq(states.projectId, id), isNull(states.deletedAt)))
|
||||||
|
.orderBy(asc(states.sortOrder));
|
||||||
|
const projectTasks = await db.select({ id: tasks.id, stateId: tasks.stateId, completedAt: tasks.completedAt, dueDate: tasks.dueDate })
|
||||||
|
.from(tasks).where(and(eq(tasks.projectId, id), isNull(tasks.deletedAt)));
|
||||||
|
|
||||||
|
const stateById = new Map(projectStates.map((s) => [s.id, s]));
|
||||||
|
const byGroup: Record<string, number> = { backlog: 0, unstarted: 0, started: 0, completed: 0, cancelled: 0 };
|
||||||
|
const byState: Record<string, number> = {};
|
||||||
|
for (const t of projectTasks) {
|
||||||
|
byState[t.stateId || "__none__"] = (byState[t.stateId || "__none__"] || 0) + 1;
|
||||||
|
const group = t.stateId ? stateById.get(t.stateId)?.group || "unstarted" : "unstarted";
|
||||||
|
byGroup[group] = (byGroup[group] || 0) + 1;
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const overdue = projectTasks.filter((t) => t.dueDate && new Date(t.dueDate) < now && !t.completedAt).length;
|
||||||
|
const dueSoon = projectTasks.filter((t) => {
|
||||||
|
if (!t.dueDate || t.completedAt) return false;
|
||||||
|
const d = new Date(t.dueDate).getTime() - now.getTime();
|
||||||
|
return d >= 0 && d <= 7 * 24 * 60 * 60 * 1000;
|
||||||
|
}).length;
|
||||||
|
const total = projectTasks.length;
|
||||||
|
const completed = byGroup.completed || 0;
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
total, completed, overdue, dueSoon,
|
||||||
|
progress: total > 0 ? Math.round((completed / total) * 100) : 0,
|
||||||
|
byGroup, byState,
|
||||||
|
states: projectStates.map((s) => ({ id: s.id, name: s.name, color: s.color, group: s.group, count: byState[s.id] || 0 })),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||||
|
}
|
||||||
|
console.error("[projects] GET /:id/stats error:", error);
|
||||||
|
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get project stats" } }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// PATCH /api/projects/:id — Update a project
|
// PATCH /api/projects/:id — Update a project
|
||||||
projectRoutes.patch("/:id", async (c) => {
|
projectRoutes.patch("/:id", async (c) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, tasks, states as statesTable, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } from "@project-e/db";
|
import { db, tasks, states as statesTable, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } from "@project-e/db";
|
||||||
import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, exists, gte, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm";
|
||||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
||||||
import { recordActivity } from "../middleware/activity";
|
import { recordActivity } from "../middleware/activity";
|
||||||
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||||
@@ -95,6 +95,8 @@ taskRoutes.get("/", async (c) => {
|
|||||||
const parentId = url.searchParams.get("parent_id");
|
const parentId = url.searchParams.get("parent_id");
|
||||||
const projectId = url.searchParams.get("project_id");
|
const projectId = url.searchParams.get("project_id");
|
||||||
const sectionId = url.searchParams.get("section_id");
|
const sectionId = url.searchParams.get("section_id");
|
||||||
|
const dueAfter = url.searchParams.get("due_after");
|
||||||
|
const dueBefore = url.searchParams.get("due_before");
|
||||||
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
|
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
|
||||||
const offset = parseInt(url.searchParams.get("offset") || "0");
|
const offset = parseInt(url.searchParams.get("offset") || "0");
|
||||||
const order = url.searchParams.get("order") || "asc";
|
const order = url.searchParams.get("order") || "asc";
|
||||||
@@ -139,6 +141,14 @@ taskRoutes.get("/", async (c) => {
|
|||||||
if (sectionId) {
|
if (sectionId) {
|
||||||
conditions.push(eq(tasks.sectionId, sectionId));
|
conditions.push(eq(tasks.sectionId, sectionId));
|
||||||
}
|
}
|
||||||
|
if (dueAfter) {
|
||||||
|
const d = new Date(dueAfter);
|
||||||
|
if (!Number.isNaN(d.getTime())) conditions.push(gte(tasks.dueDate, d));
|
||||||
|
}
|
||||||
|
if (dueBefore) {
|
||||||
|
const d = new Date(dueBefore);
|
||||||
|
if (!Number.isNaN(d.getTime())) conditions.push(lte(tasks.dueDate, d));
|
||||||
|
}
|
||||||
if (stateId) {
|
if (stateId) {
|
||||||
conditions.push(eq(tasks.stateId, stateId));
|
conditions.push(eq(tasks.stateId, stateId));
|
||||||
}
|
}
|
||||||
@@ -249,6 +259,114 @@ taskRoutes.get("/", async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/tasks/grouped — Board-ready grouping (by state, project, or priority)
|
||||||
|
taskRoutes.get("/grouped", async (c) => {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth(c);
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const groupBy = url.searchParams.get("group_by") || "state";
|
||||||
|
const projectId = url.searchParams.get("project_id");
|
||||||
|
const priority = url.searchParams.get("priority");
|
||||||
|
const search = url.searchParams.get("search");
|
||||||
|
const dueAfter = url.searchParams.get("due_after");
|
||||||
|
const dueBefore = url.searchParams.get("due_before");
|
||||||
|
let domainId = url.searchParams.get("domain") || undefined;
|
||||||
|
if (!domainId) {
|
||||||
|
const active = await resolveActiveDomain(user);
|
||||||
|
domainId = active.id;
|
||||||
|
}
|
||||||
|
await requireWorkspaceAccess(c, domainId);
|
||||||
|
|
||||||
|
const conditions: any[] = [eq(tasks.domainId, domainId), isNull(tasks.deletedAt)];
|
||||||
|
if (projectId) conditions.push(eq(tasks.projectId, projectId));
|
||||||
|
if (priority) conditions.push(inArray(tasks.priority, priority.split(",") as any));
|
||||||
|
if (search) conditions.push(ilike(tasks.title, `%${search}%`));
|
||||||
|
if (dueAfter) {
|
||||||
|
const d = new Date(dueAfter);
|
||||||
|
if (!Number.isNaN(d.getTime())) conditions.push(gte(tasks.dueDate, d));
|
||||||
|
}
|
||||||
|
if (dueBefore) {
|
||||||
|
const d = new Date(dueBefore);
|
||||||
|
if (!Number.isNaN(d.getTime())) conditions.push(lte(tasks.dueDate, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await db.select().from(tasks)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(asc(tasks.order))
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
let taskTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
|
||||||
|
if (items.length > 0) {
|
||||||
|
const tagRows = await db.select({
|
||||||
|
taskId: taskTags.taskId, id: tagsTable.id, name: tagsTable.name, color: tagsTable.color,
|
||||||
|
}).from(taskTags).innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id))
|
||||||
|
.where(inArray(taskTags.taskId, items.map((t) => t.id)));
|
||||||
|
for (const row of tagRows) {
|
||||||
|
if (!taskTagMap.has(row.taskId)) taskTagMap.set(row.taskId, []);
|
||||||
|
taskTagMap.get(row.taskId)!.push({ id: row.id, name: row.name, color: row.color });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const withTags = items.map((t) => ({ ...t, tags: taskTagMap.get(t.id) || [] }));
|
||||||
|
|
||||||
|
if (groupBy === "state") {
|
||||||
|
const stateIds = [...new Set(withTags.map((t) => t.stateId).filter(Boolean))] as string[];
|
||||||
|
let stateList: typeof statesTable.$inferSelect[] = [];
|
||||||
|
if (projectId) {
|
||||||
|
stateList = await db.select().from(statesTable)
|
||||||
|
.where(and(eq(statesTable.projectId, projectId), isNull(statesTable.deletedAt)))
|
||||||
|
.orderBy(asc(statesTable.sortOrder));
|
||||||
|
} else if (stateIds.length > 0) {
|
||||||
|
stateList = await db.select().from(statesTable).where(inArray(statesTable.id, stateIds));
|
||||||
|
}
|
||||||
|
const byState = new Map<string | null, typeof withTags>();
|
||||||
|
for (const t of withTags) {
|
||||||
|
const key = t.stateId ?? null;
|
||||||
|
if (!byState.has(key)) byState.set(key, []);
|
||||||
|
byState.get(key)!.push(t);
|
||||||
|
}
|
||||||
|
const groups = stateList.map((s) => ({
|
||||||
|
id: s.id, name: s.name, color: s.color, group: s.group,
|
||||||
|
tasks: (byState.get(s.id) || []).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||||
|
}));
|
||||||
|
const unassigned = (byState.get(null) || []).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
if (unassigned.length > 0) groups.unshift({ id: "__none__", name: "No state", color: null, group: "unstarted", tasks: unassigned } as any);
|
||||||
|
return c.json({ groups, totalItems: withTags.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupBy === "priority") {
|
||||||
|
const order = ["urgent", "high", "medium", "low"] as const;
|
||||||
|
return c.json({
|
||||||
|
groups: order.map((p) => ({ id: p, name: p[0].toUpperCase() + p.slice(1), tasks: withTags.filter((t) => t.priority === p) })),
|
||||||
|
totalItems: withTags.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// group_by=project
|
||||||
|
const projectIds = [...new Set(withTags.map((t) => t.projectId).filter(Boolean))] as string[];
|
||||||
|
let projectList: { id: string; name: string; color: string | null }[] = [];
|
||||||
|
if (projectIds.length > 0) {
|
||||||
|
projectList = await db.select({ id: projects.id, name: projects.name, color: projects.color })
|
||||||
|
.from(projects).where(inArray(projects.id, projectIds));
|
||||||
|
}
|
||||||
|
const byProject = new Map<string | null, typeof withTags>();
|
||||||
|
for (const t of withTags) {
|
||||||
|
const key = t.projectId ?? null;
|
||||||
|
if (!byProject.has(key)) byProject.set(key, []);
|
||||||
|
byProject.get(key)!.push(t);
|
||||||
|
}
|
||||||
|
const groups = projectList.map((p) => ({ id: p.id, name: p.name, color: p.color, tasks: byProject.get(p.id) || [] }));
|
||||||
|
const inbox = byProject.get(null) || [];
|
||||||
|
if (inbox.length > 0) groups.unshift({ id: "__none__", name: "No project", color: null, tasks: inbox });
|
||||||
|
return c.json({ groups, totalItems: withTags.length });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||||
|
}
|
||||||
|
console.error("[tasks] GET /grouped error:", error);
|
||||||
|
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to group tasks" } }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/tasks — Create a task
|
// POST /api/tasks — Create a task
|
||||||
taskRoutes.post("/", async (c) => {
|
taskRoutes.post("/", async (c) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
/home/user/projects/dev/ProjectE/apps/web/node_modules
|
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { CalendarClock, Flag, FolderKanban, Search, SlidersHorizontal, X } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import type { State } from "@/lib/types";
|
||||||
|
|
||||||
|
export type DueFilter = "all" | "overdue" | "today" | "week" | "none";
|
||||||
|
|
||||||
|
export const DUE_FILTER_LABELS: Record<DueFilter, string> = {
|
||||||
|
all: "Any due date",
|
||||||
|
overdue: "Overdue",
|
||||||
|
today: "Due today",
|
||||||
|
week: "Due this week",
|
||||||
|
none: "No due date",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FilterBarProps {
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (v: string) => void;
|
||||||
|
projects: { id: string; name: string; color?: string | null }[];
|
||||||
|
projectId: string;
|
||||||
|
onProjectChange: (v: string) => void;
|
||||||
|
states: State[];
|
||||||
|
stateId: string;
|
||||||
|
onStateChange: (v: string) => void;
|
||||||
|
priority: string;
|
||||||
|
onPriorityChange: (v: string) => void;
|
||||||
|
dueFilter: DueFilter;
|
||||||
|
onDueChange: (v: DueFilter) => void;
|
||||||
|
onClearAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="gap-1 border-[#91caff] bg-[#e6f4ff] pl-2 pr-1 text-[11px] font-medium text-[#002c8c] hover:bg-[#bae0ff]"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="rounded-full p-0.5 hover:bg-[#91caff]/40"
|
||||||
|
aria-label={`Remove ${label} filter`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
projects,
|
||||||
|
projectId,
|
||||||
|
onProjectChange,
|
||||||
|
states,
|
||||||
|
stateId,
|
||||||
|
onStateChange,
|
||||||
|
priority,
|
||||||
|
onPriorityChange,
|
||||||
|
dueFilter,
|
||||||
|
onDueChange,
|
||||||
|
onClearAll,
|
||||||
|
}: FilterBarProps) {
|
||||||
|
const projectName = projects.find((p) => p.id === projectId)?.name;
|
||||||
|
const stateName = states.find((s) => s.id === stateId)?.name;
|
||||||
|
const activeCount =
|
||||||
|
(projectId ? 1 : 0) + (stateId ? 1 : 0) + (priority ? 1 : 0) + (dueFilter !== "all" ? 1 : 0) + (search ? 1 : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative min-w-[200px] flex-1 sm:max-w-xs">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search tasks..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="h-8 pl-8"
|
||||||
|
aria-label="Search tasks"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select value={projectId || "__all__"} onValueChange={(v) => onProjectChange(v === "__all__" ? "" : v)}>
|
||||||
|
<SelectTrigger className="h-8 w-44" aria-label="Filter by project">
|
||||||
|
<FolderKanban className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<SelectValue placeholder="All projects" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">All projects</SelectItem>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: p.color || "#1677ff" }} />
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{states.length > 0 && (
|
||||||
|
<Select value={stateId || "__all__"} onValueChange={(v) => onStateChange(v === "__all__" ? "" : v)}>
|
||||||
|
<SelectTrigger className="h-8 w-40" aria-label="Filter by state">
|
||||||
|
<SelectValue placeholder="All states" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">All states</SelectItem>
|
||||||
|
{states.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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select value={priority || "__all__"} onValueChange={(v) => onPriorityChange(v === "__all__" ? "" : v)}>
|
||||||
|
<SelectTrigger className="h-8 w-36" aria-label="Filter by priority">
|
||||||
|
<Flag className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<SelectValue placeholder="Priority" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">Any priority</SelectItem>
|
||||||
|
<SelectItem value="urgent">Urgent</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 gap-1.5">
|
||||||
|
<CalendarClock className="h-3.5 w-3.5" />
|
||||||
|
{dueFilter === "all" ? "Due date" : DUE_FILTER_LABELS[dueFilter]}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuLabel>Due date</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{(Object.keys(DUE_FILTER_LABELS) as DueFilter[]).map((d) => (
|
||||||
|
<DropdownMenuItem key={d} onClick={() => onDueChange(d)}>
|
||||||
|
{DUE_FILTER_LABELS[d]}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 gap-1 text-muted-foreground" onClick={onClearAll}>
|
||||||
|
<X className="h-3.5 w-3.5" /> Clear ({activeCount})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5" aria-label="Active filters">
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
|
<SlidersHorizontal className="h-3 w-3" /> Active:
|
||||||
|
</span>
|
||||||
|
{projectName && <Chip label={projectName} onRemove={() => onProjectChange("")} />}
|
||||||
|
{stateName && <Chip label={stateName} onRemove={() => onStateChange("")} />}
|
||||||
|
{priority && <Chip label={`Priority: ${priority}`} onRemove={() => onPriorityChange("")} />}
|
||||||
|
{dueFilter !== "all" && <Chip label={DUE_FILTER_LABELS[dueFilter]} onRemove={() => onDueChange("all")} />}
|
||||||
|
{search && <Chip label={`"${search}"`} onRemove={() => onSearchChange("")} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
PenLine,
|
PenLine,
|
||||||
Settings,
|
Settings,
|
||||||
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -24,6 +25,9 @@ import {
|
|||||||
FileBarChart,
|
FileBarChart,
|
||||||
FileStack,
|
FileStack,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useApiQuery } from "@/lib/api";
|
||||||
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
|
import type { PaginatedResponse, Project } from "@/lib/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -115,6 +119,13 @@ export function Sidebar() {
|
|||||||
? "right"
|
? "right"
|
||||||
: "left"
|
: "left"
|
||||||
);
|
);
|
||||||
|
const [projectsExpanded, setProjectsExpanded] = useState(true);
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
const { data: sidebarProjectsData } = useApiQuery<PaginatedResponse<Project>>(
|
||||||
|
["projects", activeDomainId, "sidebar"],
|
||||||
|
"/projects?limit=50&status=active" + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||||
|
);
|
||||||
|
const sidebarProjects = sidebarProjectsData?.items || [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onSidebarPositionChange = (event: Event) => {
|
const onSidebarPositionChange = (event: Event) => {
|
||||||
@@ -182,7 +193,64 @@ export function Sidebar() {
|
|||||||
const navigation = (isCollapsed: boolean, onNavigate?: () => void) => (
|
const navigation = (isCollapsed: boolean, onNavigate?: () => void) => (
|
||||||
<ScrollArea className="flex-1 py-2">
|
<ScrollArea className="flex-1 py-2">
|
||||||
<nav className="flex flex-col gap-0.5 px-2" aria-label="Primary">
|
<nav className="flex flex-col gap-0.5 px-2" aria-label="Primary">
|
||||||
{navItems.map((item) => renderNavLink(item, isCollapsed, onNavigate))}
|
{navItems.map((item) => {
|
||||||
|
if (item.href === "/projects" && !isCollapsed) {
|
||||||
|
const projectsActive = isActive("/projects");
|
||||||
|
return (
|
||||||
|
<div key={item.href}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex-1">{renderNavLink(item, isCollapsed, onNavigate)}</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0 text-muted-foreground"
|
||||||
|
onClick={() => setProjectsExpanded((v) => !v)}
|
||||||
|
aria-label={projectsExpanded ? "Collapse projects" : "Expand projects"}
|
||||||
|
aria-expanded={projectsExpanded}
|
||||||
|
>
|
||||||
|
<ChevronDown className={cn("h-4 w-4 transition-transform", !projectsExpanded && "-rotate-90")} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{projectsExpanded && (
|
||||||
|
<div className="mb-1 ml-6 flex flex-col gap-0.5 border-l border-sidebar-border pl-2" aria-label="Projects">
|
||||||
|
{sidebarProjects.slice(0, 8).map((p) => {
|
||||||
|
const current = location.pathname === `/projects/${p.id}`;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
to="/projects/$id"
|
||||||
|
params={{ id: p.id }}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 truncate rounded-md px-2 py-1.5 text-[13px] transition-colors",
|
||||||
|
current ? "bg-accent/50 font-semibold text-accent-foreground" : "text-muted-foreground hover:bg-accent/30 hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
title={p.name}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: p.color || "#1677ff" }} />
|
||||||
|
<span className="flex-1 truncate">{p.name}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{sidebarProjects.length === 0 && (
|
||||||
|
<p className="px-2 py-1 text-xs text-muted-foreground">No active projects</p>
|
||||||
|
)}
|
||||||
|
{(projectsActive || sidebarProjects.length > 8) && (
|
||||||
|
<Link
|
||||||
|
to="/projects"
|
||||||
|
onClick={onNavigate}
|
||||||
|
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent/30 hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
View all projects →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return renderNavLink(item, isCollapsed, onNavigate);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<nav className="flex flex-col gap-0.5 px-2" aria-label="Workspace">
|
<nav className="flex flex-col gap-0.5 px-2" aria-label="Workspace">
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Plus, Settings2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
|
import { parseTaskInput } from "@/lib/nlp";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type { Task } from "@/lib/types";
|
||||||
|
|
||||||
|
interface QuickAddBarProps {
|
||||||
|
projectId?: string | null;
|
||||||
|
sectionId?: string | null;
|
||||||
|
onMoreOptions?: () => void;
|
||||||
|
onCreated?: (task: Task) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linear-style quick-add: type a title, press Enter, the task appears.
|
||||||
|
* Natural-language parsing (due dates, priorities, tags) runs live.
|
||||||
|
*/
|
||||||
|
export function QuickAddBar({ projectId, sectionId, onMoreOptions, onCreated, placeholder }: QuickAddBarProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const parsed = title.trim() ? parseTaskInput(title) : null;
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => api.post<Task>("/tasks", data),
|
||||||
|
onSuccess: (task) => {
|
||||||
|
setTitle("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-groups"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["project"] });
|
||||||
|
onCreated?.(task);
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to create task"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const raw = title.trim();
|
||||||
|
if (!raw || createMutation.isPending) return;
|
||||||
|
const p = parseTaskInput(raw);
|
||||||
|
createMutation.mutate({
|
||||||
|
title: p.title,
|
||||||
|
priority: p.priority || "medium",
|
||||||
|
...(p.dueDate ? { dueDate: p.dueDate } : {}),
|
||||||
|
...(p.tags.length > 0 ? { tagNames: p.tags } : {}),
|
||||||
|
...(projectId ? { projectId } : {}),
|
||||||
|
...(sectionId ? { sectionId } : {}),
|
||||||
|
...(activeDomainId ? { domain: activeDomainId } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[#d9dee7] bg-white shadow-[0_1px_2px_#fafafa]">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<Plus className="h-4 w-4 shrink-0 text-[#1677ff]" />
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={placeholder || 'Add a task — try "Report due tomorrow 5pm #work p1"'}
|
||||||
|
className="h-8 border-0 px-0 shadow-none focus-visible:ring-0"
|
||||||
|
aria-label="Quick add task"
|
||||||
|
/>
|
||||||
|
{onMoreOptions && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={onMoreOptions} title="More options" aria-label="More task options">
|
||||||
|
<Settings2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" className="h-7 shrink-0" disabled={!title.trim() || createMutation.isPending} onClick={submit}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{parsed && (parsed.dueDate || parsed.priority || parsed.tags.length > 0) && (
|
||||||
|
<div className="flex flex-wrap gap-1 border-t border-[#f1f1f1] px-3 py-1.5">
|
||||||
|
{parsed.dueDate && (
|
||||||
|
<Badge variant="outline" className="border-[#91caff] bg-[#e6f4ff] text-[10px] text-[#002c8c]">
|
||||||
|
Due {new Date(parsed.dueDate).toLocaleDateString()}{" "}
|
||||||
|
{new Date(parsed.dueDate).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{parsed.priority && <Badge variant="secondary" className="text-[10px]">Priority {parsed.priority}</Badge>}
|
||||||
|
{parsed.tags.map((t) => (
|
||||||
|
<Badge key={t} variant="outline" className="text-[10px]">#{t}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ArrowUpRight, Calendar, Flag, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
import { api, useApiQuery } from "@/lib/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { TagManager } from "@/components/entities/tag-manager";
|
||||||
|
import { PRIORITY } from "@/lib/status-colors";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { State, Task } from "@/lib/types";
|
||||||
|
|
||||||
|
interface TaskDetailPanelProps {
|
||||||
|
taskId: string | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onOpenFullPage?: (task: Task) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : "Something went wrong";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskDetailPanel({ taskId, open, onOpenChange, onOpenFullPage }: TaskDetailPanelProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [newSubtask, setNewSubtask] = useState("");
|
||||||
|
|
||||||
|
const { data: task, isLoading } = useApiQuery<Task>(["task", taskId || ""], "/tasks/" + taskId, {
|
||||||
|
enabled: open && !!taskId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: statesData } = useApiQuery<{ items: State[] }>(
|
||||||
|
["states", task?.projectId || ""],
|
||||||
|
"/states?projectId=" + (task?.projectId || ""),
|
||||||
|
{ enabled: open && !!task?.projectId }
|
||||||
|
);
|
||||||
|
const projectStates = statesData?.items || [];
|
||||||
|
const currentState = task?.stateId ? projectStates.find((s) => s.id === task.stateId) : null;
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-groups"] });
|
||||||
|
if (taskId) queryClient.invalidateQueries({ queryKey: ["task", taskId] });
|
||||||
|
if (task?.projectId) queryClient.invalidateQueries({ queryKey: ["project", task.projectId] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchMutation = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => api.patch<Task>(`/tasks/${taskId}`, data),
|
||||||
|
onSuccess: refresh,
|
||||||
|
onError: (err) => toast.error(errorMessage(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addSubtaskMutation = useMutation({
|
||||||
|
mutationFn: (title: string) =>
|
||||||
|
api.post<Task>("/tasks", { title, parentId: taskId, projectId: task?.projectId || null, domain: task?.domainId }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewSubtask("");
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(errorMessage(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api.delete(`/tasks/${taskId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Task deleted");
|
||||||
|
refresh();
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(errorMessage(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const subtasks = task?.subtasks || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className="w-full p-0 sm:max-w-md">
|
||||||
|
<SheetHeader className="border-b border-[#f1f1f1] px-4 py-3">
|
||||||
|
<SheetTitle className="text-sm text-muted-foreground">Task details</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
{isLoading || !task ? (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">{isLoading ? "Loading..." : "Select a task"}</p>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-[calc(100vh-8rem)] px-4 py-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
defaultValue={task.title}
|
||||||
|
key={task.id + task.title}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const v = e.target.value.trim();
|
||||||
|
if (v && v !== task.title) patchMutation.mutate({ title: v });
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
|
}}
|
||||||
|
className="h-9 text-base font-semibold"
|
||||||
|
aria-label="Task title"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{currentState ? (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-1 text-[11px]"
|
||||||
|
style={currentState.color ? { backgroundColor: currentState.color + "20", color: currentState.color } : undefined}
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full" style={currentState.color ? { backgroundColor: currentState.color } : undefined} />
|
||||||
|
{currentState.name}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="text-[11px]">No state</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className={cn("text-[11px]", PRIORITY[task.priority]?.badge)}>
|
||||||
|
{PRIORITY[task.priority]?.label ?? task.priority}
|
||||||
|
</Badge>
|
||||||
|
{task.dueDate && (
|
||||||
|
<Badge variant="outline" className="text-[11px]">
|
||||||
|
<Calendar className="mr-1 h-3 w-3" />
|
||||||
|
{format(parseISO(task.dueDate), "MMM d, yyyy")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1 block text-xs text-muted-foreground">State</Label>
|
||||||
|
<Select
|
||||||
|
value={task.stateId || "__none__"}
|
||||||
|
onValueChange={(v) => patchMutation.mutate({ stateId: v === "__none__" ? null : v })}
|
||||||
|
disabled={projectStates.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue placeholder="No state" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">No state</SelectItem>
|
||||||
|
{projectStates.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1 block text-xs text-muted-foreground">Priority</Label>
|
||||||
|
<Select value={task.priority} onValueChange={(v) => patchMutation.mutate({ priority: v })}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
<SelectItem value="urgent">Urgent</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Calendar className="h-3 w-3" /> Due date
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-8"
|
||||||
|
defaultValue={task.dueDate ? task.dueDate.slice(0, 10) : ""}
|
||||||
|
key={task.id + (task.dueDate || "")}
|
||||||
|
onChange={(e) => patchMutation.mutate({ dueDate: e.target.value ? new Date(e.target.value).toISOString() : null })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Flag className="h-3 w-3" /> Estimate
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="h-8"
|
||||||
|
defaultValue={task.estimatedMinutes ?? ""}
|
||||||
|
key={task.id + (task.estimatedMinutes ?? "")}
|
||||||
|
placeholder="Minutes"
|
||||||
|
onBlur={(e) => {
|
||||||
|
const v = e.target.value ? parseInt(e.target.value, 10) : null;
|
||||||
|
patchMutation.mutate({ estimatedMinutes: v });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1 block text-xs text-muted-foreground">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
defaultValue={task.description || ""}
|
||||||
|
key={task.id + (task.description || "")}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Add a description…"
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (e.target.value !== (task.description || "")) {
|
||||||
|
patchMutation.mutate({ description: e.target.value || null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
Subtasks {subtasks.length > 0 && `(${subtasks.length})`}
|
||||||
|
</p>
|
||||||
|
{subtasks.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No subtasks yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{subtasks.map((s) => (
|
||||||
|
<TaskSubtaskRow key={s.id} subtask={s} projectStates={projectStates} onChanged={refresh} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-1.5 flex gap-1.5">
|
||||||
|
<Input
|
||||||
|
value={newSubtask}
|
||||||
|
onChange={(e) => setNewSubtask(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newSubtask.trim()) addSubtaskMutation.mutate(newSubtask.trim());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Add subtask…"
|
||||||
|
className="h-8"
|
||||||
|
aria-label="New subtask title"
|
||||||
|
/>
|
||||||
|
<Button size="sm" className="h-8" disabled={!newSubtask.trim() || addSubtaskMutation.isPending} onClick={() => addSubtaskMutation.mutate(newSubtask.trim())}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#f1f1f1] pt-3">
|
||||||
|
<TagManager entityType="task" entityId={task.id} tags={task.tags || []} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-[#f1f1f1] pt-3">
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
|
||||||
|
<Trash2 className="mr-1 h-3.5 w-3.5" /> Delete
|
||||||
|
</Button>
|
||||||
|
{onOpenFullPage && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onOpenFullPage(task)}>
|
||||||
|
Open full page <ArrowUpRight className="ml-1 h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskSubtaskRow({ subtask, projectStates, onChanged }: { subtask: Task; projectStates: State[]; onChanged: () => void }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const state = subtask.stateId ? projectStates.find((s) => s.id === subtask.stateId) : null;
|
||||||
|
const isDone = state?.group === "completed";
|
||||||
|
|
||||||
|
const toggle = async () => {
|
||||||
|
const completedState = projectStates.find((s) => s.group === "completed");
|
||||||
|
const openState = projectStates.find((s) => s.group !== "completed" && s.group !== "cancelled");
|
||||||
|
await api.patch(`/tasks/${subtask.id}`, {
|
||||||
|
stateId: isDone ? openState?.id || null : completedState?.id || null,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task", subtask.parentId || ""] });
|
||||||
|
onChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-muted/50">
|
||||||
|
<Checkbox checked={!!isDone} onCheckedChange={toggle} aria-label={"Toggle " + subtask.title} />
|
||||||
|
<span className={cn("min-w-0 flex-1 truncate text-sm", isDone && "text-muted-foreground line-through")}>
|
||||||
|
{subtask.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+20
-16
@@ -10,10 +10,10 @@
|
|||||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "Cascadia Code", "Source Code Pro",
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "Cascadia Code", "Source Code Pro",
|
||||||
Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
||||||
|
|
||||||
/* Control heights & sizing */
|
/* Control heights & sizing — Buzzbee 32px control grid */
|
||||||
--control-height: 40px;
|
--control-height: 32px;
|
||||||
--control-height-sm: 32px;
|
--control-height-sm: 24px;
|
||||||
--control-height-lg: 48px;
|
--control-height-lg: 40px;
|
||||||
--size: 16px;
|
--size: 16px;
|
||||||
--size-sm: 12px;
|
--size-sm: 12px;
|
||||||
--size-md: 20px;
|
--size-md: 20px;
|
||||||
@@ -27,30 +27,34 @@
|
|||||||
--duration-slow: 0.3s;
|
--duration-slow: 0.3s;
|
||||||
--ease-default: cubic-bezier(0.645, 0.045, 0.355, 1);
|
--ease-default: cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||||
|
|
||||||
/* Light mode — Buzzbee palette */
|
/* Light mode — Buzzbee palette (#1677ff accent, #01bec6 teal) */
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 0 0% 6.7%;
|
--foreground: 0 0% 6.7%;
|
||||||
--card: 220 13% 97%;
|
--card: 220 14% 97%;
|
||||||
--card-foreground: 0 0% 6.7%;
|
--card-foreground: 0 0% 6.7%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 0 0% 6.7%;
|
--popover-foreground: 0 0% 6.7%;
|
||||||
--primary: 217 91% 60%;
|
--primary: 216 100% 54%;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 210 40% 96%;
|
--secondary: 220 14% 97%;
|
||||||
--secondary-foreground: 0 0% 18%;
|
--secondary-foreground: 0 0% 18%;
|
||||||
--muted: 210 40% 96%;
|
--muted: 220 14% 97%;
|
||||||
--muted-foreground: 220 9% 45%;
|
--muted-foreground: 220 9% 45%;
|
||||||
--accent: 210 100% 95%;
|
--accent: 214 100% 95%;
|
||||||
--accent-foreground: 222 100% 27%;
|
--accent-foreground: 224 100% 27%;
|
||||||
--destructive: 0 84% 60%;
|
--destructive: 0 84% 60%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 214 32% 88%;
|
--border: 214 24% 87%;
|
||||||
--input: 214 32% 88%;
|
--input: 214 24% 87%;
|
||||||
--ring: 210 100% 75%;
|
--ring: 213 100% 77%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
/* Interactive accent — pinned vibrant blue */
|
/* Interactive accent — Buzzbee primary #1677ff */
|
||||||
--accent-hsl: 217 91% 60%;
|
--accent-hsl: 216 100% 54%;
|
||||||
|
--brand-teal: 184 99% 39%;
|
||||||
|
--brand-primary-bg: 214 100% 95%;
|
||||||
|
--brand-primary-border: 213 100% 77%;
|
||||||
|
--brand-focus-ring: 0 0 0 3px #e6f4ff;
|
||||||
|
|
||||||
/* Shell surfaces */
|
/* Shell surfaces */
|
||||||
--sidebar-bg: 0 0% 100%;
|
--sidebar-bg: 0 0% 100%;
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ export interface StatusToken {
|
|||||||
badge: string;
|
badge: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Task workflow statuses (board column dots + badges). */
|
/** Task workflow statuses (board column dots + badges) — Buzzbee tints. */
|
||||||
export const TASK_STATUS: Record<string, StatusToken> = {
|
export const TASK_STATUS: Record<string, StatusToken> = {
|
||||||
todo: { label: "Todo", dot: "bg-slate-500", badge: "bg-slate-500 text-white" },
|
todo: { label: "Todo", dot: "bg-slate-400", badge: "bg-[#f1f1f1] text-[#2e2e2e] border border-[#dbdbdb]" },
|
||||||
in_progress: { label: "In Progress", dot: "bg-blue-500", badge: "bg-blue-500 text-white" },
|
in_progress: { label: "In Progress", dot: "bg-[#1677ff]", badge: "bg-[#e6f4ff] text-[#002c8c] border border-[#91caff]" },
|
||||||
done: { label: "Done", dot: "bg-green-500", badge: "bg-green-500 text-white" },
|
done: { label: "Done", dot: "bg-[#52c41a]", badge: "bg-[#f6ffed] text-[#135200] border border-[#b6eb8f]" },
|
||||||
cancelled: { label: "Cancelled", dot: "bg-red-500", badge: "bg-red-500 text-white" },
|
cancelled: { label: "Cancelled", dot: "bg-[#ff4d4f]", badge: "bg-[#fff2f0] text-[#820014] border border-[#ffccc7]" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Task priority. Badges use a soft tint (matching text + translucent bg). */
|
/** Task priority. Badges use a soft tint (matching text + translucent bg). */
|
||||||
@@ -29,12 +29,12 @@ export const PRIORITY: Record<string, { label: string; badge: string }> = {
|
|||||||
urgent: { label: "Urgent", badge: "text-red-600 bg-red-500/15" },
|
urgent: { label: "Urgent", badge: "text-red-600 bg-red-500/15" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Project lifecycle statuses. */
|
/** Project lifecycle statuses — Buzzbee tints. */
|
||||||
export const PROJECT_STATUS: Record<string, StatusToken> = {
|
export const PROJECT_STATUS: Record<string, StatusToken> = {
|
||||||
active: { label: "Active", dot: "bg-green-500", badge: "bg-green-500 text-white" },
|
active: { label: "Active", dot: "bg-[#52c41a]", badge: "bg-[#f6ffed] text-[#135200] border border-[#b6eb8f]" },
|
||||||
paused: { label: "Paused", dot: "bg-amber-500", badge: "bg-amber-500 text-white" },
|
paused: { label: "Paused", dot: "bg-[#faad14]", badge: "bg-[#fffbe6] text-[#874c00] border border-[#ffe58f]" },
|
||||||
completed: { label: "Completed", dot: "bg-blue-500", badge: "bg-blue-500 text-white" },
|
completed: { label: "Completed", dot: "bg-[#1677ff]", badge: "bg-[#e6f4ff] text-[#002c8c] border border-[#91caff]" },
|
||||||
archived: { label: "Archived", dot: "bg-slate-500", badge: "bg-slate-500 text-white" },
|
archived: { label: "Archived", dot: "bg-slate-400", badge: "bg-[#f1f1f1] text-[#646464] border border-[#dbdbdb]" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,7 +62,7 @@ export const ENTITY: Record<string, string> = {
|
|||||||
* pick up the user's chosen accent color.
|
* pick up the user's chosen accent color.
|
||||||
*/
|
*/
|
||||||
export const CALENDAR_EVENT: Record<string, string> = {
|
export const CALENDAR_EVENT: Record<string, string> = {
|
||||||
task: "var(--accent-hsl, 217 91% 60%)",
|
task: "var(--accent-hsl, 216 100% 54%)",
|
||||||
habit: "142 71% 45%",
|
habit: "142 71% 45%",
|
||||||
project: "271 81% 56%",
|
project: "271 81% 56%",
|
||||||
note: "24 95% 53%",
|
note: "24 95% 53%",
|
||||||
|
|||||||
@@ -187,6 +187,12 @@ function CalendarPage() {
|
|||||||
const activeDomainId = useApiDomain();
|
const activeDomainId = useApiDomain();
|
||||||
const [showTasks, setShowTasks] = useState(true);
|
const [showTasks, setShowTasks] = useState(true);
|
||||||
const [showHabits, setShowHabits] = useState(true);
|
const [showHabits, setShowHabits] = useState(true);
|
||||||
|
const [filterProjectId, setFilterProjectId] = useState("");
|
||||||
|
|
||||||
|
const { data: calendarProjectsData } = useApiQuery<{ items: { id: string; name: string }[] }>(
|
||||||
|
["projects", activeDomainId, "calendar"],
|
||||||
|
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||||
|
);
|
||||||
|
|
||||||
const fromISO = useMemo(() => { const d = new Date(date); d.setDate(1); d.setHours(0,0,0,0); d.setMonth(d.getMonth()-1); return d.toISOString(); }, [date]);
|
const fromISO = useMemo(() => { const d = new Date(date); d.setDate(1); d.setHours(0,0,0,0); d.setMonth(d.getMonth()-1); return d.toISOString(); }, [date]);
|
||||||
const toISO = useMemo(() => { const d = new Date(date); d.setMonth(d.getMonth()+2); d.setDate(0); d.setHours(23,59,59,999); return d.toISOString(); }, [date]);
|
const toISO = useMemo(() => { const d = new Date(date); d.setMonth(d.getMonth()+2); d.setDate(0); d.setHours(23,59,59,999); return d.toISOString(); }, [date]);
|
||||||
@@ -196,8 +202,8 @@ function CalendarPage() {
|
|||||||
`/calendar/events?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "")
|
`/calendar/events?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||||
);
|
);
|
||||||
const { data: unifiedData } = useApiQuery<{ items: CalendarEvent[] }>(
|
const { data: unifiedData } = useApiQuery<{ items: CalendarEvent[] }>(
|
||||||
["calendar-unified", activeDomainId, fromISO, toISO],
|
["calendar-unified", activeDomainId, fromISO, toISO, filterProjectId],
|
||||||
`/calendar/unified?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "")
|
`/calendar/unified?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "") + (filterProjectId ? "&project_id=" + filterProjectId : "")
|
||||||
);
|
);
|
||||||
const events = useMemo(() => {
|
const events = useMemo(() => {
|
||||||
if (!unifiedData?.items) return eventsData?.items || [];
|
if (!unifiedData?.items) return eventsData?.items || [];
|
||||||
@@ -352,6 +358,17 @@ function CalendarPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={showTasks} onChange={e=>setShowTasks(e.target.checked)} /> Tasks</label>
|
<label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={showTasks} onChange={e=>setShowTasks(e.target.checked)} /> Tasks</label>
|
||||||
<label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={showHabits} onChange={e=>setShowHabits(e.target.checked)} /> Habits</label>
|
<label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={showHabits} onChange={e=>setShowHabits(e.target.checked)} /> Habits</label>
|
||||||
|
<select
|
||||||
|
value={filterProjectId}
|
||||||
|
onChange={(e) => setFilterProjectId(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-2 text-xs"
|
||||||
|
aria-label="Filter calendar by project"
|
||||||
|
>
|
||||||
|
<option value="">All projects</option>
|
||||||
|
{(calendarProjectsData?.items || []).map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{["month", "week", "day", "agenda"].map((name) => (
|
{["month", "week", "day", "agenda"].map((name) => (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -6,7 +6,7 @@ import { api, useApiQuery } from "@/lib/api";
|
|||||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
|
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
|
||||||
import { Plus, Pencil, Trash2, Calendar, LayoutGrid, List } from "lucide-react";
|
import { Plus, Pencil, Trash2, Calendar, LayoutGrid, List, Search, Clock } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -117,15 +117,16 @@ function ProjectCard({
|
|||||||
onOpenPanel: (p: Project) => void;
|
onOpenPanel: (p: Project) => void;
|
||||||
}) {
|
}) {
|
||||||
const status = PROJECT_STATUS[project.status];
|
const status = PROJECT_STATUS[project.status];
|
||||||
|
const overdue = project.targetDate && project.status === "active" && new Date(project.targetDate) < new Date();
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="cursor-pointer border rounded-lg transition-colors hover:bg-muted/20"
|
className="cursor-pointer rounded-lg border border-[#d9dee7] bg-white shadow-[0_1px_2px_#fafafa] transition-colors hover:bg-[#f7f8fa]"
|
||||||
onClick={() => onOpenDetail(project)}
|
onClick={() => onOpenDetail(project)}
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-1.5">
|
<CardHeader className="pb-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
<div className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: project.color || "#1677ff" }} />
|
||||||
<CardTitle className="text-base truncate">{project.name}</CardTitle>
|
<CardTitle className="truncate text-base">{project.name}</CardTitle>
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
{status && (
|
{status && (
|
||||||
<Badge variant="secondary" className="font-mono text-[10px] gap-1.5">
|
<Badge variant="secondary" className="font-mono text-[10px] gap-1.5">
|
||||||
@@ -155,9 +156,16 @@ function ProjectCard({
|
|||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span className="font-mono">{project.completedCount}/{project.taskCount} tasks</span>
|
<span className="font-mono">{project.completedCount}/{project.taskCount} tasks</span>
|
||||||
{project.targetDate && (
|
{project.targetDate && (
|
||||||
<span className="flex items-center gap-1"><Calendar className="h-3 w-3" />{new Date(project.targetDate).toLocaleDateString()}</span>
|
<span className={`flex items-center gap-1 ${overdue ? "font-semibold text-destructive" : ""}`}>
|
||||||
|
<Calendar className="h-3 w-3" />{new Date(project.targetDate).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{project.updatedAt && (
|
||||||
|
<p className="mt-1.5 flex items-center gap-1 font-mono text-[10px] text-muted-foreground">
|
||||||
|
<Clock className="h-3 w-3" /> Updated {new Date(project.updatedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -185,8 +193,19 @@ function ProjectsPage() {
|
|||||||
projectQueryUrl()
|
projectQueryUrl()
|
||||||
);
|
);
|
||||||
|
|
||||||
const projects = projectsData?.items || [];
|
const allProjects = projectsData?.items || [];
|
||||||
const hasMoreProjects = projects.length < (projectsData?.totalItems || 0);
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
const projects = useMemo(
|
||||||
|
() =>
|
||||||
|
allProjects.filter(
|
||||||
|
(p) =>
|
||||||
|
(!search || p.name.toLowerCase().includes(search.toLowerCase())) &&
|
||||||
|
(!statusFilter || p.status === statusFilter)
|
||||||
|
),
|
||||||
|
[allProjects, search, statusFilter]
|
||||||
|
);
|
||||||
|
const hasMoreProjects = allProjects.length < (projectsData?.totalItems || 0);
|
||||||
const [loadingMoreProjects, setLoadingMoreProjects] = useState(false);
|
const [loadingMoreProjects, setLoadingMoreProjects] = useState(false);
|
||||||
|
|
||||||
const loadMoreProjects = async () => {
|
const loadMoreProjects = async () => {
|
||||||
@@ -246,14 +265,45 @@ function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative min-w-[180px] flex-1 sm:max-w-xs">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input placeholder="Search projects..." value={search} onChange={(e) => setSearch(e.target.value)} className="h-8 pl-8" aria-label="Search projects" />
|
||||||
|
</div>
|
||||||
|
<Select value={statusFilter || "__all__"} onValueChange={(v) => setStatusFilter(v === "__all__" ? "" : v)}>
|
||||||
|
<SelectTrigger className="h-8 w-40" aria-label="Filter by status"><SelectValue placeholder="All statuses" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">All statuses</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="paused">Paused</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
<SelectItem value="archived">Archived</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{(search || statusFilter) && (
|
||||||
|
<Button variant="ghost" size="sm" className="h-8" onClick={() => { setSearch(""); setStatusFilter(""); }}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto font-mono text-xs text-muted-foreground">{projects.length} projects</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingState label="Loading projects..." />
|
<LoadingState label="Loading projects..." />
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
<ErrorState message="Failed to load projects." onRetry={() => refetch()} />
|
<ErrorState message="Failed to load projects." onRetry={() => refetch()} />
|
||||||
) : projects.length === 0 ? (
|
) : projects.length === 0 ? (
|
||||||
<EmptyState title="No projects yet" description="Create your first project to get started." />
|
<EmptyState title={search || statusFilter ? "No matching projects" : "No projects yet"} description={search || statusFilter ? "Try clearing your filters." : "Create your first project to get started."} />
|
||||||
) : view === "grid" ? (
|
) : view === "grid" ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
className="flex min-h-[148px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-[#91caff] bg-[#e6f4ff]/40 text-sm font-medium text-[#002c8c] transition-colors hover:bg-[#e6f4ff]"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
New project
|
||||||
|
</button>
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import { Route as appRoute } from "../../_app";
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
|
Boxes,
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
Flag,
|
Flag,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
|
Layers,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
|
MessageSquare,
|
||||||
Plus,
|
Plus,
|
||||||
Repeat,
|
Repeat,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -19,7 +23,6 @@ import { differenceInCalendarDays, format, parseISO } from "date-fns";
|
|||||||
import { api, useApiQuery } from "@/lib/api";
|
import { api, useApiQuery } from "@/lib/api";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
import { useOptimisticPatch } from "@/hooks/use-optimistic-patch";
|
import { useOptimisticPatch } from "@/hooks/use-optimistic-patch";
|
||||||
import { EntityDetailPage } from "@/components/entities/detail-page";
|
|
||||||
import {
|
import {
|
||||||
InlineDate,
|
InlineDate,
|
||||||
InlineEdit,
|
InlineEdit,
|
||||||
@@ -153,66 +156,87 @@ function ProjectDetail() {
|
|||||||
onError: (err) => toast.error(errorMessage(err)),
|
onError: (err) => toast.error(errorMessage(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [section, setSection] = useState<"tasks" | "overview" | "sections" | "modules" | "cycles" | "activity" | "comments">("tasks");
|
||||||
|
|
||||||
|
const { data: stats } = useApiQuery<{
|
||||||
|
total: number; completed: number; overdue: number; dueSoon: number; progress: number;
|
||||||
|
}>(["project-stats", id], "/projects/" + id + "/stats", { enabled: !!id });
|
||||||
|
|
||||||
if (isLoading) return <LoadingState label="Loading project..." />;
|
if (isLoading) return <LoadingState label="Loading project..." />;
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
|
return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
|
||||||
}
|
}
|
||||||
if (!project) return <ErrorState message="Project not found" />;
|
if (!project) return <ErrorState message="Project not found" />;
|
||||||
|
|
||||||
|
const taskCount = project.taskCount ?? project.tasks?.length ?? 0;
|
||||||
|
const sectionCount = project.sections?.length ?? 0;
|
||||||
|
|
||||||
|
const navItems: { value: typeof section; label: string; icon: React.ReactNode; count?: number }[] = [
|
||||||
|
{ value: "tasks", label: "Tasks", icon: <ListTodo className="h-4 w-4" />, count: taskCount },
|
||||||
|
{ value: "sections", label: "Sections", icon: <Layers className="h-4 w-4" />, count: sectionCount },
|
||||||
|
{ value: "modules", label: "Modules", icon: <Boxes className="h-4 w-4" /> },
|
||||||
|
{ value: "cycles", label: "Cycles", icon: <Repeat className="h-4 w-4" /> },
|
||||||
|
{ value: "overview", label: "Overview", icon: <LayoutGrid className="h-4 w-4" /> },
|
||||||
|
{ value: "activity", label: "Activity", icon: <Activity className="h-4 w-4" /> },
|
||||||
|
{ value: "comments", label: "Comments", icon: <MessageSquare className="h-4 w-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntityDetailPage
|
<div className="space-y-4">
|
||||||
backTo={{ to: "/projects", label: "Back to Projects" }}
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
title={
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: "/projects" })}>
|
||||||
<InlineText
|
← Projects
|
||||||
value={project.name}
|
</Button>
|
||||||
onSave={(name) => patch({ id, data: { name } })}
|
<span className="flex h-9 w-9 items-center justify-center rounded-lg border border-[#d9dee7] bg-[#f7f8fa]">
|
||||||
placeholder="Untitled project"
|
<FolderKanban className="h-5 w-5" style={{ color: project.color || "#1677ff" }} />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="text-lg font-bold">
|
||||||
|
<InlineText
|
||||||
|
value={project.name}
|
||||||
|
onSave={(name) => patch({ id, data: { name } })}
|
||||||
|
placeholder="Untitled project"
|
||||||
|
/>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<InlineSelect
|
||||||
|
value={project.status}
|
||||||
|
options={PROJECT_STATUS_OPTIONS}
|
||||||
|
displayValue={(v) => (
|
||||||
|
<Badge className={PROJECT_STATUS[v]?.badge}>
|
||||||
|
{PROJECT_STATUS[v]?.label ?? v}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
onSave={(status) => patch({ id, data: { status } })}
|
||||||
|
/>
|
||||||
|
<InlineEdit
|
||||||
|
value={project.color ?? ""}
|
||||||
|
onSave={(color) => patch({ id, data: { color: color || null } })}
|
||||||
|
showEditIcon={false}
|
||||||
|
title="Edit color"
|
||||||
|
display={() => (
|
||||||
|
<span
|
||||||
|
className="h-4 w-4 rounded-full border border-[#d9dee7]"
|
||||||
|
style={{ backgroundColor: project.color || "#1677ff" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderEdit={(v, onChange, commit) => (
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
autoFocus
|
||||||
|
className="h-8 w-12"
|
||||||
|
value={v || "#1677ff"}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
commit(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
icon={<FolderKanban className="h-6 w-6" />}
|
|
||||||
badges={
|
|
||||||
<>
|
|
||||||
<InlineSelect
|
|
||||||
value={project.status}
|
|
||||||
options={PROJECT_STATUS_OPTIONS}
|
|
||||||
displayValue={(v) => (
|
|
||||||
<Badge className={PROJECT_STATUS[v]?.badge}>
|
|
||||||
{PROJECT_STATUS[v]?.label ?? v}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
onSave={(status) => patch({ id, data: { status } })}
|
|
||||||
/>
|
|
||||||
<InlineEdit
|
|
||||||
value={project.color ?? ""}
|
|
||||||
onSave={(color) => patch({ id, data: { color: color || null } })}
|
|
||||||
showEditIcon={false}
|
|
||||||
title="Edit color"
|
|
||||||
display={() => (
|
|
||||||
<span
|
|
||||||
className="h-4 w-4 rounded-full"
|
|
||||||
style={{ backgroundColor: project.color || "#3b82f6" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
renderEdit={(v, onChange, commit) => (
|
|
||||||
<Input
|
|
||||||
type="color"
|
|
||||||
autoFocus
|
|
||||||
className="h-8 w-12"
|
|
||||||
value={v || "#3b82f6"}
|
|
||||||
onChange={(e) => {
|
|
||||||
onChange(e.target.value);
|
|
||||||
commit(e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
actions={
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive">
|
<Button variant="destructive" size="sm">
|
||||||
<Trash2 className="h-4 w-4" /> Delete
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -234,29 +258,55 @@ function ProjectDetail() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
}
|
</div>
|
||||||
tabs={[
|
|
||||||
{
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
value: "overview",
|
<nav className="w-full shrink-0 space-y-1 md:w-52" aria-label="Project sections">
|
||||||
label: "Overview",
|
<div className="rounded-lg border border-[#d9dee7] bg-white p-3">
|
||||||
content: <Overview project={project} patch={patch} />,
|
<div className="flex items-center gap-2">
|
||||||
},
|
<ListTodo className="h-4 w-4 text-muted-foreground" />
|
||||||
{ value: "tasks", label: "Tasks", content: <ProjectTasks project={project} /> },
|
<p className="text-sm font-semibold">Progress</p>
|
||||||
{ value: "sections", label: "Sections", content: <Sections project={project} /> },
|
<span className="ml-auto font-mono text-xs font-medium">{stats?.progress ?? project.progress ?? 0}%</span>
|
||||||
{ value: "modules", label: "Modules", content: <ProjectModules project={project} /> },
|
</div>
|
||||||
{ value: "cycles", label: "Cycles", content: <ProjectCycles project={project} /> },
|
<Progress value={stats?.progress ?? project.progress ?? 0} className="mt-2 h-1.5" />
|
||||||
{
|
<p className="mt-1.5 font-mono text-[11px] text-muted-foreground">
|
||||||
value: "activity",
|
{stats?.completed ?? project.completedCount ?? 0} of {stats?.total ?? taskCount} done
|
||||||
label: "Activity",
|
{(stats?.overdue ?? 0) > 0 && <span className="text-destructive"> · {stats?.overdue} overdue</span>}
|
||||||
content: <EntityActivity entityType="project" entityId={id} />,
|
</p>
|
||||||
},
|
</div>
|
||||||
{
|
{navItems.map((item) => (
|
||||||
value: "comments",
|
<button
|
||||||
label: "Comments",
|
key={item.value}
|
||||||
content: <EntityComments entityType="project" entityId={id} />,
|
type="button"
|
||||||
},
|
onClick={() => setSection(item.value)}
|
||||||
]}
|
className={cn(
|
||||||
/>
|
"flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||||
|
section === item.value
|
||||||
|
? "border-b-2 border-b-[#1677ff] bg-[#e6f4ff]/60 font-semibold text-[#002c8c]"
|
||||||
|
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
aria-current={section === item.value ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span className="flex-1 text-left">{item.label}</span>
|
||||||
|
{item.count !== undefined && (
|
||||||
|
<Badge variant="secondary" className="font-mono text-[10px]">{item.count}</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 rounded-lg border border-[#d9dee7] bg-white p-4">
|
||||||
|
{section === "tasks" && <ProjectTasks project={project} />}
|
||||||
|
{section === "overview" && <Overview project={project} patch={patch} />}
|
||||||
|
{section === "sections" && <Sections project={project} />}
|
||||||
|
{section === "modules" && <ProjectModules project={project} />}
|
||||||
|
{section === "cycles" && <ProjectCycles project={project} />}
|
||||||
|
{section === "activity" && <EntityActivity entityType="project" entityId={id} />}
|
||||||
|
{section === "comments" && <EntityComments entityType="project" entityId={id} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+329
-198
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -9,7 +9,7 @@ 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 { 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 { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { Plus, GripVertical, Pencil, Trash2, Calendar, ListTodo, Layout as LayoutIcon, Search, MoreHorizontal, Bookmark, X } from "lucide-react";
|
import { Plus, GripVertical, Pencil, Trash2, Calendar, ListTodo, Layout as LayoutIcon, MoreHorizontal, Bookmark, X, CalendarDays } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -21,30 +21,33 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
||||||
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
|
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
|
||||||
import { PRIORITY } from "@/lib/status-colors";
|
import { PRIORITY } from "@/lib/status-colors";
|
||||||
import type { Task, State, StateGroup, PaginatedResponse } from "@/lib/types";
|
import type { Task, State, PaginatedResponse } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { parseTaskInput } from "@/lib/nlp";
|
import { parseTaskInput } from "@/lib/nlp";
|
||||||
import { useSavedViewsStore } from "@/lib/stores/use-saved-views-store";
|
import { useSavedViewsStore } from "@/lib/stores/use-saved-views-store";
|
||||||
import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
|
import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
|
||||||
import { BulkActionBar } from "@/components/tasks/bulk-action-bar";
|
import { BulkActionBar } from "@/components/tasks/bulk-action-bar";
|
||||||
|
import { FilterBar, type DueFilter } from "@/components/filters/filter-bar";
|
||||||
|
import { QuickAddBar } from "@/components/tasks/quick-add-bar";
|
||||||
|
import { TaskDetailPanel } from "@/components/tasks/task-detail-panel";
|
||||||
import { showUndoToast } from "@/lib/undo/use-undo-toast";
|
import { showUndoToast } from "@/lib/undo/use-undo-toast";
|
||||||
|
import { format, parseISO, startOfDay, endOfDay, addDays, startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth, isToday } from "date-fns";
|
||||||
|
|
||||||
const STATE_GROUP_COLUMNS: { id: StateGroup; label: string; colorClass: string }[] = [
|
/** Fallback columns when no project is selected (group by task status). */
|
||||||
{ id: "backlog", label: "Backlog", colorClass: "bg-slate-400" },
|
const FALLBACK_COLUMNS: { id: string; label: string; colorClass: string; statuses: string[] }[] = [
|
||||||
{ id: "unstarted", label: "Unstarted", colorClass: "bg-slate-500" },
|
{ id: "todo", label: "Todo", colorClass: "bg-slate-400", statuses: ["todo"] },
|
||||||
{ id: "started", label: "Started", colorClass: "bg-blue-500" },
|
{ id: "in_progress", label: "In Progress", colorClass: "bg-blue-500", statuses: ["in_progress"] },
|
||||||
{ id: "completed", label: "Completed", colorClass: "bg-green-500" },
|
{ id: "done", label: "Done", colorClass: "bg-green-500", statuses: ["done"] },
|
||||||
{ id: "cancelled", label: "Cancelled", colorClass: "bg-red-500" },
|
{ id: "cancelled", label: "Cancelled", colorClass: "bg-red-500", statuses: ["cancelled"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
function SortableTaskCard({ task, stateName, stateColor, onClick, onEdit }: { task: Task; stateName?: string; stateColor?: string | null; onClick: () => void; onEdit?: () => void }) {
|
function SortableTaskCard({ task, stateName, stateColor, projectName, onClick, onEdit }: { task: Task; stateName?: string; stateColor?: string | null; projectName?: string; onClick: () => void; onEdit?: () => void }) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
@@ -53,42 +56,47 @@ function SortableTaskCard({ task, stateName, stateColor, onClick, onEdit }: { ta
|
|||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const overdue = task.dueDate && !task.completedAt && new Date(task.dueDate) < new Date();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||||
<Card className="cursor-pointer border rounded-lg transition-colors hover:bg-muted/20" onClick={onClick}>
|
<Card className="cursor-pointer rounded-lg border border-[#d9dee7] bg-white transition-colors hover:bg-[#f7f8fa]" onClick={onClick}>
|
||||||
<CardContent className="p-2.5">
|
<CardContent className="p-2.5">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<GripVertical className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
|
<GripVertical className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium truncate">{task.title}</p>
|
<p className="truncate text-sm font-medium">{task.title}</p>
|
||||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{stateName && (
|
{stateName && (
|
||||||
<Badge variant="secondary" className="font-mono text-[10px] gap-1" style={stateColor ? { backgroundColor: stateColor + "20", color: stateColor } : undefined}>
|
<Badge variant="secondary" className="gap-1 font-mono text-[10px]" style={stateColor ? { backgroundColor: stateColor + "20", color: stateColor } : undefined}>
|
||||||
<span className="h-1.5 w-1.5 rounded-full" style={stateColor ? { backgroundColor: stateColor } : undefined} />
|
<span className="h-1.5 w-1.5 rounded-full" style={stateColor ? { backgroundColor: stateColor } : undefined} />
|
||||||
{stateName}
|
{stateName}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<Badge variant="outline" className="font-mono text-[10px]">
|
<Badge variant="outline" className={cn("font-mono text-[10px]", overdue && "border-[#ffccc7] bg-[#fff2f0] text-[#820014]")}>
|
||||||
<Calendar className="h-3 w-3 mr-1" />
|
<Calendar className="mr-1 h-3 w-3" />
|
||||||
{new Date(task.dueDate).toLocaleDateString()}
|
{new Date(task.dueDate).toLocaleDateString()}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge variant="secondary" className={cn("font-mono text-[10px]", PRIORITY[task.priority]?.badge)}>
|
<Badge variant="secondary" className={cn("font-mono text-[10px]", PRIORITY[task.priority]?.badge)}>
|
||||||
{PRIORITY[task.priority]?.label ?? task.priority}
|
{PRIORITY[task.priority]?.label ?? task.priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
{task.tags?.slice(0, 2).map((tag) => (
|
{task.tags?.slice(0, 3).map((tag) => (
|
||||||
<Badge key={tag.id} variant="outline" className="font-mono text-[10px]" style={{ borderColor: tag.color || undefined }}>
|
<Badge key={tag.id} variant="outline" className="font-mono text-[10px]" style={{ borderColor: tag.color || undefined }}>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{projectName && (
|
||||||
|
<p className="mt-1 truncate text-[11px] text-muted-foreground">{projectName}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 -mt-1 -mr-1 shrink-0"
|
className="-mr-1 -mt-1 h-7 w-7 shrink-0"
|
||||||
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
||||||
aria-label={"Edit " + task.title}
|
aria-label={"Edit " + task.title}
|
||||||
>
|
>
|
||||||
@@ -169,6 +177,7 @@ function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => vo
|
|||||||
if (recurrenceRule) data.recurrenceRule = recurrenceRule;
|
if (recurrenceRule) data.recurrenceRule = recurrenceRule;
|
||||||
const customFields = { ...customFieldValues };
|
const customFields = { ...customFieldValues };
|
||||||
if (Object.keys(customFields).length > 0) data.customFields = customFields;
|
if (Object.keys(customFields).length > 0) data.customFields = customFields;
|
||||||
|
if (projectId && !task) data.projectId = projectId;
|
||||||
if (task) {
|
if (task) {
|
||||||
updateMutation.mutate(data);
|
updateMutation.mutate(data);
|
||||||
} else {
|
} else {
|
||||||
@@ -182,8 +191,8 @@ function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => vo
|
|||||||
<Label htmlFor="title">Title <span className="text-xs text-muted-foreground">— try "Buy milk tomorrow 5pm #groceries p1"</span></Label>
|
<Label htmlFor="title">Title <span className="text-xs text-muted-foreground">— try "Buy milk tomorrow 5pm #groceries p1"</span></Label>
|
||||||
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder='e.g. Report due tomorrow 5pm #work p1' required />
|
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder='e.g. Report due tomorrow 5pm #work p1' required />
|
||||||
{parsed && (parsed.dueDate || parsed.priority || parsed.tags.length > 0) && (
|
{parsed && (parsed.dueDate || parsed.priority || parsed.tags.length > 0) && (
|
||||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{parsed.dueDate && <Badge variant="outline" className="text-[10px]">Due {new Date(parsed.dueDate).toLocaleDateString()} {new Date(parsed.dueDate).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}</Badge>}
|
{parsed.dueDate && <Badge variant="outline" className="border-[#91caff] bg-[#e6f4ff] text-[10px] text-[#002c8c]">Due {new Date(parsed.dueDate).toLocaleDateString()} {new Date(parsed.dueDate).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}</Badge>}
|
||||||
{parsed.priority && <Badge variant="secondary" className="text-[10px]">Priority {parsed.priority}</Badge>}
|
{parsed.priority && <Badge variant="secondary" className="text-[10px]">Priority {parsed.priority}</Badge>}
|
||||||
{parsed.tags.map(t => <Badge key={t} variant="outline" className="text-[10px]">#{t}</Badge>)}
|
{parsed.tags.map(t => <Badge key={t} variant="outline" className="text-[10px]">#{t}</Badge>)}
|
||||||
</div>
|
</div>
|
||||||
@@ -242,14 +251,106 @@ function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => vo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyDueFilter(tasks: Task[], dueFilter: DueFilter): Task[] {
|
||||||
|
if (dueFilter === "all") return tasks;
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = startOfDay(now);
|
||||||
|
const todayEnd = endOfDay(now);
|
||||||
|
const weekEnd = endOfDay(addDays(now, 7));
|
||||||
|
return tasks.filter((t) => {
|
||||||
|
if (dueFilter === "none") return !t.dueDate;
|
||||||
|
if (!t.dueDate) return false;
|
||||||
|
const d = new Date(t.dueDate);
|
||||||
|
if (dueFilter === "overdue") return d < now && !t.completedAt;
|
||||||
|
if (dueFilter === "today") return d >= todayStart && d <= todayEnd;
|
||||||
|
if (dueFilter === "week") return d >= todayStart && d <= weekEnd;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function TasksCalendar({ tasks, onSelectTask }: { tasks: Task[]; onSelectTask: (t: Task) => void }) {
|
||||||
|
const [month, setMonth] = useState(() => startOfMonth(new Date()));
|
||||||
|
const days = useMemo(() => {
|
||||||
|
const start = startOfWeek(startOfMonth(month));
|
||||||
|
const end = endOfWeek(endOfMonth(month));
|
||||||
|
return eachDayOfInterval({ start, end });
|
||||||
|
}, [month]);
|
||||||
|
|
||||||
|
const byDay = useMemo(() => {
|
||||||
|
const map = new Map<string, Task[]>();
|
||||||
|
for (const t of tasks) {
|
||||||
|
if (!t.dueDate) continue;
|
||||||
|
const key = format(parseISO(t.dueDate), "yyyy-MM-dd");
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(t);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[#d9dee7] bg-white">
|
||||||
|
<div className="flex items-center justify-between border-b border-[#f1f1f1] px-3 py-2">
|
||||||
|
<h3 className="font-mono text-sm font-semibold">{format(month, "MMMM yyyy")}</h3>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="outline" size="sm" className="h-7" onClick={() => setMonth(startOfMonth(new Date()))}>Today</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-7" onClick={() => setMonth(addDays(startOfMonth(month), -15))}>←</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-7" onClick={() => setMonth(addDays(endOfMonth(month), 15))}>→</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 border-b border-[#f1f1f1] bg-[#fafafa]">
|
||||||
|
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((d) => (
|
||||||
|
<div key={d} className="px-2 py-1 text-center text-[11px] font-semibold text-muted-foreground">{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7">
|
||||||
|
{days.map((day) => {
|
||||||
|
const key = format(day, "yyyy-MM-dd");
|
||||||
|
const dayTasks = byDay.get(key) || [];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[84px] border-b border-r border-[#f1f1f1] p-1 [&:nth-child(7n)]:border-r-0",
|
||||||
|
!isSameMonth(day, month) && "bg-[#fafafa]/60 text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className={cn("px-1 font-mono text-[11px]", isToday(day) && "font-bold text-[#1677ff]")}>
|
||||||
|
{format(day, "d")}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{dayTasks.slice(0, 3).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectTask(t)}
|
||||||
|
className="block w-full truncate rounded border border-[#91caff] bg-[#e6f4ff] px-1 py-0.5 text-left text-[11px] text-[#002c8c] hover:bg-[#bae0ff]"
|
||||||
|
>
|
||||||
|
{t.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{dayTasks.length > 3 && (
|
||||||
|
<p className="px-1 text-[10px] text-muted-foreground">+{dayTasks.length - 3} more</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TasksPage() {
|
function TasksPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [view, setView] = useState<"board" | "list">("board");
|
const [view, setView] = useState<"board" | "list" | "calendar">("board");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [priority, setPriority] = useState("");
|
||||||
|
const [dueFilter, setDueFilter] = useState<DueFilter>("all");
|
||||||
const [selectedStateId, setSelectedStateId] = useState("");
|
const [selectedStateId, setSelectedStateId] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
|
const [panelTaskId, setPanelTaskId] = useState<string | null>(null);
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [selectedRowIndex, setSelectedRowIndex] = useState<number>(-1);
|
const [selectedRowIndex, setSelectedRowIndex] = useState<number>(-1);
|
||||||
@@ -264,11 +365,12 @@ function TasksPage() {
|
|||||||
|
|
||||||
const activeDomainId = useApiDomain();
|
const activeDomainId = useApiDomain();
|
||||||
|
|
||||||
const { data: projectsData } = useApiQuery<PaginatedResponse<{ id: string; name: string }>>(
|
const { data: projectsData } = useApiQuery<PaginatedResponse<{ id: string; name: string; color?: string | null }>>(
|
||||||
["projects", activeDomainId],
|
["projects", activeDomainId],
|
||||||
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
|
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||||
);
|
);
|
||||||
const projects = projectsData?.items || [];
|
const projects = projectsData?.items || [];
|
||||||
|
const projectById = useMemo(() => new Map(projects.map((p) => [p.id, p])), [projects]);
|
||||||
|
|
||||||
const [filterProjectId, setFilterProjectId] = useState("");
|
const [filterProjectId, setFilterProjectId] = useState("");
|
||||||
const [saveViewOpen, setSaveViewOpen] = useState(false);
|
const [saveViewOpen, setSaveViewOpen] = useState(false);
|
||||||
@@ -282,13 +384,7 @@ function TasksPage() {
|
|||||||
"/states?projectId=" + filterProjectId,
|
"/states?projectId=" + filterProjectId,
|
||||||
{ enabled: !!filterProjectId }
|
{ enabled: !!filterProjectId }
|
||||||
);
|
);
|
||||||
const projectStates = statesData?.items || [];
|
const projectStates = useMemo(() => (statesData?.items || []).sort((a, b) => a.sortOrder - b.sortOrder), [statesData]);
|
||||||
|
|
||||||
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 stateById = useMemo(() => {
|
||||||
const map = new Map<string, State>();
|
const map = new Map<string, State>();
|
||||||
@@ -296,6 +392,15 @@ function TasksPage() {
|
|||||||
return map;
|
return map;
|
||||||
}, [projectStates]);
|
}, [projectStates]);
|
||||||
|
|
||||||
|
const groupedQuery = useQuery({
|
||||||
|
queryKey: ["task-groups", activeDomainId, filterProjectId],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<{ groups: { id: string; name: string; color: string | null; group?: string; tasks: Task[] }[]; totalItems: number }>(
|
||||||
|
"/tasks/grouped?group_by=state" + (activeDomainId ? "&domain=" + activeDomainId : "") + (filterProjectId ? "&project_id=" + filterProjectId : "")
|
||||||
|
),
|
||||||
|
enabled: !!filterProjectId,
|
||||||
|
});
|
||||||
|
|
||||||
const taskQueryParams = () =>
|
const taskQueryParams = () =>
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
limit: "200",
|
limit: "200",
|
||||||
@@ -303,15 +408,17 @@ function TasksPage() {
|
|||||||
...(search ? { search } : {}),
|
...(search ? { search } : {}),
|
||||||
...(filterProjectId ? { project_id: filterProjectId } : {}),
|
...(filterProjectId ? { project_id: filterProjectId } : {}),
|
||||||
...(selectedStateId ? { state_id: selectedStateId } : {}),
|
...(selectedStateId ? { state_id: selectedStateId } : {}),
|
||||||
|
...(priority ? { priority } : {}),
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>(
|
const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>(
|
||||||
["tasks", activeDomainId, search, filterProjectId, selectedStateId],
|
["tasks", activeDomainId, search, filterProjectId, selectedStateId, priority],
|
||||||
"/tasks?" + taskQueryParams()
|
"/tasks?" + taskQueryParams()
|
||||||
);
|
);
|
||||||
|
|
||||||
const tasks = tasksData?.items || [];
|
const allTasks = tasksData?.items || [];
|
||||||
const hasMoreTasks = tasks.length < (tasksData?.totalItems || 0);
|
const tasks = useMemo(() => applyDueFilter(allTasks, dueFilter), [allTasks, dueFilter]);
|
||||||
|
const hasMoreTasks = allTasks.length < (tasksData?.totalItems || 0);
|
||||||
const [loadingMoreTasks, setLoadingMoreTasks] = useState(false);
|
const [loadingMoreTasks, setLoadingMoreTasks] = useState(false);
|
||||||
|
|
||||||
// Keyboard navigation for list view
|
// Keyboard navigation for list view
|
||||||
@@ -358,36 +465,6 @@ function TasksPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "J":
|
|
||||||
if (e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (tasks.length > 1) {
|
|
||||||
const newOrder = tasks.map((t) => t.id);
|
|
||||||
const idx = currentIdx;
|
|
||||||
const nextIdx = Math.min(idx + 1, tasks.length - 1);
|
|
||||||
if (idx !== nextIdx) {
|
|
||||||
[newOrder[idx], newOrder[nextIdx]] = [newOrder[nextIdx], newOrder[idx]];
|
|
||||||
reorderMutation.mutate({ orderedIds: newOrder });
|
|
||||||
setSelectedRowIndex(nextIdx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "K":
|
|
||||||
if (e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (tasks.length > 1) {
|
|
||||||
const newOrder = tasks.map((t) => t.id);
|
|
||||||
const idx = currentIdx;
|
|
||||||
const nextIdx = Math.max(idx - 1, 0);
|
|
||||||
if (idx !== nextIdx) {
|
|
||||||
[newOrder[idx], newOrder[nextIdx]] = [newOrder[nextIdx], newOrder[idx]];
|
|
||||||
reorderMutation.mutate({ orderedIds: newOrder });
|
|
||||||
setSelectedRowIndex(nextIdx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "Enter":
|
case "Enter":
|
||||||
if (selectedRowIndex >= 0 && selectedRowIndex < tasks.length) {
|
if (selectedRowIndex >= 0 && selectedRowIndex < tasks.length) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -407,7 +484,6 @@ function TasksPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const task = tasks[selectedRowIndex];
|
const task = tasks[selectedRowIndex];
|
||||||
const newStatus = task.status === "done" ? "todo" : "done";
|
const newStatus = task.status === "done" ? "todo" : "done";
|
||||||
stateUpdateMutation.mutate({ taskId: task.id, stateId: task.stateId || "" });
|
|
||||||
api.patch(`/tasks/${task.id}`, { status: newStatus }).then(() => {
|
api.patch(`/tasks/${task.id}`, { status: newStatus }).then(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
});
|
});
|
||||||
@@ -425,7 +501,7 @@ function TasksPage() {
|
|||||||
|
|
||||||
document.addEventListener("keydown", handler);
|
document.addEventListener("keydown", handler);
|
||||||
return () => document.removeEventListener("keydown", handler);
|
return () => document.removeEventListener("keydown", handler);
|
||||||
}, [view, tasks, selectedRowIndex, selectedTaskIds]);
|
}, [view, tasks, selectedRowIndex, selectedTaskIds, queryClient]);
|
||||||
|
|
||||||
// Hide kbd hint after 5 seconds
|
// Hide kbd hint after 5 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -439,9 +515,9 @@ function TasksPage() {
|
|||||||
setLoadingMoreTasks(true);
|
setLoadingMoreTasks(true);
|
||||||
try {
|
try {
|
||||||
const next = await api.get<PaginatedResponse<Task>>(
|
const next = await api.get<PaginatedResponse<Task>>(
|
||||||
"/tasks?" + new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(taskQueryParams())), offset: String(tasks.length) }).toString()
|
"/tasks?" + new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(taskQueryParams())), offset: String(allTasks.length) }).toString()
|
||||||
);
|
);
|
||||||
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, filterProjectId, selectedStateId], (old) => {
|
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, filterProjectId, selectedStateId, priority], (old) => {
|
||||||
if (!old) return old;
|
if (!old) return old;
|
||||||
const seen = new Set(old.items.map((t) => t.id));
|
const seen = new Set(old.items.map((t) => t.id));
|
||||||
return { ...old, items: [...old.items, ...next.items.filter((t) => !seen.has(t.id))] };
|
return { ...old, items: [...old.items, ...next.items.filter((t) => !seen.has(t.id))] };
|
||||||
@@ -456,12 +532,21 @@ function TasksPage() {
|
|||||||
api.patch<Task>("/tasks/" + taskId, { stateId }),
|
api.patch<Task>("/tasks/" + taskId, { stateId }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-groups"] });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const statusUpdateMutation = useMutation({
|
||||||
|
mutationFn: ({ taskId, status }: { taskId: string; status: string }) =>
|
||||||
|
api.patch<Task>("/tasks/" + taskId, { status }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const reorderMutation = useMutation({
|
const reorderMutation = useMutation({
|
||||||
mutationFn: ({ orderedIds }: { orderedIds: string[] }) =>
|
mutationFn: ({ orderedIds }: { orderedIds: string[] }) =>
|
||||||
api.post("/tasks/reorder", { orderedIds }),
|
api.post("/tasks/reorder", { orderedIds }),
|
||||||
@@ -548,15 +633,12 @@ function TasksPage() {
|
|||||||
useSensor(KeyboardSensor)
|
useSensor(KeyboardSensor)
|
||||||
);
|
);
|
||||||
|
|
||||||
const taskGroupOf = useCallback(
|
const columnOfTask = useCallback(
|
||||||
(task: Task): StateGroup => {
|
(task: Task): string => {
|
||||||
if (task.stateId) {
|
if (filterProjectId) return task.stateId || "__none__";
|
||||||
const group = stateGroupOf.get(task.stateId);
|
return task.status || "todo";
|
||||||
if (group) return group;
|
|
||||||
}
|
|
||||||
return "unstarted";
|
|
||||||
},
|
},
|
||||||
[stateGroupOf]
|
[filterProjectId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
@@ -574,25 +656,26 @@ function TasksPage() {
|
|||||||
const draggedTask = tasks.find((t) => t.id === taskId);
|
const draggedTask = tasks.find((t) => t.id === taskId);
|
||||||
if (!draggedTask) return;
|
if (!draggedTask) return;
|
||||||
|
|
||||||
const columnTasks = (group: StateGroup) =>
|
const columnTasks = (colId: string) =>
|
||||||
tasks
|
tasks
|
||||||
.filter((t) => taskGroupOf(t) === group)
|
.filter((t) => columnOfTask(t) === colId)
|
||||||
.sort((a, b) => a.order - b.order);
|
.sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
let targetGroup: StateGroup;
|
const allColumnIds = columns.map((c) => c.id);
|
||||||
|
let targetColumn: string;
|
||||||
let insertIndex: number;
|
let insertIndex: number;
|
||||||
if (STATE_GROUP_COLUMNS.some((c) => c.id === overId)) {
|
if (allColumnIds.includes(overId)) {
|
||||||
targetGroup = overId as StateGroup;
|
targetColumn = overId;
|
||||||
insertIndex = -1;
|
insertIndex = -1;
|
||||||
} else {
|
} else {
|
||||||
const overTask = tasks.find((t) => t.id === overId);
|
const overTask = tasks.find((t) => t.id === overId);
|
||||||
if (!overTask) return;
|
if (!overTask) return;
|
||||||
targetGroup = taskGroupOf(overTask);
|
targetColumn = columnOfTask(overTask);
|
||||||
const overIndex = columnTasks(targetGroup).findIndex((t) => t.id === overId);
|
const overIndex = columnTasks(targetColumn).findIndex((t) => t.id === overId);
|
||||||
insertIndex = overIndex === -1 ? -1 : overIndex;
|
insertIndex = overIndex === -1 ? -1 : overIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetIds = columnTasks(targetGroup)
|
const targetIds = columnTasks(targetColumn)
|
||||||
.map((t) => t.id)
|
.map((t) => t.id)
|
||||||
.filter((id) => id !== taskId);
|
.filter((id) => id !== taskId);
|
||||||
if (insertIndex === -1) {
|
if (insertIndex === -1) {
|
||||||
@@ -601,15 +684,15 @@ function TasksPage() {
|
|||||||
targetIds.splice(Math.min(insertIndex, targetIds.length), 0, taskId);
|
targetIds.splice(Math.min(insertIndex, targetIds.length), 0, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentIds = columnTasks(targetGroup).map((t) => t.id);
|
const currentIds = columnTasks(targetColumn).map((t) => t.id);
|
||||||
const unchanged =
|
const unchanged =
|
||||||
currentIds.length === targetIds.length &&
|
currentIds.length === targetIds.length &&
|
||||||
currentIds.every((id, i) => id === targetIds[i]);
|
currentIds.every((id, i) => id === targetIds[i]);
|
||||||
if (unchanged) return;
|
if (unchanged) return;
|
||||||
|
|
||||||
const groupChanged = taskGroupOf(draggedTask) !== targetGroup;
|
const groupChanged = columnOfTask(draggedTask) !== targetColumn;
|
||||||
const orderById = new Map(targetIds.map((id, i) => [id, i]));
|
const orderById = new Map(targetIds.map((id, i) => [id, i]));
|
||||||
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, filterProjectId, selectedStateId], (old) => {
|
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, filterProjectId, selectedStateId, priority], (old) => {
|
||||||
if (!old) return old;
|
if (!old) return old;
|
||||||
return {
|
return {
|
||||||
...old,
|
...old,
|
||||||
@@ -620,10 +703,12 @@ function TasksPage() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (groupChanged && draggedTask.projectId) {
|
if (groupChanged) {
|
||||||
const firstStateInGroup = projectStates.find((s) => s.group === targetGroup && s.projectId === draggedTask.projectId);
|
if (filterProjectId && targetColumn !== "__none__") {
|
||||||
if (firstStateInGroup) {
|
stateUpdateMutation.mutate({ taskId, stateId: targetColumn });
|
||||||
stateUpdateMutation.mutate({ taskId, stateId: firstStateInGroup.id });
|
} else if (!filterProjectId) {
|
||||||
|
const statusMap: Record<string, string> = { todo: "todo", in_progress: "in_progress", done: "done", cancelled: "cancelled" };
|
||||||
|
if (statusMap[targetColumn]) statusUpdateMutation.mutate({ taskId, status: statusMap[targetColumn] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reorderMutation.mutate({ orderedIds: targetIds });
|
reorderMutation.mutate({ orderedIds: targetIds });
|
||||||
@@ -635,32 +720,66 @@ function TasksPage() {
|
|||||||
|
|
||||||
const openTaskPanel = (task: Task) => {
|
const openTaskPanel = (task: Task) => {
|
||||||
setSelectedTask(task);
|
setSelectedTask(task);
|
||||||
|
setPanelTaskId(task.id);
|
||||||
setPanelOpen(true);
|
setPanelOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearch("");
|
||||||
|
setFilterProjectId("");
|
||||||
|
setSelectedStateId("");
|
||||||
|
setPriority("");
|
||||||
|
setDueFilter("all");
|
||||||
|
};
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return STATE_GROUP_COLUMNS.map((col) => ({
|
if (filterProjectId) {
|
||||||
...col,
|
const groups = groupedQuery.data?.groups;
|
||||||
tasks: tasks
|
if (groups) {
|
||||||
.filter((t) => taskGroupOf(t) === col.id)
|
return groups.map((g) => ({
|
||||||
.sort((a, b) => a.order - b.order),
|
id: g.id,
|
||||||
|
label: g.name,
|
||||||
|
color: g.color,
|
||||||
|
tasks: applyDueFilter(
|
||||||
|
g.tasks.filter((t) =>
|
||||||
|
(!search || t.title.toLowerCase().includes(search.toLowerCase())) &&
|
||||||
|
(!selectedStateId || t.stateId === selectedStateId) &&
|
||||||
|
(!priority || t.priority === priority)
|
||||||
|
),
|
||||||
|
dueFilter
|
||||||
|
).sort((a, b) => a.order - b.order),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return projectStates.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
label: s.name,
|
||||||
|
color: s.color,
|
||||||
|
tasks: tasks.filter((t) => t.stateId === s.id).sort((a, b) => a.order - b.order),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return FALLBACK_COLUMNS.map((col) => ({
|
||||||
|
id: col.id,
|
||||||
|
label: col.label,
|
||||||
|
colorClass: col.colorClass,
|
||||||
|
tasks: tasks.filter((t) => col.statuses.includes(t.status)).sort((a, b) => a.order - b.order),
|
||||||
}));
|
}));
|
||||||
}, [tasks, taskGroupOf]);
|
}, [filterProjectId, groupedQuery.data, projectStates, tasks, search, selectedStateId, priority, dueFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold">Tasks</h1>
|
<h1 className="text-xl font-bold">Tasks</h1>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tabs value={view} onValueChange={(v) => setView(v as "board" | "list")}>
|
<Tabs value={view} onValueChange={(v) => setView(v as "board" | "list" | "calendar")}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="board" aria-label="Board view"><LayoutIcon className="h-4 w-4" /></TabsTrigger>
|
<TabsTrigger value="board" aria-label="Board view"><LayoutIcon className="h-4 w-4" /></TabsTrigger>
|
||||||
<TabsTrigger value="list" aria-label="List view"><ListTodo className="h-4 w-4" /></TabsTrigger>
|
<TabsTrigger value="list" aria-label="List view"><ListTodo className="h-4 w-4" /></TabsTrigger>
|
||||||
|
<TabsTrigger value="calendar" aria-label="Calendar view"><CalendarDays className="h-4 w-4" /></TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button aria-label="New task"><Plus className="h-4 w-4 mr-2" />New Task</Button>
|
<Button aria-label="New task"><Plus className="mr-2 h-4 w-4" />New Task</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -672,67 +791,46 @@ function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1.5 flex-wrap">
|
<QuickAddBar projectId={filterProjectId || undefined} onMoreOptions={() => setCreateOpen(true)} />
|
||||||
<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={filterProjectId} onValueChange={setFilterProjectId}>
|
|
||||||
<SelectTrigger className="w-44"><SelectValue placeholder="All projects" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Saved views */}
|
<FilterBar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
projects={projects}
|
||||||
|
projectId={filterProjectId}
|
||||||
|
onProjectChange={(v) => { setFilterProjectId(v); setSelectedStateId(""); }}
|
||||||
|
states={projectStates}
|
||||||
|
stateId={selectedStateId}
|
||||||
|
onStateChange={setSelectedStateId}
|
||||||
|
priority={priority}
|
||||||
|
onPriorityChange={setPriority}
|
||||||
|
dueFilter={dueFilter}
|
||||||
|
onDueChange={setDueFilter}
|
||||||
|
onClearAll={clearFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{savedViews.length > 0 && (
|
{savedViews.length > 0 && (
|
||||||
<Select
|
<Select
|
||||||
value=""
|
value=""
|
||||||
onValueChange={(id) => {
|
onValueChange={(vid) => {
|
||||||
const view = savedViews.find((v) => v.id === id);
|
const v = savedViews.find((sv) => sv.id === vid);
|
||||||
if (view) {
|
if (v) {
|
||||||
setSearch(view.filters.search);
|
setSearch(v.filters.search);
|
||||||
setFilterProjectId(view.filters.projectId);
|
setFilterProjectId(v.filters.projectId);
|
||||||
setSelectedStateId(view.filters.stateId);
|
setSelectedStateId(v.filters.stateId);
|
||||||
|
setPriority(v.filters.priority);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-44"><SelectValue placeholder="Saved views" /></SelectTrigger>
|
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder="Saved views" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{savedViews.map((v) => (
|
{savedViews.map((v) => (
|
||||||
<SelectItem key={v.id} value={v.id} className="flex items-center justify-between">
|
<SelectItem key={v.id} value={v.id}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Bookmark className="h-3 w-3" />
|
<Bookmark className="h-3 w-3" />
|
||||||
{v.name}
|
{v.name}
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
className="ml-auto hover:text-destructive"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
removeSavedView(v.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -741,7 +839,7 @@ function TasksPage() {
|
|||||||
|
|
||||||
<Dialog open={saveViewOpen} onOpenChange={setSaveViewOpen}>
|
<Dialog open={saveViewOpen} onOpenChange={setSaveViewOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-1">
|
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs">
|
||||||
<Bookmark className="h-3.5 w-3.5" /> Save View
|
<Bookmark className="h-3.5 w-3.5" /> Save View
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -756,7 +854,7 @@ function TasksPage() {
|
|||||||
onChange={(e) => setViewName(e.target.value)}
|
onChange={(e) => setViewName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && viewName.trim()) {
|
if (e.key === "Enter" && viewName.trim()) {
|
||||||
addSavedView(viewName.trim(), { search, projectId: filterProjectId, stateId: selectedStateId, priority: "" });
|
addSavedView(viewName.trim(), { search, projectId: filterProjectId, stateId: selectedStateId, priority });
|
||||||
setViewName("");
|
setViewName("");
|
||||||
setSaveViewOpen(false);
|
setSaveViewOpen(false);
|
||||||
}
|
}
|
||||||
@@ -767,7 +865,7 @@ function TasksPage() {
|
|||||||
<Button
|
<Button
|
||||||
disabled={!viewName.trim()}
|
disabled={!viewName.trim()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
addSavedView(viewName.trim(), { search, projectId: filterProjectId, stateId: selectedStateId, priority: "" });
|
addSavedView(viewName.trim(), { search, projectId: filterProjectId, stateId: selectedStateId, priority });
|
||||||
setViewName("");
|
setViewName("");
|
||||||
setSaveViewOpen(false);
|
setSaveViewOpen(false);
|
||||||
}}
|
}}
|
||||||
@@ -778,6 +876,22 @@ function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
{savedViews.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{savedViews.map((v) => (
|
||||||
|
<Badge key={v.id} variant="outline" className="gap-1 text-[10px]">
|
||||||
|
{v.name}
|
||||||
|
<button
|
||||||
|
className="ml-0.5 hover:text-destructive"
|
||||||
|
onClick={() => removeSavedView(v.id)}
|
||||||
|
aria-label={`Remove saved view ${v.name}`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -786,18 +900,22 @@ function TasksPage() {
|
|||||||
<ErrorState message="Failed to load tasks." onRetry={() => refetch()} />
|
<ErrorState message="Failed to load tasks." onRetry={() => refetch()} />
|
||||||
) : view === "board" ? (
|
) : view === "board" ? (
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<ColumnDroppable key={col.id} id={col.id} className="bg-muted/50 rounded-lg p-3">
|
<ColumnDroppable key={col.id} id={col.id} className="rounded-lg border border-[#d9dee7] bg-[#f7f8fa] p-2.5">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={cn("w-2 h-2 rounded-full", col.colorClass)} />
|
{"color" in col && col.color ? (
|
||||||
<h3 className="font-semibold text-sm">{col.label}</h3>
|
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: col.color as string }} />
|
||||||
|
) : (
|
||||||
|
<div className={cn("h-2 w-2 rounded-full", (col as any).colorClass || "bg-slate-400")} />
|
||||||
|
)}
|
||||||
|
<h3 className="text-sm font-semibold">{col.label}</h3>
|
||||||
<Badge variant="secondary" className="font-mono text-[10px]">{col.tasks.length}</Badge>
|
<Badge variant="secondary" className="font-mono text-[10px]">{col.tasks.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||||
<div className="space-y-1.5 min-h-[100px]">
|
<div className="min-h-[100px] space-y-1.5">
|
||||||
{col.tasks.map((task) => {
|
{col.tasks.map((task) => {
|
||||||
const st = task.stateId ? stateById.get(task.stateId) : undefined;
|
const st = task.stateId ? stateById.get(task.stateId) : undefined;
|
||||||
return (
|
return (
|
||||||
@@ -806,13 +924,14 @@ function TasksPage() {
|
|||||||
task={task}
|
task={task}
|
||||||
stateName={st?.name}
|
stateName={st?.name}
|
||||||
stateColor={st?.color}
|
stateColor={st?.color}
|
||||||
onClick={() => openTaskDetail(task)}
|
projectName={!filterProjectId ? projectById.get(task.projectId || "")?.name : undefined}
|
||||||
|
onClick={() => openTaskPanel(task)}
|
||||||
onEdit={() => openTaskPanel(task)}
|
onEdit={() => openTaskPanel(task)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{col.tasks.length === 0 && (
|
{col.tasks.length === 0 && (
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">No tasks</p>
|
<p className="py-4 text-center text-xs text-muted-foreground">No tasks</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
@@ -820,17 +939,19 @@ function TasksPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeId ? <div className="p-3 bg-card rounded-lg shadow-lg border opacity-80">Moving...</div> : null}
|
{activeId ? <div className="rounded-lg border bg-card p-3 opacity-80 shadow-lg">Moving...</div> : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
) : view === "calendar" ? (
|
||||||
|
<TasksCalendar tasks={tasks} onSelectTask={openTaskPanel} />
|
||||||
) : (
|
) : (
|
||||||
<div className="border rounded-lg relative">
|
<div className="relative rounded-lg border border-[#d9dee7] bg-white">
|
||||||
{view === "list" && showKbdHint && (
|
{view === "list" && showKbdHint && (
|
||||||
<div className="absolute top-0 right-0 text-[10px] text-muted-foreground flex gap-3 p-2 z-10">
|
<div className="absolute right-0 top-0 z-10 flex gap-3 p-2 text-[10px] text-muted-foreground">
|
||||||
<span><kbd className="px-1 py-0.5 rounded border bg-muted font-mono">j</kbd>/<kbd className="px-1 py-0.5 rounded border bg-muted font-mono">k</kbd> navigate</span>
|
<span><kbd className="rounded border bg-muted px-1 py-0.5 font-mono">j</kbd>/<kbd className="rounded border bg-muted px-1 py-0.5 font-mono">k</kbd> navigate</span>
|
||||||
<span><kbd className="px-1 py-0.5 rounded border bg-muted font-mono">Enter</kbd> open</span>
|
<span><kbd className="rounded border bg-muted px-1 py-0.5 font-mono">Enter</kbd> open</span>
|
||||||
<span><kbd className="px-1 py-0.5 rounded border bg-muted font-mono">e</kbd> edit</span>
|
<span><kbd className="rounded border bg-muted px-1 py-0.5 font-mono">e</kbd> edit</span>
|
||||||
<span><kbd className="px-1 py-0.5 rounded border bg-muted font-mono">x</kbd> toggle</span>
|
<span><kbd className="rounded border bg-muted px-1 py-0.5 font-mono">x</kbd> toggle</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Table>
|
<Table>
|
||||||
@@ -865,7 +986,7 @@ function TasksPage() {
|
|||||||
key={task.id}
|
key={task.id}
|
||||||
data-row-index={idx}
|
data-row-index={idx}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer hover:bg-muted/30 py-2",
|
"cursor-pointer py-2 hover:bg-muted/30",
|
||||||
idx === selectedRowIndex && "bg-accent/50 ring-1 ring-[hsl(var(--accent-hsl))]"
|
idx === selectedRowIndex && "bg-accent/50 ring-1 ring-[hsl(var(--accent-hsl))]"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -880,7 +1001,7 @@ function TasksPage() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
openTaskDetail(task);
|
openTaskPanel(task);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -902,7 +1023,7 @@ function TasksPage() {
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
<TableCell className="font-medium py-2">
|
<TableCell className="py-2 font-medium">
|
||||||
{editingTaskId === task.id ? (
|
{editingTaskId === task.id ? (
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -927,7 +1048,7 @@ function TasksPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-2">
|
<TableCell className="py-2">
|
||||||
{st ? (
|
{st ? (
|
||||||
<Badge variant="secondary" className="font-mono text-[10px] gap-1" style={st.color ? { backgroundColor: st.color + "20", color: st.color } : undefined}>
|
<Badge variant="secondary" className="gap-1 font-mono text-[10px]" 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} />
|
<span className="h-1.5 w-1.5 rounded-full" style={st.color ? { backgroundColor: st.color } : undefined} />
|
||||||
{st.name}
|
{st.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -938,7 +1059,7 @@ function TasksPage() {
|
|||||||
<TableCell className="py-2">
|
<TableCell className="py-2">
|
||||||
<Badge variant="outline" className={cn("font-mono text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
|
<Badge variant="outline" className={cn("font-mono text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-[10px] text-muted-foreground py-2">
|
<TableCell className="py-2 font-mono text-[10px] text-muted-foreground">
|
||||||
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
|
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-2">
|
<TableCell className="py-2">
|
||||||
@@ -947,7 +1068,8 @@ function TasksPage() {
|
|||||||
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
|
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => openTaskPanel(task)}>Edit</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => openTaskPanel(task)}>Quick view</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => openTaskDetail(task)}>Open full page</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem>
|
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -972,7 +1094,7 @@ function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasMoreTasks && (
|
{hasMoreTasks && view !== "calendar" && (
|
||||||
<div className="flex justify-center pt-2">
|
<div className="flex justify-center pt-2">
|
||||||
<Button variant="outline" size="sm" onClick={loadMoreTasks} disabled={loadingMoreTasks}>
|
<Button variant="outline" size="sm" onClick={loadMoreTasks} disabled={loadingMoreTasks}>
|
||||||
{loadingMoreTasks ? "Loading..." : "Load more tasks"}
|
{loadingMoreTasks ? "Loading..." : "Load more tasks"}
|
||||||
@@ -980,30 +1102,39 @@ function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedTask?.title || "Task Details"}>
|
<TaskDetailPanel
|
||||||
{selectedTask && (
|
taskId={panelTaskId}
|
||||||
<div className="space-y-3">
|
open={panelOpen}
|
||||||
<TaskForm task={selectedTask} onClose={() => setPanelOpen(false)} />
|
onOpenChange={(o) => {
|
||||||
<div className="pt-4 border-t">
|
setPanelOpen(o);
|
||||||
<AlertDialog>
|
if (!o) {
|
||||||
<AlertDialogTrigger asChild>
|
setPanelTaskId(null);
|
||||||
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Task</Button>
|
setSelectedTask(null);
|
||||||
</AlertDialogTrigger>
|
}
|
||||||
<AlertDialogContent>
|
}}
|
||||||
<AlertDialogHeader>
|
onOpenFullPage={(t) => {
|
||||||
<AlertDialogTitle>Delete Task</AlertDialogTitle>
|
setPanelOpen(false);
|
||||||
<AlertDialogDescription>Are you sure you want to delete "{selectedTask.title}"? This action cannot be undone.</AlertDialogDescription>
|
openTaskDetail(t);
|
||||||
</AlertDialogHeader>
|
}}
|
||||||
<AlertDialogFooter>
|
/>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={() => deleteMutation.mutate(selectedTask.id)} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
|
{selectedTask && !panelOpen && (
|
||||||
</AlertDialogFooter>
|
<AlertDialog>
|
||||||
</AlertDialogContent>
|
<AlertDialogTrigger asChild>
|
||||||
</AlertDialog>
|
<span className="hidden" />
|
||||||
</div>
|
</AlertDialogTrigger>
|
||||||
</div>
|
<AlertDialogContent>
|
||||||
)}
|
<AlertDialogHeader>
|
||||||
</EntityDetailPanel>
|
<AlertDialogTitle>Delete Task</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Are you sure you want to delete "{selectedTask.title}"? This action cannot be undone.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => deleteMutation.mutate(selectedTask.id)} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -34,7 +34,7 @@ test.describe('Tasks', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('opens the task detail page when a task card is clicked', async ({ page }) => {
|
test('opens the task detail panel when a task card is clicked', async ({ page }) => {
|
||||||
// Make sure a task exists before trying to open it.
|
// Make sure a task exists before trying to open it.
|
||||||
if (!(await page.getByText(testTasks.title, { exact: true }).isVisible().catch(() => false))) {
|
if (!(await page.getByText(testTasks.title, { exact: true }).isVisible().catch(() => false))) {
|
||||||
await page.getByRole('button', { name: /new task/i }).click();
|
await page.getByRole('button', { name: /new task/i }).click();
|
||||||
@@ -46,6 +46,11 @@ test.describe('Tasks', () => {
|
|||||||
|
|
||||||
await page.getByText(testTasks.title, { exact: true }).first().click();
|
await page.getByText(testTasks.title, { exact: true }).first().click();
|
||||||
|
|
||||||
|
// Clicking a card opens the slide-over detail panel.
|
||||||
|
await expect(page.getByText('Task details')).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// The panel offers a link to the full detail page.
|
||||||
|
await page.getByRole('button', { name: /open full page/i }).click();
|
||||||
await page.waitForURL('**/tasks/*', { timeout: 10_000 });
|
await page.waitForURL('**/tasks/*', { timeout: 10_000 });
|
||||||
|
|
||||||
// The detail page renders the task title as a heading.
|
// The detail page renders the task title as a heading.
|
||||||
@@ -54,10 +59,13 @@ test.describe('Tasks', () => {
|
|||||||
).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('switches between board and list views', async ({ page }) => {
|
test('switches between board, list, and calendar views', async ({ page }) => {
|
||||||
await page.getByRole('tab', { name: /list view/i }).click();
|
await page.getByRole('tab', { name: /list view/i }).click();
|
||||||
await expect(page.getByRole('columnheader', { name: 'Title' })).toBeVisible();
|
await expect(page.getByRole('columnheader', { name: 'Title' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: /calendar view/i }).click();
|
||||||
|
await expect(page.getByText(/today/i).first()).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('tab', { name: /board view/i }).click();
|
await page.getByRole('tab', { name: /board view/i }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Todo' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Todo' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user