mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 08:18:19 +00:00
feat(ui): improve team board with task snapshots and board toolbar
This commit is contained in:
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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user