feat(ui): improve team board with task snapshots and board toolbar

This commit is contained in:
viettranx committed 2026-03-16 20:06:35 +07:00
1 parent 96898a3daa
commit 89303f5624
7 files changed
+63 -19

No files matched your search

@@ -108,6 +108,10 @@ export const BoardContainer = memo(function BoardContainer({
const handleCreateTask = useCallback(() => toast.info(t("board.createViaChat")), [t]);
const handleTaskClick = useCallback((task: TeamTaskData) => setSelectedTask(task), []);
const handleCloseDetail = useCallback(() => setSelectedTask(null), []);
const handleNavigateTask = useCallback((taskId: string) => {
const found = tasks.find((t) => t.id === taskId);
if (found) setSelectedTask(found);
}, [tasks]);
// Delete handler for kanban cards (confirm + call API)
const deleteTaskRef = useRef(deleteTask);
@@ -146,6 +150,7 @@ export const BoardContainer = memo(function BoardContainer({
isTeamV2={isTeamV2}
groupBy={groupBy}
emojiLookup={emojiLookup}
taskLookup={taskLookup}
onTaskClick={handleTaskClick}
onDeleteTask={deleteTask ? handleDeleteTask : undefined}
/>
@@ -174,6 +179,7 @@ export const BoardContainer = memo(function BoardContainer({
taskLookup={taskLookup}
memberLookup={memberLookup}
emojiLookup={emojiLookup}
onNavigateTask={handleNavigateTask}
/>
)}
</div>
+1 -1
View File
@@ -3,9 +3,9 @@ import type { TeamTaskData, TeamMemberData } from "@/types/team";
/** All kanban column statuses in display order */
export const KANBAN_STATUSES = [
"pending",
"blocked",
"in_progress",
"completed",
"blocked",
"failed",
"cancelled",
] as const;
@@ -9,11 +9,12 @@ interface KanbanBoardProps {
isTeamV2?: boolean;
groupBy: GroupBy;
emojiLookup?: Map<string, string>;
taskLookup?: Map<string, string>;
onTaskClick: (task: TeamTaskData) => void;
onDeleteTask?: (taskId: string) => void;
}
export const KanbanBoard = memo(function KanbanBoard({ tasks, isTeamV2, groupBy, emojiLookup, onTaskClick, onDeleteTask }: KanbanBoardProps) {
export const KanbanBoard = memo(function KanbanBoard({ tasks, isTeamV2, groupBy, emojiLookup, taskLookup, onTaskClick, onDeleteTask }: KanbanBoardProps) {
const grouped = useMemo(() => groupTasksBy(tasks, groupBy), [tasks, groupBy]);
const columns = useMemo(() => {
@@ -39,6 +40,7 @@ export const KanbanBoard = memo(function KanbanBoard({ tasks, isTeamV2, groupBy,
tasks={grouped.get(col) ?? []}
isTeamV2={isTeamV2}
emojiLookup={emojiLookup}
taskLookup={taskLookup}
onTaskClick={onTaskClick}
onDeleteTask={onDeleteTask}
/>
+21 -4
View File
@@ -1,5 +1,6 @@
import { memo } from "react";
import { Trash2 } from "lucide-react";
import { motion } from "framer-motion";
import { Trash2, Ban } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslation } from "react-i18next";
import { isTaskLocked } from "./board-utils";
@@ -24,20 +25,26 @@ interface KanbanCardProps {
task: TeamTaskData;
isTeamV2?: boolean;
emojiLookup?: Map<string, string>;
taskLookup?: Map<string, string>;
onClick: () => void;
onDelete?: (taskId: string) => void;
}
export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, emojiLookup, onClick, onDelete }: KanbanCardProps) {
export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, emojiLookup, taskLookup, onClick, onDelete }: KanbanCardProps) {
const { t } = useTranslation("teams");
const locked = isTaskLocked(task);
const blocked = task.status === "blocked";
const ownerEmoji = task.owner_agent_id && emojiLookup?.get(task.owner_agent_id);
const canDelete = onDelete && isTerminalStatus(task.status);
const prio = PRIORITY_LABELS[task.priority] ?? PRIORITY_LABELS[0]!;
const hasBlockers = task.blocked_by && task.blocked_by.length > 0;
return (
<div
<motion.div
layoutId={task.id}
layout
initial={false}
transition={{ type: "spring", stiffness: 350, damping: 30 }}
className={
"group relative cursor-pointer rounded-lg border bg-card p-3 shadow-sm transition-colors hover:bg-accent/50" +
(locked ? " border-l-2 border-l-green-500" : blocked ? " border-l-2 border-l-amber-500" : "")
@@ -70,6 +77,16 @@ export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, emojiLookup
<p className="line-clamp-2 text-xs font-medium leading-snug">{task.subject}</p>
{/* Blocked-by row */}
{hasBlockers && (
<p className="mt-1 flex items-center gap-1 text-[10px] text-amber-600 dark:text-amber-400">
<Ban className="h-3 w-3 shrink-0" />
<span className="truncate">
{task.blocked_by!.map((id) => taskLookup?.get(id) || id.slice(0, 8)).join(", ")}
</span>
</p>
)}
{/* Bottom row: owner + type badge + priority */}
<div className="mt-2 flex items-center gap-1.5">
{ownerEmoji && <span className="text-sm leading-none">{ownerEmoji}</span>}
@@ -95,6 +112,6 @@ export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, emojiLookup
<span className="text-[10px] text-muted-foreground">{task.progress_percent}%</span>
</div>
)}
</div>
</motion.div>
);
});
+18 -11
View File
@@ -1,4 +1,5 @@
import { memo } from "react";
import { AnimatePresence, LayoutGroup } from "framer-motion";
import { Badge } from "@/components/ui/badge";
import { useTranslation } from "react-i18next";
import { STATUS_COLORS } from "./board-utils";
@@ -11,11 +12,12 @@ interface KanbanColumnProps {
tasks: TeamTaskData[];
isTeamV2?: boolean;
emojiLookup?: Map<string, string>;
taskLookup?: Map<string, string>;
onTaskClick: (task: TeamTaskData) => void;
onDeleteTask?: (taskId: string) => void;
}
export const KanbanColumn = memo(function KanbanColumn({ columnId, title, tasks, isTeamV2, emojiLookup, onTaskClick, onDeleteTask }: KanbanColumnProps) {
export const KanbanColumn = memo(function KanbanColumn({ columnId, title, tasks, isTeamV2, emojiLookup, taskLookup, onTaskClick, onDeleteTask }: KanbanColumnProps) {
const { t } = useTranslation("teams");
return (
@@ -30,16 +32,21 @@ export const KanbanColumn = memo(function KanbanColumn({ columnId, title, tasks,
{tasks.length === 0 ? (
<div className="py-6 text-center text-xs text-muted-foreground">{t("board.emptyColumn")}</div>
) : (
tasks.map((task) => (
<KanbanCard
key={task.id}
task={task}
isTeamV2={isTeamV2}
emojiLookup={emojiLookup}
onClick={() => onTaskClick(task)}
onDelete={onDeleteTask}
/>
))
<LayoutGroup>
<AnimatePresence mode="popLayout">
{tasks.map((task) => (
<KanbanCard
key={task.id}
task={task}
isTeamV2={isTeamV2}
emojiLookup={emojiLookup}
taskLookup={taskLookup}
onClick={() => onTaskClick(task)}
onDelete={onDeleteTask}
/>
))}
</AnimatePresence>
</LayoutGroup>
)}
</div>
</div>
@@ -24,11 +24,12 @@ interface TaskDetailDialogProps {
taskLookup?: Map<string, string>;
memberLookup?: Map<string, string>;
emojiLookup?: Map<string, string>;
onNavigateTask?: (taskId: string) => void;
}
export function TaskDetailDialog({
task, teamId, isTeamV2, onClose,
getTaskDetail, deleteTask, taskLookup, memberLookup, emojiLookup,
getTaskDetail, deleteTask, taskLookup, memberLookup, emojiLookup, onNavigateTask,
}: TaskDetailDialogProps) {
const { t } = useTranslation("teams");
const [events, setEvents] = useState<TeamTaskEvent[]>([]);
@@ -171,7 +172,14 @@ export function TaskDetailDialog({
<span className="text-muted-foreground">{t("tasks.detail.blockedBy")}</span>{" "}
<div className="mt-1 flex flex-wrap gap-1">
{task.blocked_by.map((id) => (
<Badge key={id} variant="outline" className="text-xs">{taskLookup?.get(id) || id.slice(0, 8)}</Badge>
<Badge
key={id}
variant="outline"
className={"text-xs" + (onNavigateTask ? " cursor-pointer hover:bg-accent" : "")}
onClick={onNavigateTask ? () => onNavigateTask(id) : undefined}
>
{taskLookup?.get(id) || id.slice(0, 8)}
</Badge>
))}
</div>
</div>
@@ -120,6 +120,10 @@ export function TaskList({
taskLookup={taskLookup}
memberLookup={memberLookup}
emojiLookup={emojiLookup}
onNavigateTask={(taskId) => {
const found = tasks.find((t) => t.id === taskId);
if (found) setSelectedTask(found);
}}
/>
)}
</>