feat: add review bucket filtering and status indication in task board
This commit is contained in:
@@ -2,8 +2,11 @@ import { CalendarClock, UserCircle } from "lucide-react";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type TaskStatus = "inbox" | "in_progress" | "review" | "done";
|
||||||
|
|
||||||
interface TaskCardProps {
|
interface TaskCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
status?: TaskStatus;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
assignee?: string;
|
assignee?: string;
|
||||||
due?: string;
|
due?: string;
|
||||||
@@ -19,6 +22,7 @@ interface TaskCardProps {
|
|||||||
|
|
||||||
export function TaskCard({
|
export function TaskCard({
|
||||||
title,
|
title,
|
||||||
|
status,
|
||||||
priority,
|
priority,
|
||||||
assignee,
|
assignee,
|
||||||
due,
|
due,
|
||||||
@@ -32,10 +36,13 @@ export function TaskCard({
|
|||||||
onDragEnd,
|
onDragEnd,
|
||||||
}: TaskCardProps) {
|
}: TaskCardProps) {
|
||||||
const hasPendingApproval = approvalsPendingCount > 0;
|
const hasPendingApproval = approvalsPendingCount > 0;
|
||||||
|
const needsLeadReview = status === "review" && !isBlocked && !hasPendingApproval;
|
||||||
const leftBarClassName = isBlocked
|
const leftBarClassName = isBlocked
|
||||||
? "bg-rose-400"
|
? "bg-rose-400"
|
||||||
: hasPendingApproval
|
: hasPendingApproval
|
||||||
? "bg-amber-400"
|
? "bg-amber-400"
|
||||||
|
: needsLeadReview
|
||||||
|
? "bg-indigo-400"
|
||||||
: null;
|
: null;
|
||||||
const priorityBadge = (value?: string) => {
|
const priorityBadge = (value?: string) => {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
@@ -61,6 +68,7 @@ export function TaskCard({
|
|||||||
isDragging && "opacity-60 shadow-none",
|
isDragging && "opacity-60 shadow-none",
|
||||||
hasPendingApproval && "border-amber-200 bg-amber-50/40",
|
hasPendingApproval && "border-amber-200 bg-amber-50/40",
|
||||||
isBlocked && "border-rose-200 bg-rose-50/50",
|
isBlocked && "border-rose-200 bg-rose-50/50",
|
||||||
|
needsLeadReview && "border-indigo-200 bg-indigo-50/30",
|
||||||
)}
|
)}
|
||||||
draggable={draggable}
|
draggable={draggable}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
@@ -98,6 +106,12 @@ export function TaskCard({
|
|||||||
Approval needed · {approvalsPendingCount}
|
Approval needed · {approvalsPendingCount}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{needsLeadReview ? (
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-indigo-700">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
|
||||||
|
Waiting for lead review
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type TaskBoardProps = {
|
|||||||
onTaskMove?: (taskId: string, status: TaskStatus) => void | Promise<void>;
|
onTaskMove?: (taskId: string, status: TaskStatus) => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ReviewBucket = "all" | "approval_needed" | "waiting_lead" | "blocked";
|
||||||
|
|
||||||
const columns: Array<{
|
const columns: Array<{
|
||||||
title: string;
|
title: string;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
@@ -99,6 +101,7 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
|
|
||||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||||
const [activeColumn, setActiveColumn] = useState<TaskStatus | null>(null);
|
const [activeColumn, setActiveColumn] = useState<TaskStatus | null>(null);
|
||||||
|
const [reviewBucket, setReviewBucket] = useState<ReviewBucket>("all");
|
||||||
|
|
||||||
const setCardRef = useCallback(
|
const setCardRef = useCallback(
|
||||||
(taskId: string) => (node: HTMLDivElement | null) => {
|
(taskId: string) => (node: HTMLDivElement | null) => {
|
||||||
@@ -310,6 +313,42 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
>
|
>
|
||||||
{columns.map((column) => {
|
{columns.map((column) => {
|
||||||
const columnTasks = grouped[column.status] ?? [];
|
const columnTasks = grouped[column.status] ?? [];
|
||||||
|
const reviewCounts =
|
||||||
|
column.status === "review"
|
||||||
|
? columnTasks.reduce(
|
||||||
|
(acc, task) => {
|
||||||
|
if (task.is_blocked) {
|
||||||
|
acc.blocked += 1;
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
if ((task.approvals_pending_count ?? 0) > 0) {
|
||||||
|
acc.approval_needed += 1;
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
acc.waiting_lead += 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
all: columnTasks.length,
|
||||||
|
approval_needed: 0,
|
||||||
|
waiting_lead: 0,
|
||||||
|
blocked: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const filteredTasks =
|
||||||
|
column.status === "review" && reviewBucket !== "all"
|
||||||
|
? columnTasks.filter((task) => {
|
||||||
|
if (reviewBucket === "blocked") return Boolean(task.is_blocked);
|
||||||
|
if (reviewBucket === "approval_needed")
|
||||||
|
return (task.approvals_pending_count ?? 0) > 0 && !task.is_blocked;
|
||||||
|
if (reviewBucket === "waiting_lead")
|
||||||
|
return !task.is_blocked && (task.approvals_pending_count ?? 0) === 0;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
: columnTasks;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={column.title}
|
key={column.title}
|
||||||
@@ -335,16 +374,52 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
column.badge,
|
column.badge,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{columnTasks.length}
|
{filteredTasks.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{column.status === "review" && reviewCounts ? (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ key: "all", label: "All", count: reviewCounts.all },
|
||||||
|
{
|
||||||
|
key: "approval_needed",
|
||||||
|
label: "Approval needed",
|
||||||
|
count: reviewCounts.approval_needed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "waiting_lead",
|
||||||
|
label: "Lead review",
|
||||||
|
count: reviewCounts.waiting_lead,
|
||||||
|
},
|
||||||
|
{ key: "blocked", label: "Blocked", count: reviewCounts.blocked },
|
||||||
|
] as const
|
||||||
|
).map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReviewBucket(option.key)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-2.5 py-1 transition",
|
||||||
|
reviewBucket === option.key
|
||||||
|
? "border-slate-900 bg-slate-900 text-white"
|
||||||
|
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50",
|
||||||
|
)}
|
||||||
|
aria-pressed={reviewBucket === option.key}
|
||||||
|
>
|
||||||
|
{option.label} · {option.count}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
|
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{columnTasks.map((task) => (
|
{filteredTasks.map((task) => (
|
||||||
<div key={task.id} ref={setCardRef(task.id)}>
|
<div key={task.id} ref={setCardRef(task.id)}>
|
||||||
<TaskCard
|
<TaskCard
|
||||||
title={task.title}
|
title={task.title}
|
||||||
|
status={task.status}
|
||||||
priority={task.priority}
|
priority={task.priority}
|
||||||
assignee={task.assignee ?? undefined}
|
assignee={task.assignee ?? undefined}
|
||||||
due={formatDueDate(task.due_at)}
|
due={formatDueDate(task.due_at)}
|
||||||
|
|||||||
Reference in New Issue
Block a user