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:
@@ -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,
|
||||
PenLine,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
@@ -24,6 +25,9 @@ import {
|
||||
FileBarChart,
|
||||
FileStack,
|
||||
} 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 { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -115,6 +119,13 @@ export function Sidebar() {
|
||||
? "right"
|
||||
: "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(() => {
|
||||
const onSidebarPositionChange = (event: Event) => {
|
||||
@@ -182,7 +193,64 @@ export function Sidebar() {
|
||||
const navigation = (isCollapsed: boolean, onNavigate?: () => void) => (
|
||||
<ScrollArea className="flex-1 py-2">
|
||||
<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>
|
||||
<Separator className="my-2" />
|
||||
<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",
|
||||
Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
||||
|
||||
/* Control heights & sizing */
|
||||
--control-height: 40px;
|
||||
--control-height-sm: 32px;
|
||||
--control-height-lg: 48px;
|
||||
/* Control heights & sizing — Buzzbee 32px control grid */
|
||||
--control-height: 32px;
|
||||
--control-height-sm: 24px;
|
||||
--control-height-lg: 40px;
|
||||
--size: 16px;
|
||||
--size-sm: 12px;
|
||||
--size-md: 20px;
|
||||
@@ -27,30 +27,34 @@
|
||||
--duration-slow: 0.3s;
|
||||
--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%;
|
||||
--foreground: 0 0% 6.7%;
|
||||
--card: 220 13% 97%;
|
||||
--card: 220 14% 97%;
|
||||
--card-foreground: 0 0% 6.7%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 6.7%;
|
||||
--primary: 217 91% 60%;
|
||||
--primary: 216 100% 54%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary: 220 14% 97%;
|
||||
--secondary-foreground: 0 0% 18%;
|
||||
--muted: 210 40% 96%;
|
||||
--muted: 220 14% 97%;
|
||||
--muted-foreground: 220 9% 45%;
|
||||
--accent: 210 100% 95%;
|
||||
--accent-foreground: 222 100% 27%;
|
||||
--accent: 214 100% 95%;
|
||||
--accent-foreground: 224 100% 27%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 214 32% 88%;
|
||||
--input: 214 32% 88%;
|
||||
--ring: 210 100% 75%;
|
||||
--border: 214 24% 87%;
|
||||
--input: 214 24% 87%;
|
||||
--ring: 213 100% 77%;
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Interactive accent — pinned vibrant blue */
|
||||
--accent-hsl: 217 91% 60%;
|
||||
/* Interactive accent — Buzzbee primary #1677ff */
|
||||
--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 */
|
||||
--sidebar-bg: 0 0% 100%;
|
||||
|
||||
@@ -13,12 +13,12 @@ export interface StatusToken {
|
||||
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> = {
|
||||
todo: { label: "Todo", dot: "bg-slate-500", badge: "bg-slate-500 text-white" },
|
||||
in_progress: { label: "In Progress", dot: "bg-blue-500", badge: "bg-blue-500 text-white" },
|
||||
done: { label: "Done", dot: "bg-green-500", badge: "bg-green-500 text-white" },
|
||||
cancelled: { label: "Cancelled", dot: "bg-red-500", badge: "bg-red-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-[#1677ff]", badge: "bg-[#e6f4ff] text-[#002c8c] border border-[#91caff]" },
|
||||
done: { label: "Done", dot: "bg-[#52c41a]", badge: "bg-[#f6ffed] text-[#135200] border border-[#b6eb8f]" },
|
||||
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). */
|
||||
@@ -29,12 +29,12 @@ export const PRIORITY: Record<string, { label: string; badge: string }> = {
|
||||
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> = {
|
||||
active: { label: "Active", dot: "bg-green-500", badge: "bg-green-500 text-white" },
|
||||
paused: { label: "Paused", dot: "bg-amber-500", badge: "bg-amber-500 text-white" },
|
||||
completed: { label: "Completed", dot: "bg-blue-500", badge: "bg-blue-500 text-white" },
|
||||
archived: { label: "Archived", dot: "bg-slate-500", badge: "bg-slate-500 text-white" },
|
||||
active: { label: "Active", dot: "bg-[#52c41a]", badge: "bg-[#f6ffed] text-[#135200] border border-[#b6eb8f]" },
|
||||
paused: { label: "Paused", dot: "bg-[#faad14]", badge: "bg-[#fffbe6] text-[#874c00] border border-[#ffe58f]" },
|
||||
completed: { label: "Completed", dot: "bg-[#1677ff]", badge: "bg-[#e6f4ff] text-[#002c8c] border border-[#91caff]" },
|
||||
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.
|
||||
*/
|
||||
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%",
|
||||
project: "271 81% 56%",
|
||||
note: "24 95% 53%",
|
||||
|
||||
@@ -187,6 +187,12 @@ function CalendarPage() {
|
||||
const activeDomainId = useApiDomain();
|
||||
const [showTasks, setShowTasks] = 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 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 : "")
|
||||
);
|
||||
const { data: unifiedData } = useApiQuery<{ items: CalendarEvent[] }>(
|
||||
["calendar-unified", activeDomainId, fromISO, toISO],
|
||||
`/calendar/unified?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
["calendar-unified", activeDomainId, fromISO, toISO, filterProjectId],
|
||||
`/calendar/unified?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "") + (filterProjectId ? "&project_id=" + filterProjectId : "")
|
||||
);
|
||||
const events = useMemo(() => {
|
||||
if (!unifiedData?.items) return eventsData?.items || [];
|
||||
@@ -352,6 +358,17 @@ function CalendarPage() {
|
||||
<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={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 className="flex items-center gap-1">
|
||||
{["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 { Route as appRoute } from "../_app";
|
||||
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 { useRealtime } from "@/hooks/use-realtime";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -117,15 +117,16 @@ function ProjectCard({
|
||||
onOpenPanel: (p: Project) => void;
|
||||
}) {
|
||||
const status = PROJECT_STATUS[project.status];
|
||||
const overdue = project.targetDate && project.status === "active" && new Date(project.targetDate) < new Date();
|
||||
return (
|
||||
<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)}
|
||||
>
|
||||
<CardHeader className="pb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
||||
<CardTitle className="text-base truncate">{project.name}</CardTitle>
|
||||
<div className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: project.color || "#1677ff" }} />
|
||||
<CardTitle className="truncate text-base">{project.name}</CardTitle>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{status && (
|
||||
<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">
|
||||
<span className="font-mono">{project.completedCount}/{project.taskCount} tasks</span>
|
||||
{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>
|
||||
{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>
|
||||
</Card>
|
||||
);
|
||||
@@ -185,8 +193,19 @@ function ProjectsPage() {
|
||||
projectQueryUrl()
|
||||
);
|
||||
|
||||
const projects = projectsData?.items || [];
|
||||
const hasMoreProjects = projects.length < (projectsData?.totalItems || 0);
|
||||
const allProjects = projectsData?.items || [];
|
||||
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 loadMoreProjects = async () => {
|
||||
@@ -246,14 +265,45 @@ function ProjectsPage() {
|
||||
</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 ? (
|
||||
<LoadingState label="Loading projects..." />
|
||||
) : isError ? (
|
||||
<ErrorState message="Failed to load projects." onRetry={() => refetch()} />
|
||||
) : 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" ? (
|
||||
<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) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
|
||||
@@ -4,12 +4,16 @@ import { Route as appRoute } from "../../_app";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Activity,
|
||||
Boxes,
|
||||
Calendar,
|
||||
Clock,
|
||||
Flag,
|
||||
FolderKanban,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
ListTodo,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
Repeat,
|
||||
Trash2,
|
||||
@@ -19,7 +23,6 @@ import { differenceInCalendarDays, format, parseISO } from "date-fns";
|
||||
import { api, useApiQuery } from "@/lib/api";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { useOptimisticPatch } from "@/hooks/use-optimistic-patch";
|
||||
import { EntityDetailPage } from "@/components/entities/detail-page";
|
||||
import {
|
||||
InlineDate,
|
||||
InlineEdit,
|
||||
@@ -153,66 +156,87 @@ function ProjectDetail() {
|
||||
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 (isError) {
|
||||
return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
|
||||
}
|
||||
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 (
|
||||
<EntityDetailPage
|
||||
backTo={{ to: "/projects", label: "Back to Projects" }}
|
||||
title={
|
||||
<InlineText
|
||||
value={project.name}
|
||||
onSave={(name) => patch({ id, data: { name } })}
|
||||
placeholder="Untitled project"
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: "/projects" })}>
|
||||
← Projects
|
||||
</Button>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg border border-[#d9dee7] bg-[#f7f8fa]">
|
||||
<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>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trash2 className="h-4 w-4" /> Delete
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
@@ -234,29 +258,55 @@ function ProjectDetail() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
}
|
||||
tabs={[
|
||||
{
|
||||
value: "overview",
|
||||
label: "Overview",
|
||||
content: <Overview project={project} patch={patch} />,
|
||||
},
|
||||
{ value: "tasks", label: "Tasks", content: <ProjectTasks project={project} /> },
|
||||
{ value: "sections", label: "Sections", content: <Sections project={project} /> },
|
||||
{ value: "modules", label: "Modules", content: <ProjectModules project={project} /> },
|
||||
{ value: "cycles", label: "Cycles", content: <ProjectCycles project={project} /> },
|
||||
{
|
||||
value: "activity",
|
||||
label: "Activity",
|
||||
content: <EntityActivity entityType="project" entityId={id} />,
|
||||
},
|
||||
{
|
||||
value: "comments",
|
||||
label: "Comments",
|
||||
content: <EntityComments entityType="project" entityId={id} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<nav className="w-full shrink-0 space-y-1 md:w-52" aria-label="Project sections">
|
||||
<div className="rounded-lg border border-[#d9dee7] bg-white p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListTodo className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-semibold">Progress</p>
|
||||
<span className="ml-auto font-mono text-xs font-medium">{stats?.progress ?? project.progress ?? 0}%</span>
|
||||
</div>
|
||||
<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">
|
||||
{stats?.completed ?? project.completedCount ?? 0} of {stats?.total ?? taskCount} done
|
||||
{(stats?.overdue ?? 0) > 0 && <span className="text-destructive"> · {stats?.overdue} overdue</span>}
|
||||
</p>
|
||||
</div>
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
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 { Route as appRoute } from "../_app";
|
||||
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 { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -21,30 +21,33 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
||||
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
|
||||
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 { parseTaskInput } from "@/lib/nlp";
|
||||
import { useSavedViewsStore } from "@/lib/stores/use-saved-views-store";
|
||||
import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
|
||||
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 { 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 }[] = [
|
||||
{ id: "backlog", label: "Backlog", colorClass: "bg-slate-400" },
|
||||
{ id: "unstarted", label: "Unstarted", colorClass: "bg-slate-500" },
|
||||
{ id: "started", label: "Started", colorClass: "bg-blue-500" },
|
||||
{ id: "completed", label: "Completed", colorClass: "bg-green-500" },
|
||||
{ id: "cancelled", label: "Cancelled", colorClass: "bg-red-500" },
|
||||
/** Fallback columns when no project is selected (group by task status). */
|
||||
const FALLBACK_COLUMNS: { id: string; label: string; colorClass: string; statuses: string[] }[] = [
|
||||
{ id: "todo", label: "Todo", colorClass: "bg-slate-400", statuses: ["todo"] },
|
||||
{ id: "in_progress", label: "In Progress", colorClass: "bg-blue-500", statuses: ["in_progress"] },
|
||||
{ id: "done", label: "Done", colorClass: "bg-green-500", statuses: ["done"] },
|
||||
{ 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 style = {
|
||||
@@ -53,42 +56,47 @@ function SortableTaskCard({ task, stateName, stateColor, onClick, onEdit }: { ta
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
const overdue = task.dueDate && !task.completedAt && new Date(task.dueDate) < new Date();
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-start gap-2">
|
||||
<GripVertical className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{task.title}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
<GripVertical className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{task.title}</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{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} />
|
||||
{stateName}
|
||||
</Badge>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
<Calendar className="h-3 w-3 mr-1" />
|
||||
<Badge variant="outline" className={cn("font-mono text-[10px]", overdue && "border-[#ffccc7] bg-[#fff2f0] text-[#820014]")}>
|
||||
<Calendar className="mr-1 h-3 w-3" />
|
||||
{new Date(task.dueDate).toLocaleDateString()}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className={cn("font-mono text-[10px]", PRIORITY[task.priority]?.badge)}>
|
||||
{PRIORITY[task.priority]?.label ?? task.priority}
|
||||
</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 }}>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{projectName && (
|
||||
<p className="mt-1 truncate text-[11px] text-muted-foreground">{projectName}</p>
|
||||
)}
|
||||
</div>
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
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(); }}
|
||||
aria-label={"Edit " + task.title}
|
||||
>
|
||||
@@ -169,6 +177,7 @@ function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => vo
|
||||
if (recurrenceRule) data.recurrenceRule = recurrenceRule;
|
||||
const customFields = { ...customFieldValues };
|
||||
if (Object.keys(customFields).length > 0) data.customFields = customFields;
|
||||
if (projectId && !task) data.projectId = projectId;
|
||||
if (task) {
|
||||
updateMutation.mutate(data);
|
||||
} 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>
|
||||
<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) && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{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>}
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{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>
|
||||
@@ -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() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [view, setView] = useState<"board" | "list">("board");
|
||||
const [view, setView] = useState<"board" | "list" | "calendar">("board");
|
||||
const [search, setSearch] = useState("");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [dueFilter, setDueFilter] = useState<DueFilter>("all");
|
||||
const [selectedStateId, setSelectedStateId] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [panelTaskId, setPanelTaskId] = useState<string | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [selectedRowIndex, setSelectedRowIndex] = useState<number>(-1);
|
||||
@@ -264,11 +365,12 @@ function TasksPage() {
|
||||
|
||||
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?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||
);
|
||||
const projects = projectsData?.items || [];
|
||||
const projectById = useMemo(() => new Map(projects.map((p) => [p.id, p])), [projects]);
|
||||
|
||||
const [filterProjectId, setFilterProjectId] = useState("");
|
||||
const [saveViewOpen, setSaveViewOpen] = useState(false);
|
||||
@@ -282,13 +384,7 @@ function TasksPage() {
|
||||
"/states?projectId=" + filterProjectId,
|
||||
{ enabled: !!filterProjectId }
|
||||
);
|
||||
const projectStates = statesData?.items || [];
|
||||
|
||||
const stateGroupOf = useMemo(() => {
|
||||
const map = new Map<string, StateGroup>();
|
||||
for (const s of projectStates) map.set(s.id, s.group);
|
||||
return map;
|
||||
}, [projectStates]);
|
||||
const projectStates = useMemo(() => (statesData?.items || []).sort((a, b) => a.sortOrder - b.sortOrder), [statesData]);
|
||||
|
||||
const stateById = useMemo(() => {
|
||||
const map = new Map<string, State>();
|
||||
@@ -296,6 +392,15 @@ function TasksPage() {
|
||||
return map;
|
||||
}, [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 = () =>
|
||||
new URLSearchParams({
|
||||
limit: "200",
|
||||
@@ -303,15 +408,17 @@ function TasksPage() {
|
||||
...(search ? { search } : {}),
|
||||
...(filterProjectId ? { project_id: filterProjectId } : {}),
|
||||
...(selectedStateId ? { state_id: selectedStateId } : {}),
|
||||
...(priority ? { priority } : {}),
|
||||
}).toString();
|
||||
|
||||
const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>(
|
||||
["tasks", activeDomainId, search, filterProjectId, selectedStateId],
|
||||
["tasks", activeDomainId, search, filterProjectId, selectedStateId, priority],
|
||||
"/tasks?" + taskQueryParams()
|
||||
);
|
||||
|
||||
const tasks = tasksData?.items || [];
|
||||
const hasMoreTasks = tasks.length < (tasksData?.totalItems || 0);
|
||||
const allTasks = tasksData?.items || [];
|
||||
const tasks = useMemo(() => applyDueFilter(allTasks, dueFilter), [allTasks, dueFilter]);
|
||||
const hasMoreTasks = allTasks.length < (tasksData?.totalItems || 0);
|
||||
const [loadingMoreTasks, setLoadingMoreTasks] = useState(false);
|
||||
|
||||
// Keyboard navigation for list view
|
||||
@@ -358,36 +465,6 @@ function TasksPage() {
|
||||
});
|
||||
}
|
||||
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":
|
||||
if (selectedRowIndex >= 0 && selectedRowIndex < tasks.length) {
|
||||
e.preventDefault();
|
||||
@@ -407,7 +484,6 @@ function TasksPage() {
|
||||
e.preventDefault();
|
||||
const task = tasks[selectedRowIndex];
|
||||
const newStatus = task.status === "done" ? "todo" : "done";
|
||||
stateUpdateMutation.mutate({ taskId: task.id, stateId: task.stateId || "" });
|
||||
api.patch(`/tasks/${task.id}`, { status: newStatus }).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
});
|
||||
@@ -425,7 +501,7 @@ function TasksPage() {
|
||||
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [view, tasks, selectedRowIndex, selectedTaskIds]);
|
||||
}, [view, tasks, selectedRowIndex, selectedTaskIds, queryClient]);
|
||||
|
||||
// Hide kbd hint after 5 seconds
|
||||
useEffect(() => {
|
||||
@@ -439,9 +515,9 @@ function TasksPage() {
|
||||
setLoadingMoreTasks(true);
|
||||
try {
|
||||
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;
|
||||
const seen = new Set(old.items.map((t) => 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 }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["task-groups"] });
|
||||
},
|
||||
onError: () => {
|
||||
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({
|
||||
mutationFn: ({ orderedIds }: { orderedIds: string[] }) =>
|
||||
api.post("/tasks/reorder", { orderedIds }),
|
||||
@@ -548,15 +633,12 @@ function TasksPage() {
|
||||
useSensor(KeyboardSensor)
|
||||
);
|
||||
|
||||
const taskGroupOf = useCallback(
|
||||
(task: Task): StateGroup => {
|
||||
if (task.stateId) {
|
||||
const group = stateGroupOf.get(task.stateId);
|
||||
if (group) return group;
|
||||
}
|
||||
return "unstarted";
|
||||
const columnOfTask = useCallback(
|
||||
(task: Task): string => {
|
||||
if (filterProjectId) return task.stateId || "__none__";
|
||||
return task.status || "todo";
|
||||
},
|
||||
[stateGroupOf]
|
||||
[filterProjectId]
|
||||
);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
@@ -574,25 +656,26 @@ function TasksPage() {
|
||||
const draggedTask = tasks.find((t) => t.id === taskId);
|
||||
if (!draggedTask) return;
|
||||
|
||||
const columnTasks = (group: StateGroup) =>
|
||||
const columnTasks = (colId: string) =>
|
||||
tasks
|
||||
.filter((t) => taskGroupOf(t) === group)
|
||||
.filter((t) => columnOfTask(t) === colId)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
let targetGroup: StateGroup;
|
||||
const allColumnIds = columns.map((c) => c.id);
|
||||
let targetColumn: string;
|
||||
let insertIndex: number;
|
||||
if (STATE_GROUP_COLUMNS.some((c) => c.id === overId)) {
|
||||
targetGroup = overId as StateGroup;
|
||||
if (allColumnIds.includes(overId)) {
|
||||
targetColumn = overId;
|
||||
insertIndex = -1;
|
||||
} else {
|
||||
const overTask = tasks.find((t) => t.id === overId);
|
||||
if (!overTask) return;
|
||||
targetGroup = taskGroupOf(overTask);
|
||||
const overIndex = columnTasks(targetGroup).findIndex((t) => t.id === overId);
|
||||
targetColumn = columnOfTask(overTask);
|
||||
const overIndex = columnTasks(targetColumn).findIndex((t) => t.id === overId);
|
||||
insertIndex = overIndex === -1 ? -1 : overIndex;
|
||||
}
|
||||
|
||||
const targetIds = columnTasks(targetGroup)
|
||||
const targetIds = columnTasks(targetColumn)
|
||||
.map((t) => t.id)
|
||||
.filter((id) => id !== taskId);
|
||||
if (insertIndex === -1) {
|
||||
@@ -601,15 +684,15 @@ function TasksPage() {
|
||||
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 =
|
||||
currentIds.length === targetIds.length &&
|
||||
currentIds.every((id, i) => id === targetIds[i]);
|
||||
if (unchanged) return;
|
||||
|
||||
const groupChanged = taskGroupOf(draggedTask) !== targetGroup;
|
||||
const groupChanged = columnOfTask(draggedTask) !== targetColumn;
|
||||
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;
|
||||
return {
|
||||
...old,
|
||||
@@ -620,10 +703,12 @@ function TasksPage() {
|
||||
};
|
||||
});
|
||||
|
||||
if (groupChanged && draggedTask.projectId) {
|
||||
const firstStateInGroup = projectStates.find((s) => s.group === targetGroup && s.projectId === draggedTask.projectId);
|
||||
if (firstStateInGroup) {
|
||||
stateUpdateMutation.mutate({ taskId, stateId: firstStateInGroup.id });
|
||||
if (groupChanged) {
|
||||
if (filterProjectId && targetColumn !== "__none__") {
|
||||
stateUpdateMutation.mutate({ taskId, stateId: targetColumn });
|
||||
} 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 });
|
||||
@@ -635,32 +720,66 @@ function TasksPage() {
|
||||
|
||||
const openTaskPanel = (task: Task) => {
|
||||
setSelectedTask(task);
|
||||
setPanelTaskId(task.id);
|
||||
setPanelOpen(true);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch("");
|
||||
setFilterProjectId("");
|
||||
setSelectedStateId("");
|
||||
setPriority("");
|
||||
setDueFilter("all");
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return STATE_GROUP_COLUMNS.map((col) => ({
|
||||
...col,
|
||||
tasks: tasks
|
||||
.filter((t) => taskGroupOf(t) === col.id)
|
||||
.sort((a, b) => a.order - b.order),
|
||||
if (filterProjectId) {
|
||||
const groups = groupedQuery.data?.groups;
|
||||
if (groups) {
|
||||
return groups.map((g) => ({
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">Tasks</h1>
|
||||
<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>
|
||||
<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="calendar" aria-label="Calendar view"><CalendarDays className="h-4 w-4" /></TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<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>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -672,67 +791,46 @@ function TasksPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Search tasks..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" />
|
||||
</div>
|
||||
<Select value={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>
|
||||
)}
|
||||
<QuickAddBar projectId={filterProjectId || undefined} onMoreOptions={() => setCreateOpen(true)} />
|
||||
|
||||
{/* 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 && (
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(id) => {
|
||||
const view = savedViews.find((v) => v.id === id);
|
||||
if (view) {
|
||||
setSearch(view.filters.search);
|
||||
setFilterProjectId(view.filters.projectId);
|
||||
setSelectedStateId(view.filters.stateId);
|
||||
onValueChange={(vid) => {
|
||||
const v = savedViews.find((sv) => sv.id === vid);
|
||||
if (v) {
|
||||
setSearch(v.filters.search);
|
||||
setFilterProjectId(v.filters.projectId);
|
||||
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>
|
||||
{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">
|
||||
<Bookmark className="h-3 w-3" />
|
||||
{v.name}
|
||||
</span>
|
||||
<button
|
||||
className="ml-auto hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeSavedView(v.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -741,7 +839,7 @@ function TasksPage() {
|
||||
|
||||
<Dialog open={saveViewOpen} onOpenChange={setSaveViewOpen}>
|
||||
<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
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -756,7 +854,7 @@ function TasksPage() {
|
||||
onChange={(e) => setViewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
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("");
|
||||
setSaveViewOpen(false);
|
||||
}
|
||||
@@ -767,7 +865,7 @@ function TasksPage() {
|
||||
<Button
|
||||
disabled={!viewName.trim()}
|
||||
onClick={() => {
|
||||
addSavedView(viewName.trim(), { search, projectId: filterProjectId, stateId: selectedStateId, priority: "" });
|
||||
addSavedView(viewName.trim(), { search, projectId: filterProjectId, stateId: selectedStateId, priority });
|
||||
setViewName("");
|
||||
setSaveViewOpen(false);
|
||||
}}
|
||||
@@ -778,6 +876,22 @@ function TasksPage() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</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>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -786,18 +900,22 @@ function TasksPage() {
|
||||
<ErrorState message="Failed to load tasks." onRetry={() => refetch()} />
|
||||
) : view === "board" ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-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) => (
|
||||
<ColumnDroppable key={col.id} id={col.id} className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<ColumnDroppable key={col.id} id={col.id} className="rounded-lg border border-[#d9dee7] bg-[#f7f8fa] p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("w-2 h-2 rounded-full", col.colorClass)} />
|
||||
<h3 className="font-semibold text-sm">{col.label}</h3>
|
||||
{"color" in col && col.color ? (
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<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) => {
|
||||
const st = task.stateId ? stateById.get(task.stateId) : undefined;
|
||||
return (
|
||||
@@ -806,13 +924,14 @@ function TasksPage() {
|
||||
task={task}
|
||||
stateName={st?.name}
|
||||
stateColor={st?.color}
|
||||
onClick={() => openTaskDetail(task)}
|
||||
projectName={!filterProjectId ? projectById.get(task.projectId || "")?.name : undefined}
|
||||
onClick={() => openTaskPanel(task)}
|
||||
onEdit={() => openTaskPanel(task)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
</SortableContext>
|
||||
@@ -820,17 +939,19 @@ function TasksPage() {
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
</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 && (
|
||||
<div className="absolute top-0 right-0 text-[10px] text-muted-foreground flex gap-3 p-2 z-10">
|
||||
<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="px-1 py-0.5 rounded border bg-muted 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="px-1 py-0.5 rounded border bg-muted font-mono">x</kbd> toggle</span>
|
||||
<div className="absolute right-0 top-0 z-10 flex gap-3 p-2 text-[10px] text-muted-foreground">
|
||||
<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="rounded border bg-muted px-1 py-0.5 font-mono">Enter</kbd> open</span>
|
||||
<span><kbd className="rounded border bg-muted px-1 py-0.5 font-mono">e</kbd> edit</span>
|
||||
<span><kbd className="rounded border bg-muted px-1 py-0.5 font-mono">x</kbd> toggle</span>
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
@@ -865,7 +986,7 @@ function TasksPage() {
|
||||
key={task.id}
|
||||
data-row-index={idx}
|
||||
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))]"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
@@ -880,7 +1001,7 @@ function TasksPage() {
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
openTaskDetail(task);
|
||||
openTaskPanel(task);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -902,7 +1023,7 @@ function TasksPage() {
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="font-medium py-2">
|
||||
<TableCell className="py-2 font-medium">
|
||||
{editingTaskId === task.id ? (
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -927,7 +1048,7 @@ function TasksPage() {
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
{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} />
|
||||
{st.name}
|
||||
</Badge>
|
||||
@@ -938,7 +1059,7 @@ function TasksPage() {
|
||||
<TableCell className="py-2">
|
||||
<Badge variant="outline" className={cn("font-mono text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
|
||||
</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() : "-"}
|
||||
</TableCell>
|
||||
<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>
|
||||
</DropdownMenuTrigger>
|
||||
<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 />
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -972,7 +1094,7 @@ function TasksPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMoreTasks && (
|
||||
{hasMoreTasks && view !== "calendar" && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button variant="outline" size="sm" onClick={loadMoreTasks} disabled={loadingMoreTasks}>
|
||||
{loadingMoreTasks ? "Loading..." : "Load more tasks"}
|
||||
@@ -980,30 +1102,39 @@ function TasksPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedTask?.title || "Task Details"}>
|
||||
{selectedTask && (
|
||||
<div className="space-y-3">
|
||||
<TaskForm task={selectedTask} onClose={() => setPanelOpen(false)} />
|
||||
<div className="pt-4 border-t">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Task</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<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>
|
||||
)}
|
||||
</EntityDetailPanel>
|
||||
<TaskDetailPanel
|
||||
taskId={panelTaskId}
|
||||
open={panelOpen}
|
||||
onOpenChange={(o) => {
|
||||
setPanelOpen(o);
|
||||
if (!o) {
|
||||
setPanelTaskId(null);
|
||||
setSelectedTask(null);
|
||||
}
|
||||
}}
|
||||
onOpenFullPage={(t) => {
|
||||
setPanelOpen(false);
|
||||
openTaskDetail(t);
|
||||
}}
|
||||
/>
|
||||
|
||||
{selectedTask && !panelOpen && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<span className="hidden" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user