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