feat(tasks): Normalize task statuses and enhance UI for task management
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
"""normalize task statuses
|
||||||
|
|
||||||
|
Revision ID: 75e05e158ca9
|
||||||
|
Revises: 4b2a5e2dbb6e
|
||||||
|
Create Date: 2026-02-05 00:16:48.958679
|
||||||
|
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '75e05e158ca9'
|
||||||
|
down_revision = '4b2a5e2dbb6e'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"UPDATE tasks SET status='in_progress' WHERE status IN ('assigned','testing')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -32,6 +32,15 @@ from app.services.activity_log import record_activity
|
|||||||
router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"])
|
router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"])
|
||||||
|
|
||||||
REQUIRED_COMMENT_FIELDS = ("summary:", "details:", "next:")
|
REQUIRED_COMMENT_FIELDS = ("summary:", "details:", "next:")
|
||||||
|
ALLOWED_STATUSES = {"inbox", "in_progress", "review", "done"}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_task_status(status_value: str) -> None:
|
||||||
|
if status_value not in ALLOWED_STATUSES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Unsupported task status.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_valid_markdown_comment(message: str) -> bool:
|
def is_valid_markdown_comment(message: str) -> bool:
|
||||||
@@ -82,6 +91,11 @@ def list_tasks(
|
|||||||
if status_filter:
|
if status_filter:
|
||||||
statuses = [s.strip() for s in status_filter.split(",") if s.strip()]
|
statuses = [s.strip() for s in status_filter.split(",") if s.strip()]
|
||||||
if statuses:
|
if statuses:
|
||||||
|
if any(status not in ALLOWED_STATUSES for status in statuses):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Unsupported task status filter.",
|
||||||
|
)
|
||||||
statement = statement.where(col(Task.status).in_(statuses))
|
statement = statement.where(col(Task.status).in_(statuses))
|
||||||
if assigned_agent_id is not None:
|
if assigned_agent_id is not None:
|
||||||
statement = statement.where(col(Task.assigned_agent_id) == assigned_agent_id)
|
statement = statement.where(col(Task.assigned_agent_id) == assigned_agent_id)
|
||||||
@@ -99,6 +113,7 @@ def create_task(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(require_admin_auth),
|
||||||
) -> Task:
|
) -> Task:
|
||||||
|
validate_task_status(payload.status)
|
||||||
task = Task.model_validate(payload)
|
task = Task.model_validate(payload)
|
||||||
task.board_id = board.id
|
task.board_id = board.id
|
||||||
if task.created_by_user_id is None and auth.user is not None:
|
if task.created_by_user_id is None and auth.user is not None:
|
||||||
@@ -135,6 +150,7 @@ def update_task(
|
|||||||
if not set(updates).issubset(allowed_fields):
|
if not set(updates).issubset(allowed_fields):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
if "status" in updates:
|
if "status" in updates:
|
||||||
|
validate_task_status(updates["status"])
|
||||||
if updates["status"] == "inbox":
|
if updates["status"] == "inbox":
|
||||||
task.assigned_agent_id = None
|
task.assigned_agent_id = None
|
||||||
task.in_progress_at = None
|
task.in_progress_at = None
|
||||||
@@ -143,6 +159,7 @@ def update_task(
|
|||||||
if updates["status"] == "in_progress":
|
if updates["status"] == "in_progress":
|
||||||
task.in_progress_at = datetime.utcnow()
|
task.in_progress_at = datetime.utcnow()
|
||||||
elif "status" in updates:
|
elif "status" in updates:
|
||||||
|
validate_task_status(updates["status"])
|
||||||
if updates["status"] == "inbox":
|
if updates["status"] == "inbox":
|
||||||
task.assigned_agent_id = None
|
task.assigned_agent_id = None
|
||||||
task.in_progress_at = None
|
task.in_progress_at = None
|
||||||
|
|||||||
@@ -280,46 +280,65 @@ export default function BoardDetailPage() {
|
|||||||
</SignedOut>
|
</SignedOut>
|
||||||
<SignedIn>
|
<SignedIn>
|
||||||
<DashboardSidebar />
|
<DashboardSidebar />
|
||||||
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
|
<main className="flex-1 overflow-y-auto bg-gradient-to-br from-slate-50 to-slate-100">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="sticky top-0 z-30 border-b border-slate-200 bg-white shadow-sm">
|
||||||
<div className="space-y-2">
|
<div className="px-8 py-6">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
{board?.slug ?? "board"}
|
<div>
|
||||||
</p>
|
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
<h1 className="text-2xl font-semibold text-strong">
|
<span>{board?.name ?? "Board"}</span>
|
||||||
{board?.name ?? "Board"}
|
</div>
|
||||||
</h1>
|
<h1 className="mt-2 text-2xl font-semibold text-slate-900 tracking-tight">
|
||||||
<p className="text-sm text-muted">
|
{board?.name ?? "Board"}
|
||||||
Keep tasks moving through your workflow.
|
</h1>
|
||||||
</p>
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Keep tasks moving through your workflow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-1">
|
||||||
|
<button className="rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white">
|
||||||
|
Board
|
||||||
|
</button>
|
||||||
|
<button className="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900">
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
<button className="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900">
|
||||||
|
Timeline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push("/boards")}
|
||||||
|
>
|
||||||
|
Back to boards
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push("/boards")}
|
|
||||||
>
|
|
||||||
Back to boards
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
<div className="p-6">
|
||||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
|
{error && (
|
||||||
{error}
|
<div className="mb-4 rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
|
||||||
</div>
|
{error}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex flex-1 items-center justify-center text-sm text-muted">
|
<div className="flex min-h-[50vh] items-center justify-center text-sm text-slate-500">
|
||||||
Loading {titleLabel}…
|
Loading {titleLabel}…
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TaskBoard
|
<TaskBoard
|
||||||
tasks={displayTasks}
|
tasks={displayTasks}
|
||||||
onCreateTask={() => setIsDialogOpen(true)}
|
onCreateTask={() => setIsDialogOpen(true)}
|
||||||
isCreateDisabled={isCreating}
|
isCreateDisabled={isCreating}
|
||||||
onTaskSelect={openComments}
|
onTaskSelect={openComments}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
|
|
||||||
<Dialog open={isCommentsOpen} onOpenChange={(open) => {
|
<Dialog open={isCommentsOpen} onOpenChange={(open) => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { CalendarClock, UserCircle } from "lucide-react";
|
import { CalendarClock, UserCircle } from "lucide-react";
|
||||||
|
|
||||||
import { StatusPill } from "@/components/atoms/StatusPill";
|
import { cn } from "@/lib/utils";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
|
|
||||||
interface TaskCardProps {
|
interface TaskCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -18,9 +17,58 @@ export function TaskCard({
|
|||||||
due,
|
due,
|
||||||
onClick,
|
onClick,
|
||||||
}: TaskCardProps) {
|
}: TaskCardProps) {
|
||||||
|
const statusConfig: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; dot: string; badge: string; text: string }
|
||||||
|
> = {
|
||||||
|
inbox: {
|
||||||
|
label: "Inbox",
|
||||||
|
dot: "bg-slate-400",
|
||||||
|
badge: "bg-slate-100",
|
||||||
|
text: "text-slate-600",
|
||||||
|
},
|
||||||
|
assigned: {
|
||||||
|
label: "Assigned",
|
||||||
|
dot: "bg-blue-500",
|
||||||
|
badge: "bg-blue-50",
|
||||||
|
text: "text-blue-700",
|
||||||
|
},
|
||||||
|
in_progress: {
|
||||||
|
label: "In progress",
|
||||||
|
dot: "bg-purple-500",
|
||||||
|
badge: "bg-purple-50",
|
||||||
|
text: "text-purple-700",
|
||||||
|
},
|
||||||
|
testing: {
|
||||||
|
label: "Testing",
|
||||||
|
dot: "bg-amber-500",
|
||||||
|
badge: "bg-amber-50",
|
||||||
|
text: "text-amber-700",
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
label: "Review",
|
||||||
|
dot: "bg-indigo-500",
|
||||||
|
badge: "bg-indigo-50",
|
||||||
|
text: "text-indigo-700",
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
label: "Done",
|
||||||
|
dot: "bg-green-500",
|
||||||
|
badge: "bg-green-50",
|
||||||
|
text: "text-green-700",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[status] ?? {
|
||||||
|
label: status,
|
||||||
|
dot: "bg-slate-400",
|
||||||
|
badge: "bg-slate-100",
|
||||||
|
text: "text-slate-600",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div
|
||||||
className="cursor-pointer border border-[color:var(--border)] bg-[color:var(--surface)] transition hover:border-[color:var(--border-strong)]"
|
className="group cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -31,26 +79,33 @@ export function TaskCard({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent className="space-y-4">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<span
|
||||||
<p className="text-sm font-semibold text-strong">{title}</p>
|
className={cn(
|
||||||
<StatusPill status={status} />
|
"inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wide",
|
||||||
</div>
|
config.badge,
|
||||||
|
config.text,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("h-1.5 w-1.5 rounded-full", config.dot)} />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm font-medium text-slate-900">{title}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-xs text-muted">
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserCircle className="h-4 w-4 text-slate-400" />
|
||||||
|
<span>{assignee ?? "Unassigned"}</span>
|
||||||
|
</div>
|
||||||
|
{due ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserCircle className="h-4 w-4" />
|
<CalendarClock className="h-4 w-4 text-slate-400" />
|
||||||
<span>{assignee ?? "Unassigned"}</span>
|
<span>{due}</span>
|
||||||
</div>
|
</div>
|
||||||
{due ? (
|
) : null}
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<CalendarClock className="h-4 w-4" />
|
</div>
|
||||||
<span>{due}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,34 @@ type TaskBoardProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: "Inbox", status: "inbox" },
|
{
|
||||||
{ title: "Assigned", status: "assigned" },
|
title: "Inbox",
|
||||||
{ title: "In Progress", status: "in_progress" },
|
status: "inbox",
|
||||||
{ title: "Testing", status: "testing" },
|
dot: "bg-slate-400",
|
||||||
{ title: "Review", status: "review" },
|
accent: "hover:border-slate-400 hover:bg-slate-50",
|
||||||
{ title: "Done", status: "done" },
|
text: "group-hover:text-slate-700 text-slate-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "In Progress",
|
||||||
|
status: "in_progress",
|
||||||
|
dot: "bg-purple-500",
|
||||||
|
accent: "hover:border-purple-400 hover:bg-purple-50",
|
||||||
|
text: "group-hover:text-purple-600 text-slate-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Review",
|
||||||
|
status: "review",
|
||||||
|
dot: "bg-indigo-500",
|
||||||
|
accent: "hover:border-indigo-400 hover:bg-indigo-50",
|
||||||
|
text: "group-hover:text-indigo-600 text-slate-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Done",
|
||||||
|
status: "done",
|
||||||
|
dot: "bg-green-500",
|
||||||
|
accent: "hover:border-green-400 hover:bg-green-50",
|
||||||
|
text: "group-hover:text-green-600 text-slate-500",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const formatDueDate = (value?: string | null) => {
|
const formatDueDate = (value?: string | null) => {
|
||||||
@@ -61,41 +83,53 @@ export function TaskBoard({
|
|||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-6 overflow-x-auto pb-4">
|
<div className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-4 overflow-x-auto pb-6">
|
||||||
{columns.map((column) => {
|
{columns.map((column) => {
|
||||||
const columnTasks = grouped[column.status] ?? [];
|
const columnTasks = grouped[column.status] ?? [];
|
||||||
return (
|
return (
|
||||||
<div key={column.title} className="space-y-4">
|
<div key={column.title} className="kanban-column min-h-[calc(100vh-260px)]">
|
||||||
<div className="flex items-center justify-between">
|
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
|
||||||
<h3 className="text-sm font-semibold text-strong">
|
<div className="flex items-center justify-between">
|
||||||
{column.title}
|
<div className="flex items-center gap-2">
|
||||||
</h3>
|
<span className={cn("h-2 w-2 rounded-full", column.dot)} />
|
||||||
<span className="text-xs text-quiet">{columnTasks.length}</span>
|
<h3 className="text-sm font-semibold text-slate-900">
|
||||||
|
{column.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">
|
||||||
|
{columnTasks.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
|
||||||
{column.status === "inbox" ? (
|
{column.status === "inbox" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCreateTask}
|
onClick={onCreateTask}
|
||||||
disabled={isCreateDisabled}
|
disabled={isCreateDisabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-center rounded-2xl border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-6 text-[11px] font-semibold uppercase tracking-[0.2em] text-quiet transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface)]",
|
"group mb-3 flex w-full items-center justify-center rounded-lg border-2 border-dashed border-slate-300 px-4 py-4 text-sm font-medium transition",
|
||||||
|
column.accent,
|
||||||
isCreateDisabled && "cursor-not-allowed opacity-60"
|
isCreateDisabled && "cursor-not-allowed opacity-60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
New task
|
<div className={cn("flex items-center gap-2", column.text)}>
|
||||||
|
<span className="text-sm font-medium">New task</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{columnTasks.map((task) => (
|
<div className="space-y-3">
|
||||||
<TaskCard
|
{columnTasks.map((task) => (
|
||||||
key={task.id}
|
<TaskCard
|
||||||
title={task.title}
|
key={task.id}
|
||||||
status={column.status}
|
title={task.title}
|
||||||
assignee={task.assignee}
|
status={column.status}
|
||||||
due={formatDueDate(task.due_at)}
|
assignee={task.assignee}
|
||||||
onClick={() => onTaskSelect?.(task)}
|
due={formatDueDate(task.due_at)}
|
||||||
/>
|
onClick={() => onTaskSelect?.(task)}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user