refactor(teams): redesign team detail page with kanban board layout

- Add board/ components: kanban board, columns, cards, toolbar, dialogs
- Add Zustand board store for task state management
- Simplify task-detail-dialog and task-list components
- Refactor team-detail-page to board-based layout
- Update team-version-modal with improved UI
- Clean up team-settings-tab
- Add i18n strings for board UI (en/vi/zh)
- Update workspace path resolution in gateway and teams_workspace
This commit is contained in:
viettranx committed 2026-03-15 17:28:54 +07:00
1 parent 8053acfe8c
commit f236d721a9
23 files changed
+1450 -510

No files matched your search

+1 -1
View File
@@ -853,7 +853,7 @@ func runGateway() {
registerConfigChannels(cfg, channelMgr, msgBus, pgStores, instanceLoader)
// Register channels/instances/links/teams RPC methods
wireChannelRPCMethods(server, pgStores, channelMgr, agentRouter, msgBus, dataDir)
wireChannelRPCMethods(server, pgStores, channelMgr, agentRouter, msgBus, workspace)
// Wire channel event subscribers (cache invalidation, pairing, cascade disable)
wireChannelEventSubscribers(msgBus, server, pgStores, channelMgr, instanceLoader, pairingMethods, cfg)
+40 -15
View File
@@ -73,11 +73,11 @@ func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway.
var files []workspaceFileEntry
if params.ChatID != "" {
// Scoped: list files in specific chatID directory.
// Scoped: list files and folders in specific chatID directory.
scopeDir := teamWorkspaceDir(m.dataDir, teamID, params.ChatID)
files = listDirFiles(scopeDir, params.ChatID)
files = walkDir(scopeDir, "", params.ChatID)
} else {
// Unscoped: list all chatID subdirectories and their files.
// Unscoped: list all chatID subdirectories and their files/folders.
entries, err := os.ReadDir(baseDir)
if err != nil {
// Directory doesn't exist = empty workspace.
@@ -93,7 +93,7 @@ func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway.
}
chatID := entry.Name()
scopeDir := filepath.Join(baseDir, chatID)
files = append(files, listDirFiles(scopeDir, chatID)...)
files = append(files, walkDir(scopeDir, "", chatID)...)
}
}
@@ -107,15 +107,28 @@ func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway.
}))
}
// listDirFiles lists non-directory files in a directory, returning workspaceFileEntry slice.
func listDirFiles(dir, chatID string) []workspaceFileEntry {
entries, err := os.ReadDir(dir)
// walkDir recursively lists files and directories, returning workspaceFileEntry slice
// with relative paths. prefix is the relative path prefix for nested entries.
func walkDir(baseDir, prefix, chatID string) []workspaceFileEntry {
entries, err := os.ReadDir(baseDir)
if err != nil {
return nil
}
var files []workspaceFileEntry
for _, entry := range entries {
relPath := entry.Name()
if prefix != "" {
relPath = prefix + "/" + entry.Name()
}
if entry.IsDir() {
files = append(files, workspaceFileEntry{
Name: relPath,
Path: filepath.Join(baseDir, entry.Name()),
ChatID: chatID,
IsDir: true,
})
// Recurse into subdirectory.
files = append(files, walkDir(filepath.Join(baseDir, entry.Name()), relPath, chatID)...)
continue
}
info, err := entry.Info()
@@ -123,8 +136,8 @@ func listDirFiles(dir, chatID string) []workspaceFileEntry {
continue
}
files = append(files, workspaceFileEntry{
Name: entry.Name(),
Path: filepath.Join(dir, entry.Name()),
Name: relPath,
Path: filepath.Join(baseDir, entry.Name()),
Size: info.Size(),
ChatID: chatID,
UpdatedAt: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"),
@@ -153,8 +166,8 @@ func (m *TeamsMethods) handleWorkspaceRead(ctx context.Context, client *gateway.
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "team_id, file_name")))
return
}
// Security: reject path traversal.
if strings.Contains(params.FileName, "..") || strings.ContainsAny(params.FileName, "/\\") {
// Security: reject path traversal (allow "/" for nested paths, reject "\" and "..").
if strings.Contains(params.FileName, "..") || strings.Contains(params.FileName, "\\") {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name"))
return
}
@@ -170,7 +183,13 @@ func (m *TeamsMethods) handleWorkspaceRead(ctx context.Context, client *gateway.
chatID = "_default"
}
diskPath := filepath.Join(teamWorkspaceDir(m.dataDir, teamID, chatID), params.FileName)
scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID)
diskPath := filepath.Clean(filepath.Join(scopeDir, params.FileName))
// Ensure resolved path stays within the workspace scope directory.
if !strings.HasPrefix(diskPath, filepath.Clean(scopeDir)+string(os.PathSeparator)) && diskPath != filepath.Clean(scopeDir) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name"))
return
}
data, err := os.ReadFile(diskPath)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, fmt.Sprintf("file not found: %s", params.FileName)))
@@ -219,8 +238,8 @@ func (m *TeamsMethods) handleWorkspaceDelete(ctx context.Context, client *gatewa
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "team_id, file_name")))
return
}
// Security: reject path traversal.
if strings.Contains(params.FileName, "..") || strings.ContainsAny(params.FileName, "/\\") {
// Security: reject path traversal (allow "/" for nested paths, reject "\" and "..").
if strings.Contains(params.FileName, "..") || strings.Contains(params.FileName, "\\") {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name"))
return
}
@@ -236,7 +255,13 @@ func (m *TeamsMethods) handleWorkspaceDelete(ctx context.Context, client *gatewa
chatID = "_default"
}
diskPath := filepath.Join(teamWorkspaceDir(m.dataDir, teamID, chatID), params.FileName)
scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID)
diskPath := filepath.Clean(filepath.Join(scopeDir, params.FileName))
// Ensure resolved path stays within the workspace scope directory.
if !strings.HasPrefix(diskPath, filepath.Clean(scopeDir)+string(os.PathSeparator)) && diskPath != filepath.Clean(scopeDir) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name"))
return
}
if err := os.Remove(diskPath); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, fmt.Sprintf("file not found: %s", params.FileName)))
return
+23
View File
@@ -64,6 +64,8 @@
"unassigned": "Unassigned",
"filters": {
"active": "Active",
"pending": "Pending",
"inProgress": "In Progress",
"inReview": "In Review",
"completed": "Completed",
"all": "All"
@@ -224,6 +226,7 @@
"autoRecoveryDesc": "Stuck tasks auto-reset after timeout",
"escalationPolicy": "Escalation Policy",
"escalationPolicyDesc": "Control what agents can do autonomously",
"comingSoon": "Coming soon",
"downgradeNote": "Downgrade is safe — V2 data is preserved but hidden.",
"betaNote": "V2 is currently in Beta.",
"gotIt": "Got it"
@@ -264,5 +267,25 @@
"taskAssigned": "Task assigned",
"failedCreateTask": "Failed to create task",
"failedAssignTask": "Failed to assign task"
},
"board": {
"kanban": "Board",
"list": "List",
"groupBy": "Group by",
"groupByStatus": "Status",
"groupByOwner": "Owner",
"groupByType": "Type",
"running": "Running",
"locked": "Agent is running",
"emptyColumn": "No tasks",
"unassigned": "Unassigned",
"createViaChat": "Chat with the team agent to create tasks"
},
"sidebar": {
"members": "Members",
"workspace": "Workspace",
"delegations": "Delegations",
"settings": "Settings",
"addMember": "Add member"
}
}
+23
View File
@@ -64,6 +64,8 @@
"unassigned": "Chưa gán",
"filters": {
"active": "Đang hoạt động",
"pending": "Chờ xử lý",
"inProgress": "Đang chạy",
"inReview": "Chờ duyệt",
"completed": "Đã hoàn thành",
"all": "Tất cả"
@@ -224,6 +226,7 @@
"autoRecoveryDesc": "Nhiệm vụ bị treo tự đặt lại sau thời gian chờ",
"escalationPolicy": "Chính sách leo thang",
"escalationPolicyDesc": "Kiểm soát những gì agent có thể tự thực hiện",
"comingSoon": "Sắp ra mắt",
"downgradeNote": "Hạ cấp an toàn — dữ liệu V2 được giữ lại nhưng ẩn đi.",
"betaNote": "V2 hiện đang ở giai đoạn Beta.",
"gotIt": "Đã hiểu"
@@ -264,5 +267,25 @@
"taskAssigned": "Đã gán nhiệm vụ",
"failedCreateTask": "Không thể tạo nhiệm vụ",
"failedAssignTask": "Không thể gán nhiệm vụ"
},
"board": {
"kanban": "Bảng",
"list": "Danh sách",
"groupBy": "Nhóm theo",
"groupByStatus": "Trạng thái",
"groupByOwner": "Người phụ trách",
"groupByType": "Loại",
"running": "Đang chạy",
"locked": "Agent đang chạy",
"emptyColumn": "Không có task",
"unassigned": "Chưa gán",
"createViaChat": "Chat với agent của team để tạo task"
},
"sidebar": {
"members": "Thành viên",
"workspace": "Không gian làm việc",
"delegations": "Ủy quyền",
"settings": "Cài đặt",
"addMember": "Thêm thành viên"
}
}
+23
View File
@@ -64,6 +64,8 @@
"unassigned": "未分配",
"filters": {
"active": "进行中",
"pending": "待处理",
"inProgress": "处理中",
"inReview": "待审核",
"completed": "已完成",
"all": "全部"
@@ -224,6 +226,7 @@
"autoRecoveryDesc": "卡住的任务在超时后自动重置",
"escalationPolicy": "升级策略",
"escalationPolicyDesc": "控制代理可以自主执行的操作",
"comingSoon": "即将推出",
"downgradeNote": "降级是安全的 — V2数据会保留但隐藏。",
"betaNote": "V2目前处于Beta阶段。",
"gotIt": "知道了"
@@ -264,5 +267,25 @@
"taskAssigned": "任务已分配",
"failedCreateTask": "创建任务失败",
"failedAssignTask": "分配任务失败"
},
"board": {
"kanban": "看板",
"list": "列表",
"groupBy": "分组",
"groupByStatus": "状态",
"groupByOwner": "负责人",
"groupByType": "类型",
"running": "运行中",
"locked": "代理正在运行",
"emptyColumn": "暂无任务",
"unassigned": "未分配",
"createViaChat": "与团队代理聊天以创建任务"
},
"sidebar": {
"members": "成员",
"workspace": "工作区",
"delegations": "委派",
"settings": "设置",
"addMember": "添加成员"
}
}
@@ -0,0 +1,156 @@
import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react";
import { useTranslation } from "react-i18next";
import { useWsEvent } from "@/hooks/use-ws-event";
import { Events } from "@/api/protocol";
import { useBoardStore } from "../stores/use-board-store";
import { toast } from "@/stores/use-toast-store";
import { buildTaskLookup, buildMemberLookup } from "./board-utils";
import { BoardToolbar } from "./board-toolbar";
import { KanbanBoard } from "./kanban-board";
import { TaskDetailDialog } from "../task-sections/task-detail-dialog";
import { TaskList } from "../task-sections";
import type {
TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment,
TeamMemberData, ScopeEntry,
} from "@/types/team";
type StatusFilter = "all" | "pending" | "in_progress" | "completed";
interface BoardContainerProps {
teamId: string;
members: TeamMemberData[];
scopes: ScopeEntry[];
isTeamV2: boolean;
getTeamTasks: (teamId: string, status?: string, channel?: string, chatId?: string) => Promise<{ tasks: TeamTaskData[]; count: number }>;
getTaskDetail: (teamId: string, taskId: string) => Promise<{ task: TeamTaskData; comments: TeamTaskComment[]; events: TeamTaskEvent[]; attachments: TeamTaskAttachment[] }>;
}
export const BoardContainer = memo(function BoardContainer({
teamId, members, scopes, isTeamV2,
getTeamTasks, getTaskDetail,
}: BoardContainerProps) {
const { t } = useTranslation("teams");
const viewMode = useBoardStore((s) => s.viewMode);
const groupBy = useBoardStore((s) => s.groupBy);
const [tasks, setTasks] = useState<TeamTaskData[]>([]);
const [initialized, setInitialized] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [selectedScope, setSelectedScope] = useState<ScopeEntry | null>(null);
const [selectedTask, setSelectedTask] = useState<TeamTaskData | null>(null);
// Lookups for name resolution
const taskLookup = useMemo(() => buildTaskLookup(tasks), [tasks]);
const memberLookup = useMemo(() => buildMemberLookup(members), [members]);
// Stable refs for filter values — avoids recreating load callback
const filtersRef = useRef({ statusFilter, selectedScope });
filtersRef.current = { statusFilter, selectedScope };
const getTeamTasksRef = useRef(getTeamTasks);
getTeamTasksRef.current = getTeamTasks;
// Stable load function — never changes identity
const load = useCallback(async (showSpinner = false) => {
if (showSpinner) setRefreshing(true);
try {
const { statusFilter: sf, selectedScope: ss } = filtersRef.current;
// Backend supports: "" (active), "all", "completed", "in_review"
// "pending" and "in_progress" are client-side filtered from "all"
const backendFilter = (sf === "pending" || sf === "in_progress") ? "all" : sf;
const res = await getTeamTasksRef.current(teamId, backendFilter, ss?.channel, ss?.chat_id);
let result = res.tasks ?? [];
if (sf === "pending") result = result.filter((t) => t.status === "pending");
else if (sf === "in_progress") result = result.filter((t) => t.status === "in_progress");
setTasks(result);
setInitialized(true);
} catch (err) {
console.error("[BoardContainer] load failed:", err);
} finally {
if (showSpinner) setRefreshing(false);
}
}, [teamId]);
// Initial load
useEffect(() => { load(); }, [load]);
// Re-fetch when filters change — seamless (no spinner)
useEffect(() => {
if (initialized) load();
}, [statusFilter, selectedScope]); // eslint-disable-line react-hooks/exhaustive-deps
// Debounced WS event reload
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const reloadOnEvent = useCallback((payload: unknown) => {
const p = payload as { team_id?: string };
if (p?.team_id && p.team_id !== teamId) return;
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => load(), 300);
}, [teamId, load]);
useWsEvent(Events.TEAM_TASK_CREATED, reloadOnEvent);
useWsEvent(Events.TEAM_TASK_CLAIMED, reloadOnEvent);
useWsEvent(Events.TEAM_TASK_COMPLETED, reloadOnEvent);
useWsEvent(Events.TEAM_TASK_CANCELLED, reloadOnEvent);
useWsEvent(Events.TEAM_TASK_REVIEWED, reloadOnEvent);
useWsEvent(Events.TEAM_TASK_APPROVED, reloadOnEvent);
useWsEvent(Events.TEAM_TASK_REJECTED, reloadOnEvent);
useWsEvent(Events.TEAM_TASK_PROGRESS, reloadOnEvent);
useWsEvent(Events.TEAM_TASK_COMMENTED, reloadOnEvent);
useWsEvent(Events.TEAM_TASK_ASSIGNED, reloadOnEvent);
// Stable callbacks for children — never change identity
const handleRefresh = useCallback(() => load(true), [load]);
const handleCreateTask = useCallback(() => toast.info(t("board.createViaChat")), [t]);
const handleTaskClick = useCallback((task: TeamTaskData) => setSelectedTask(task), []);
const handleCloseDetail = useCallback(() => setSelectedTask(null), []);
return (
<div className="flex flex-1 flex-col gap-3 overflow-hidden p-3 sm:p-4">
<BoardToolbar
statusFilter={statusFilter}
onStatusFilter={setStatusFilter}
scopes={scopes}
selectedScope={selectedScope}
onScopeChange={setSelectedScope}
spinning={refreshing}
onRefresh={handleRefresh}
onCreateTask={handleCreateTask}
/>
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
{!initialized ? (
<div className="py-12 text-center text-sm text-muted-foreground">{t("tasks.loading")}</div>
) : viewMode === "kanban" ? (
<KanbanBoard
tasks={tasks}
isTeamV2={isTeamV2}
groupBy={groupBy}
onTaskClick={handleTaskClick}
/>
) : (
<TaskList
tasks={tasks}
loading={!initialized}
teamId={teamId}
members={members}
isTeamV2={isTeamV2}
getTaskDetail={getTaskDetail}
/>
)}
</div>
{selectedTask && (
<TaskDetailDialog
task={selectedTask}
teamId={teamId}
isTeamV2={isTeamV2}
onClose={handleCloseDetail}
getTaskDetail={getTaskDetail}
taskLookup={taskLookup}
memberLookup={memberLookup}
/>
)}
</div>
);
});
@@ -0,0 +1,81 @@
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Settings, Trash2, FolderOpen, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { TeamData, TeamMemberData, TeamAccessSettings } from "@/types/team";
interface BoardHeaderProps {
team: TeamData;
members: TeamMemberData[];
onBack: () => void;
onDelete: () => void;
onSettings: () => void;
onMembers: () => void;
onWorkspace: () => void;
onV2Click?: () => void;
}
export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMembers, onWorkspace, onV2Click }: BoardHeaderProps) {
const { t } = useTranslation("teams");
const settings = (team.settings ?? {}) as TeamAccessSettings;
const isV2 = (settings.version ?? 1) >= 2;
const leadMember = members.find((m) => m.role === "lead");
const leadName = leadMember?.display_name || leadMember?.agent_key
|| team.lead_display_name || team.lead_agent_key;
const memberCount = members.length;
return (
<div className="flex items-center gap-3 border-b bg-card px-4 py-2.5 landscape-compact">
<Button variant="ghost" size="icon" onClick={onBack} className="shrink-0">
<ArrowLeft className="h-4 w-4" />
</Button>
{/* Team info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="truncate text-base font-semibold sm:text-lg">{team.name}</h2>
<Badge variant={team.status === "active" ? "success" : "secondary"} className="text-[10px]">
{team.status}
</Badge>
{isV2 && (
<Badge
className="bg-gradient-to-r from-violet-500 to-indigo-500 text-[10px] px-2 py-0.5 text-white border-0 font-semibold shadow-sm cursor-pointer hover:opacity-80 transition-opacity"
onClick={onV2Click}
>
v2 Super Team (Beta)
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{leadName && (
<span>{t("detail.lead")}: {leadName}</span>
)}
<span>
{memberCount !== 1
? t("detail.memberCountPlural", { count: memberCount })
: t("detail.memberCount", { count: memberCount })}
</span>
</div>
</div>
{/* Actions */}
<Button variant="ghost" size="icon" onClick={onMembers} className="shrink-0" title={t("members.title")}>
<Users className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={onWorkspace} className="shrink-0" title={t("workspace.title")}>
<FolderOpen className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={onSettings} className="shrink-0" title={t("detail.tabs.settings")}>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
}
@@ -0,0 +1,137 @@
import { memo } from "react";
import { Button } from "@/components/ui/button";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { RefreshCw, Plus, LayoutGrid, List } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useBoardStore } from "../stores/use-board-store";
import type { GroupBy } from "../stores/use-board-store";
import type { ScopeEntry } from "@/types/team";
type StatusFilter = "all" | "pending" | "in_progress" | "completed";
interface BoardToolbarProps {
statusFilter: StatusFilter;
onStatusFilter: (f: StatusFilter) => void;
scopes: ScopeEntry[];
selectedScope: ScopeEntry | null;
onScopeChange: (s: ScopeEntry | null) => void;
spinning: boolean;
onRefresh: () => void;
onCreateTask: () => void;
}
const STATUS_FILTERS: { value: StatusFilter; labelKey: string }[] = [
{ value: "all", labelKey: "tasks.filters.all" },
{ value: "pending", labelKey: "tasks.filters.pending" },
{ value: "in_progress", labelKey: "tasks.filters.inProgress" },
{ value: "completed", labelKey: "tasks.filters.completed" },
];
const GROUP_OPTIONS: { value: GroupBy; labelKey: string }[] = [
{ value: "status", labelKey: "board.groupByStatus" },
{ value: "owner", labelKey: "board.groupByOwner" },
];
export const BoardToolbar = memo(function BoardToolbar({
statusFilter, onStatusFilter,
scopes, selectedScope, onScopeChange,
spinning, onRefresh, onCreateTask,
}: BoardToolbarProps) {
const { t } = useTranslation("teams");
const { viewMode, setViewMode, groupBy, setGroupBy } = useBoardStore();
const filters = STATUS_FILTERS;
return (
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
{/* Status filter */}
<div className="flex rounded-lg border bg-muted/50 p-0.5">
{filters.map((f) => (
<button
key={f.value}
onClick={() => onStatusFilter(f.value)}
className={
"cursor-pointer rounded-md px-2.5 py-1 text-xs font-medium transition-colors " +
(statusFilter === f.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")
}
>
{t(f.labelKey)}
</button>
))}
</div>
{/* Scope filter */}
{scopes.length > 0 && (
<Select
value={selectedScope?.chat_id ?? "__all__"}
onValueChange={(v) => {
if (v === "__all__") onScopeChange(null);
else onScopeChange({ channel: "", chat_id: v });
}}
>
<SelectTrigger className="h-8 w-40 text-xs">
<SelectValue placeholder={t("scope.all")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("scope.all")}</SelectItem>
{scopes.map((s) => (
<SelectItem key={s.chat_id} value={s.chat_id}>
{s.chat_id.length > 16 ? s.chat_id.slice(0, 16) + "\u2026" : s.chat_id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* Group by toggle (only in kanban view) */}
{viewMode === "kanban" && (
<div className="flex rounded-lg border bg-muted/50 p-0.5">
{GROUP_OPTIONS.map((g) => (
<button
key={g.value}
onClick={() => setGroupBy(g.value)}
className={
"cursor-pointer rounded-md px-2.5 py-1 text-xs font-medium transition-colors " +
(groupBy === g.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")
}
>
{t(g.labelKey)}
</button>
))}
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* View toggle */}
<div className="flex rounded-lg border bg-muted/50 p-0.5">
<button
onClick={() => setViewMode("kanban")}
className={"cursor-pointer rounded-md p-1.5 " + (viewMode === "kanban" ? "bg-background shadow-sm" : "text-muted-foreground")}
>
<LayoutGrid className="h-4 w-4" />
</button>
<button
onClick={() => setViewMode("list")}
className={"cursor-pointer rounded-md p-1.5 " + (viewMode === "list" ? "bg-background shadow-sm" : "text-muted-foreground")}
>
<List className="h-4 w-4" />
</button>
</div>
<Button variant="outline" size="icon" className="h-8 w-8" onClick={onCreateTask}>
<Plus className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={spinning} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} />
</Button>
</div>
</div>
);
});
@@ -0,0 +1,73 @@
import type { TeamTaskData, TeamMemberData } from "@/types/team";
/** All kanban column statuses in display order */
export const KANBAN_STATUSES = [
"pending",
"in_progress",
"completed",
"blocked",
"failed",
"cancelled",
] as const;
export type KanbanStatus = (typeof KANBAN_STATUSES)[number];
/** Status display colors for column headers */
export const STATUS_COLORS: Record<string, string> = {
pending: "bg-slate-400",
in_progress: "bg-blue-500",
completed: "bg-green-500",
blocked: "bg-amber-500",
failed: "bg-red-500",
cancelled: "bg-gray-400",
};
/** Group tasks by a field for kanban columns */
export function groupTasksBy(
tasks: TeamTaskData[],
key: "status" | "owner" | "type",
): Map<string, TeamTaskData[]> {
const map = new Map<string, TeamTaskData[]>();
for (const task of tasks) {
let groupKey: string;
switch (key) {
case "status":
groupKey = task.status;
break;
case "owner":
groupKey = task.owner_agent_key || "unassigned";
break;
case "type":
groupKey = task.task_type || "general";
break;
}
const arr = map.get(groupKey) ?? [];
arr.push(task);
map.set(groupKey, arr);
}
return map;
}
/** Build task ID -> subject lookup for resolving blocked_by references */
export function buildTaskLookup(tasks: TeamTaskData[]): Map<string, string> {
const map = new Map<string, string>();
for (const t of tasks) map.set(t.id, t.subject);
return map;
}
/** Build agent_id -> display name lookup from members */
export function buildMemberLookup(
members: TeamMemberData[],
): Map<string, string> {
const map = new Map<string, string>();
for (const m of members)
map.set(m.agent_id, m.display_name || m.agent_key || m.agent_id.slice(0, 8));
return map;
}
/** Check if agent is actively running on a task */
export function isTaskLocked(task: TeamTaskData): boolean {
if (!task.locked_at) return false;
const expiry = task.lock_expires_at ? new Date(task.lock_expires_at) : null;
return !expiry || expiry > new Date();
}
@@ -0,0 +1,121 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import { useTranslation } from "react-i18next";
import { toast } from "@/stores/use-toast-store";
import type { TeamTaskData, TeamMemberData, ScopeEntry } from "@/types/team";
interface CreateTaskDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamId: string;
members: TeamMemberData[];
selectedScope: ScopeEntry | null;
createTask: (teamId: string, params: {
subject: string; description?: string; priority?: number;
taskType?: string; assignTo?: string; channel?: string; chatId?: string;
}) => Promise<TeamTaskData>;
onCreated: () => void;
}
export function CreateTaskDialog({
open, onOpenChange, teamId, members, selectedScope, createTask, onCreated,
}: CreateTaskDialogProps) {
const { t } = useTranslation("teams");
const [subject, setSubject] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState(0);
const [taskType, setTaskType] = useState("general");
const [assignTo, setAssignTo] = useState("");
const [creating, setCreating] = useState(false);
const handleCreate = async () => {
if (!subject.trim()) return;
setCreating(true);
try {
await createTask(teamId, {
subject: subject.trim(),
description: description.trim() || undefined,
priority,
taskType: taskType || undefined,
assignTo: assignTo || undefined,
channel: selectedScope?.channel || undefined,
chatId: selectedScope?.chat_id || undefined,
});
toast.success(t("toast.taskCreated"));
setSubject(""); setDescription(""); setPriority(0);
setTaskType("general"); setAssignTo("");
onOpenChange(false);
onCreated();
} catch {
toast.error(t("toast.failedCreateTask"));
} finally {
setCreating(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("tasks.createTaskTitle")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("tasks.columns.subject")} *</label>
<input type="text" value={subject} onChange={(e) => setSubject(e.target.value)}
placeholder={t("tasks.subjectPlaceholder")}
className="w-full rounded-md border bg-background px-3 py-2 text-base md:text-sm" autoFocus />
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("tasks.detail.description")}</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)}
placeholder={t("tasks.descriptionPlaceholder")} rows={3}
className="w-full resize-none rounded-md border bg-background px-3 py-2 text-base md:text-sm" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("tasks.detail.type").replace(":", "")}</label>
<select value={taskType} onChange={(e) => setTaskType(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-base md:text-sm">
<option value="general">General</option>
<option value="delegation">Delegation</option>
<option value="escalation">Escalation</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("tasks.priorityLabel")}</label>
<input type="number" value={priority} onChange={(e) => setPriority(Number(e.target.value))}
min={0} className="w-full rounded-md border bg-background px-3 py-2 text-base md:text-sm" />
</div>
</div>
{members.length > 0 && (
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("tasks.assignTo")}</label>
<select value={assignTo} onChange={(e) => setAssignTo(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-base md:text-sm">
<option value="">{t("tasks.unassigned")}</option>
{members.map((m) => (
<option key={m.agent_id} value={m.agent_id}>
{m.display_name || m.agent_key || m.agent_id}
{m.role === "lead" ? " (Lead)" : ""}
</option>
))}
</select>
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={creating}>
{t("create.cancel")}
</Button>
<Button size="sm" onClick={handleCreate} disabled={creating || !subject.trim()}>
{creating ? t("create.creating") : t("tasks.createTask")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,45 @@
import { useMemo, memo } from "react";
import { groupTasksBy, KANBAN_STATUSES } from "./board-utils";
import { KanbanColumn } from "./kanban-column";
import type { GroupBy } from "../stores/use-board-store";
import type { TeamTaskData } from "@/types/team";
interface KanbanBoardProps {
tasks: TeamTaskData[];
isTeamV2?: boolean;
groupBy: GroupBy;
onTaskClick: (task: TeamTaskData) => void;
}
export const KanbanBoard = memo(function KanbanBoard({ tasks, isTeamV2, groupBy, onTaskClick }: KanbanBoardProps) {
const grouped = useMemo(() => groupTasksBy(tasks, groupBy), [tasks, groupBy]);
const columns = useMemo(() => {
if (groupBy === "status") {
return [...KANBAN_STATUSES];
}
return [...grouped.keys()].sort();
}, [groupBy, grouped]);
return (
<div
className="flex h-full gap-3 overflow-x-auto overscroll-contain p-4 scroll-smooth snap-x snap-mandatory rounded-lg"
style={{
backgroundImage: "radial-gradient(circle, var(--color-border) 1px, transparent 1px)",
backgroundSize: "24px 24px",
}}
>
{columns.map((col) => (
<div key={col} className="snap-start self-start max-h-full flex">
<KanbanColumn
columnId={col}
title={col}
tasks={grouped.get(col) ?? []}
isTeamV2={isTeamV2}
onTaskClick={onTaskClick}
/>
</div>
))}
</div>
);
});
@@ -0,0 +1,72 @@
import { memo } from "react";
import { Badge } from "@/components/ui/badge";
import { useTranslation } from "react-i18next";
import { isTaskLocked } from "./board-utils";
import type { TeamTaskData } from "@/types/team";
const PRIORITY_COLORS: Record<number, string> = {
0: "bg-slate-400",
1: "bg-blue-500",
2: "bg-amber-500",
3: "bg-red-500",
};
interface KanbanCardProps {
task: TeamTaskData;
isTeamV2?: boolean;
onClick: () => void;
}
export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, onClick }: KanbanCardProps) {
const { t } = useTranslation("teams");
const locked = isTaskLocked(task);
const blocked = task.status === "blocked";
return (
<div
className={
"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" : "")
}
onClick={onClick}
>
<div className="mb-1 flex items-center justify-between">
<span className="font-mono text-[10px] text-muted-foreground">
{task.identifier || `#${task.task_number ?? ""}`}
</span>
<div className="flex items-center gap-1.5">
{locked && (
<span className="flex items-center gap-1 text-[10px] text-green-600 dark:text-green-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-green-500" />
{t("board.running")}
</span>
)}
<span
className={`h-2 w-2 rounded-full ${PRIORITY_COLORS[task.priority] ?? PRIORITY_COLORS[0]}`}
title={`Priority ${task.priority}`}
/>
</div>
</div>
<p className="line-clamp-2 text-sm font-medium leading-snug">{task.subject}</p>
<div className="mt-2 flex items-center gap-1.5">
<span className="truncate text-xs text-muted-foreground">
{task.owner_agent_key || t("board.unassigned")}
</span>
{task.task_type && task.task_type !== "general" && (
<Badge variant="outline" className="text-[10px] px-1 py-0">{task.task_type}</Badge>
)}
</div>
{isTeamV2 && task.progress_percent != null && task.progress_percent > 0 && (
<div className="mt-2 flex items-center gap-1.5">
<div className="h-1.5 flex-1 rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${task.progress_percent}%` }} />
</div>
<span className="text-[10px] text-muted-foreground">{task.progress_percent}%</span>
</div>
)}
</div>
);
});
@@ -0,0 +1,43 @@
import { memo } from "react";
import { Badge } from "@/components/ui/badge";
import { useTranslation } from "react-i18next";
import { STATUS_COLORS } from "./board-utils";
import { KanbanCard } from "./kanban-card";
import type { TeamTaskData } from "@/types/team";
interface KanbanColumnProps {
columnId: string;
title: string;
tasks: TeamTaskData[];
isTeamV2?: boolean;
onTaskClick: (task: TeamTaskData) => void;
}
export const KanbanColumn = memo(function KanbanColumn({ columnId, title, tasks, isTeamV2, onTaskClick }: KanbanColumnProps) {
const { t } = useTranslation("teams");
return (
<div className="flex max-h-full w-[280px] shrink-0 flex-col rounded-xl border bg-card shadow-sm">
<div className="flex items-center gap-2 px-3 py-2.5">
<span className={`h-2.5 w-2.5 rounded-full ${STATUS_COLORS[columnId] ?? "bg-gray-400"}`} />
<span className="text-sm font-medium capitalize">{title.replace(/_/g, " ")}</span>
<Badge variant="secondary" className="ml-auto text-[10px] px-1.5 py-0">{tasks.length}</Badge>
</div>
<div className="flex flex-1 flex-col gap-2 overflow-y-auto overscroll-contain px-2 pb-2">
{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}
onClick={() => onTaskClick(task)}
/>
))
)}
</div>
</div>
);
});
@@ -0,0 +1,81 @@
import { useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { useTranslation } from "react-i18next";
import { TeamSettingsTab } from "../team-settings-tab";
import { TeamVersionModal } from "../team-version-modal";
import type { TeamData, TeamMemberData, TeamAccessSettings } from "@/types/team";
interface TeamInfoDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
team: TeamData;
teamId: string;
members: TeamMemberData[];
onSaved: () => void;
}
export function TeamInfoDialog({
open, onOpenChange, team, teamId, members, onSaved,
}: TeamInfoDialogProps) {
const { t } = useTranslation("teams");
const [versionModalOpen, setVersionModalOpen] = useState(false);
const settings = (team.settings ?? {}) as TeamAccessSettings;
const isV2 = (settings.version ?? 1) >= 2;
// Resolve lead name from members (more reliable than team.lead_display_name which can be empty)
const leadMember = members.find((m) => m.role === "lead");
const leadName = leadMember?.display_name || leadMember?.agent_key
|| team.lead_display_name || team.lead_agent_key || "—";
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] w-[95vw] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{team.name}
<Badge variant={team.status === "active" ? "success" : "secondary"} className="text-[10px]">
{team.status}
</Badge>
{isV2 && (
<Badge
className="bg-gradient-to-r from-violet-500 to-indigo-500 text-[10px] px-2 py-0.5 text-white border-0 font-semibold shadow-sm cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setVersionModalOpen(true)}
>
v2 Super Team (Beta)
</Badge>
)}
</DialogTitle>
</DialogHeader>
{/* Team overview */}
<div className="grid grid-cols-1 gap-3 rounded-lg border p-4 text-sm sm:grid-cols-2">
{team.description && (
<div className="sm:col-span-2">
<span className="text-xs text-muted-foreground">{t("create.description")}</span>
<p className="mt-0.5">{team.description}</p>
</div>
)}
<div>
<span className="text-xs text-muted-foreground">{t("detail.lead")}</span>
<p className="mt-0.5 font-medium">{leadName}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">{t("members.title")}</span>
<p className="mt-0.5 font-medium">{t("detail.memberCountPlural", { count: members.length })}</p>
</div>
</div>
{/* Settings form */}
<TeamSettingsTab teamId={teamId} team={team} onSaved={() => { onSaved(); onOpenChange(false); }} />
</DialogContent>
</Dialog>
<TeamVersionModal open={versionModalOpen} onOpenChange={setVersionModalOpen} />
</>
);
}
@@ -0,0 +1,141 @@
import { useState, useMemo, useEffect, useRef } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Bot, UserPlus, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import type { TeamMemberData } from "@/types/team";
interface TeamMembersDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
members: TeamMemberData[];
onAddMember: (agentId: string, role?: string) => Promise<void>;
onRemoveMember: (agentId: string) => Promise<void>;
}
const ROLE_COLORS: Record<string, string> = {
lead: "bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/30",
reviewer: "bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/30",
member: "bg-muted text-muted-foreground",
};
export function TeamMembersDialog({
open, onOpenChange, members, onAddMember, onRemoveMember,
}: TeamMembersDialogProps) {
const { t } = useTranslation("teams");
const { agents, refresh } = useAgents();
const [showAdd, setShowAdd] = useState(false);
const [selected, setSelected] = useState("");
const [adding, setAdding] = useState(false);
const didRefresh = useRef(false);
useEffect(() => {
if (open && !didRefresh.current) { didRefresh.current = true; refresh(); }
}, [open, refresh]);
const memberIds = useMemo(() => new Set(members.map((m) => m.agent_id)), [members]);
const available = useMemo(
() => agents
.filter((a) => a.agent_type === "predefined" && a.status === "active" && !memberIds.has(a.id))
.map((a) => ({ value: a.id, label: a.display_name || a.agent_key })),
[agents, memberIds],
);
const sorted = useMemo(
() => [...members].sort((a, b) => {
if (a.role === "lead" && b.role !== "lead") return -1;
if (b.role === "lead" && a.role !== "lead") return 1;
return (a.display_name || a.agent_key || "").localeCompare(b.display_name || b.agent_key || "");
}),
[members],
);
const handleAdd = async () => {
if (!selected) return;
setAdding(true);
try {
await onAddMember(selected, "member");
setSelected("");
setShowAdd(false);
} catch { /* handled upstream */ }
finally { setAdding(false); }
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("members.title")} ({members.length})</DialogTitle>
<DialogDescription className="sr-only">{t("members.title")}</DialogDescription>
</DialogHeader>
{/* Add member */}
<div className="flex items-center justify-end">
<Button variant="outline" size="sm" className="gap-1" onClick={() => setShowAdd(!showAdd)}>
<UserPlus className="h-3.5 w-3.5" />
{t("members.addMember")}
</Button>
</div>
{showAdd && (
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<Combobox
value={selected}
onChange={setSelected}
options={available}
placeholder={t("members.searchAgents")}
/>
</div>
<Button size="sm" className="h-9 shrink-0" disabled={!selected || adding} onClick={handleAdd}>
{t("members.add")}
</Button>
</div>
)}
{/* Member list */}
<div className="max-h-[60vh] overflow-y-auto overscroll-contain rounded-lg border">
{sorted.map((m, i) => (
<div
key={m.agent_id}
className={`group flex items-start gap-3 px-4 py-3 hover:bg-muted/30 ${i > 0 ? "border-t" : ""}`}
>
<Bot className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{m.display_name || m.agent_key || m.agent_id.slice(0, 8)}
</span>
<Badge variant="outline" className={`shrink-0 text-[10px] px-1.5 py-0 ${ROLE_COLORS[m.role] ?? ""}`}>
{m.role}
</Badge>
</div>
{m.frontmatter && (
<p className="mt-1 break-words text-xs leading-relaxed text-muted-foreground">
{m.frontmatter}
</p>
)}
</div>
{m.role !== "lead" && (
<button
className="mt-0.5 shrink-0 cursor-pointer text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={() => onRemoveMember(m.agent_id)}
>
<X className="h-4 w-4" />
</button>
)}
</div>
))}
{members.length === 0 && (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">{t("members.noMembers")}</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,129 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FileBrowser } from "@/components/shared/file-browser";
import { buildTree } from "@/lib/file-helpers";
import { useTeamWorkspace } from "../hooks/use-team-workspace";
import type { ScopeEntry } from "@/types/team";
interface TeamWorkspaceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamId: string;
scopes: ScopeEntry[];
}
export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: TeamWorkspaceDialogProps) {
const { t } = useTranslation("teams");
const { files, loading, listFiles, readFile, deleteFile } = useTeamWorkspace();
const [selectedScope, setSelectedScope] = useState<string>("__all__");
const [fileContent, setFileContent] = useState<{ content: string; path: string; size: number } | null>(null);
const [contentLoading, setContentLoading] = useState(false);
const [activePath, setActivePath] = useState<string | null>(null);
const scopeValue = selectedScope === "__all__" ? "" : selectedScope;
const load = useCallback(() => {
listFiles(teamId, scopeValue || undefined);
setFileContent(null);
setActivePath(null);
}, [teamId, listFiles, scopeValue]);
useEffect(() => {
if (open) load();
}, [open, load]);
const tree = useMemo(
() => buildTree(files.map((f) => ({
path: f.name,
name: f.name.includes("/") ? f.name.split("/").pop()! : f.name,
isDir: f.is_dir ?? false,
size: f.size,
}))),
[files],
);
const handleSelect = useCallback(async (path: string) => {
const match = files.find((f) => f.name === path);
if (!match || match.is_dir) return;
setActivePath(path);
setContentLoading(true);
try {
const res = await readFile(teamId, match.name, match.chat_id || undefined);
setFileContent({ content: res.content ?? "", path: match.name, size: match.size ?? 0 });
} catch { /* ignore */ }
finally { setContentLoading(false); }
}, [teamId, files, readFile]);
const handleDelete = useCallback(async (path: string) => {
const match = files.find((f) => f.name === path);
if (!match) return;
try {
await deleteFile(teamId, match.name, match.chat_id || undefined);
if (activePath === path) { setFileContent(null); setActivePath(null); }
load();
} catch { /* ignore */ }
}, [teamId, files, deleteFile, activePath, load]);
const handleDownload = useCallback((path: string) => {
if (!fileContent || fileContent.path !== path) return;
const blob = new Blob([fileContent.content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = path.includes("/") ? path.split("/").pop()! : path;
a.click();
URL.revokeObjectURL(url);
}, [fileContent]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="h-[90vh] w-[95vw] overflow-hidden sm:max-w-6xl flex flex-col">
<DialogHeader className="shrink-0">
<div className="flex items-center justify-between">
<DialogTitle>{t("workspace.title")}</DialogTitle>
<div className="flex items-center gap-2">
{scopes.length > 0 && (
<Select value={selectedScope} onValueChange={setSelectedScope}>
<SelectTrigger className="h-8 w-40 text-xs">
<SelectValue placeholder={t("scope.all")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("scope.all")}</SelectItem>
{scopes.map((s) => (
<SelectItem key={s.chat_id} value={s.chat_id}>
{s.chat_id.length > 16 ? s.chat_id.slice(0, 16) + "\u2026" : s.chat_id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button variant="outline" size="sm" onClick={load} disabled={loading} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (loading ? " animate-spin" : "")} />
</Button>
</div>
</div>
</DialogHeader>
<FileBrowser
tree={tree}
filesLoading={loading}
activePath={activePath}
onSelect={handleSelect}
contentLoading={contentLoading}
fileContent={fileContent}
onDelete={handleDelete}
onDownload={handleDownload}
showSize
/>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,46 @@
import { create } from "zustand";
export type ViewMode = "kanban" | "list";
export type GroupBy = "status" | "owner";
interface BoardState {
viewMode: ViewMode;
groupBy: GroupBy;
setViewMode: (mode: ViewMode) => void;
setGroupBy: (by: GroupBy) => void;
}
const KEY = "goclaw:teamBoard";
function load(): Record<string, unknown> {
try {
const raw = localStorage.getItem(KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function save(partial: Record<string, unknown>) {
try {
localStorage.setItem(KEY, JSON.stringify({ ...load(), ...partial }));
} catch {
/* ignore */
}
}
export const useBoardStore = create<BoardState>((set) => {
const s = load();
return {
viewMode: (s.viewMode as ViewMode) ?? "kanban",
groupBy: (s.groupBy as GroupBy) ?? "status",
setViewMode: (mode) => {
save({ viewMode: mode });
set({ viewMode: mode });
},
setGroupBy: (by) => {
save({ groupBy: by });
set({ groupBy: by });
},
};
});
@@ -1,120 +1,46 @@
import { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { toast } from "@/stores/use-toast-store";
import { formatDate } from "@/lib/format";
import type { TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment } from "@/types/team";
import type { TeamMemberData } from "@/types/team";
import { taskStatusBadgeVariant } from "./task-utils";
interface TaskDetailDialogProps {
task: TeamTaskData;
teamId: string;
members: TeamMemberData[];
isTeamV2?: boolean;
onClose: () => void;
getTaskDetail: (teamId: string, taskId: string) => Promise<{
task: TeamTaskData;
comments: TeamTaskComment[];
events: TeamTaskEvent[];
attachments: TeamTaskAttachment[];
task: TeamTaskData; comments: TeamTaskComment[];
events: TeamTaskEvent[]; attachments: TeamTaskAttachment[];
}>;
approveTask: (teamId: string, taskId: string, comment?: string) => Promise<void>;
rejectTask: (teamId: string, taskId: string, reason?: string) => Promise<void>;
addTaskComment: (taskId: string, content: string, teamId?: string) => Promise<void>;
assignTask: (teamId: string, taskId: string, agentId: string) => Promise<void>;
taskLookup?: Map<string, string>;
memberLookup?: Map<string, string>;
}
export function TaskDetailDialog({
task, teamId, members, isTeamV2, onClose,
getTaskDetail, approveTask, rejectTask, addTaskComment, assignTask,
task, teamId, isTeamV2, onClose,
getTaskDetail, taskLookup, memberLookup,
}: TaskDetailDialogProps) {
const { t } = useTranslation("teams");
const [comments, setComments] = useState<TeamTaskComment[]>([]);
const [events, setEvents] = useState<TeamTaskEvent[]>([]);
const [attachments, setAttachments] = useState<TeamTaskAttachment[]>([]);
const [commentText, setCommentText] = useState("");
const [submitting, setSubmitting] = useState(false);
// Assign state
const [selectedAgentId, setSelectedAgentId] = useState("");
const [assigning, setAssigning] = useState(false);
const loadDetail = useCallback(async () => {
try {
const res = await getTaskDetail(teamId, task.id);
setComments(res.comments ?? []);
setEvents(res.events ?? []);
setAttachments(res.attachments ?? []);
} catch {
// ignore load errors — partial data is acceptable
}
} catch { /* partial data acceptable */ }
}, [getTaskDetail, teamId, task.id]);
useEffect(() => {
loadDetail();
}, [loadDetail]);
useEffect(() => { loadDetail(); }, [loadDetail]);
const handleApprove = async () => {
setSubmitting(true);
try {
await approveTask(teamId, task.id);
onClose();
} catch {
toast.error(t("toast.failedUpdate"));
} finally {
setSubmitting(false);
}
};
const handleReject = async () => {
setSubmitting(true);
try {
await rejectTask(teamId, task.id, "Rejected from dashboard");
onClose();
} catch {
toast.error(t("toast.failedUpdate"));
} finally {
setSubmitting(false);
}
};
const handleComment = async () => {
if (!commentText.trim()) return;
setSubmitting(true);
try {
await addTaskComment(task.id, commentText.trim(), teamId);
setCommentText("");
loadDetail();
} catch {
toast.error(t("toast.failedUpdate"));
} finally {
setSubmitting(false);
}
};
const handleAssign = async () => {
if (!selectedAgentId) return;
setAssigning(true);
try {
await assignTask(teamId, task.id, selectedAgentId);
toast.success(t("toast.taskAssigned"));
onClose();
} catch {
toast.error(t("toast.failedAssignTask"));
} finally {
setAssigning(false);
}
};
const showAssign = task.status === "pending" && !task.owner_agent_key;
const resolveMember = (id?: string) =>
(id && memberLookup?.get(id)) || undefined;
return (
<Dialog open onOpenChange={() => onClose()}>
@@ -135,7 +61,7 @@ export function TaskDetailDialog({
<p className="text-sm font-medium">{task.subject}</p>
</div>
{/* Follow-up status banner (V2 only) */}
{/* Follow-up banner (V2) */}
{isTeamV2 && task.followup_at && task.status === "in_progress" && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3">
<p className="mb-1 text-xs font-semibold text-amber-700 dark:text-amber-400">
@@ -164,7 +90,7 @@ export function TaskDetailDialog({
</div>
)}
{/* Progress bar (V2 only) */}
{/* Progress bar (V2) */}
{isTeamV2 && task.progress_percent != null && task.progress_percent > 0 && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
@@ -172,14 +98,9 @@ export function TaskDetailDialog({
<span>{task.progress_percent}%</span>
</div>
<div className="h-2 w-full rounded-full bg-muted">
<div
className="h-2 rounded-full bg-primary transition-all"
style={{ width: `${task.progress_percent}%` }}
/>
<div className="h-2 rounded-full bg-primary transition-all" style={{ width: `${task.progress_percent}%` }} />
</div>
{task.progress_step && (
<p className="text-xs text-muted-foreground">{task.progress_step}</p>
)}
{task.progress_step && <p className="text-xs text-muted-foreground">{task.progress_step}</p>}
</div>
)}
@@ -187,9 +108,7 @@ export function TaskDetailDialog({
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<span className="text-muted-foreground">{t("tasks.detail.status")}</span>{" "}
<Badge variant={taskStatusBadgeVariant(task.status)} className="text-xs">
{task.status.replace(/_/g, " ")}
</Badge>
<Badge variant={taskStatusBadgeVariant(task.status)} className="text-xs">{task.status.replace(/_/g, " ")}</Badge>
</div>
<div>
<span className="text-muted-foreground">{t("tasks.detail.priority")}</span>{" "}
@@ -197,7 +116,7 @@ export function TaskDetailDialog({
</div>
<div>
<span className="text-muted-foreground">{t("tasks.detail.owner")}</span>{" "}
<span className="font-medium">{task.owner_agent_key || "—"}</span>
<span className="font-medium">{resolveMember(task.owner_agent_id) || task.owner_agent_key || "—"}</span>
</div>
{task.task_type && task.task_type !== "general" && (
<div>
@@ -206,75 +125,22 @@ export function TaskDetailDialog({
</div>
)}
{task.created_at && (
<div>
<span className="text-muted-foreground">{t("tasks.detail.created")}</span>{" "}
{formatDate(task.created_at)}
</div>
<div><span className="text-muted-foreground">{t("tasks.detail.created")}</span> {formatDate(task.created_at)}</div>
)}
{task.updated_at && (
<div>
<span className="text-muted-foreground">{t("tasks.detail.updated")}</span>{" "}
{formatDate(task.updated_at)}
</div>
<div><span className="text-muted-foreground">{t("tasks.detail.updated")}</span> {formatDate(task.updated_at)}</div>
)}
</div>
{/* Approve / Reject buttons (V2 only) */}
{isTeamV2 && task.status === "in_review" && (
<div className="flex gap-2">
<Button
variant="default"
size="sm"
onClick={handleApprove}
disabled={submitting}
>
{t("tasks.detail.approve")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleReject}
disabled={submitting}
>
{t("tasks.detail.reject")}
</Button>
</div>
)}
{/* Assign to Agent section */}
{showAssign && members.length > 0 && (
<div className="rounded-md border p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">{t("tasks.assign")}</p>
<div className="flex gap-2">
<select
value={selectedAgentId}
onChange={(e) => setSelectedAgentId(e.target.value)}
className="flex-1 rounded-md border bg-background px-3 py-2 text-base md:text-sm"
disabled={assigning}
>
<option value="">{t("tasks.assignTo")}</option>
{members.map((m) => (
<option key={m.agent_id} value={m.agent_id}>
{m.display_name || m.agent_key || m.agent_id}
</option>
))}
</select>
<Button
size="sm"
onClick={handleAssign}
disabled={assigning || !selectedAgentId}
>
{t("tasks.assign")}
</Button>
</div>
</div>
)}
{/* Blocked by */}
{task.blocked_by && task.blocked_by.length > 0 && (
<div className="text-sm">
<span className="text-muted-foreground">{t("tasks.detail.blockedBy")}</span>{" "}
<span className="font-mono text-xs">{task.blocked_by.join(", ")}</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>
))}
</div>
</div>
)}
@@ -290,13 +156,11 @@ export function TaskDetailDialog({
{task.result && (
<div className="rounded-md border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">{t("tasks.detail.result")}</p>
<pre className="max-h-[40vh] overflow-y-auto whitespace-pre-wrap break-words text-sm">
{task.result}
</pre>
<pre className="max-h-[40vh] overflow-y-auto whitespace-pre-wrap break-words text-sm">{task.result}</pre>
</div>
)}
{/* Attachments (V2 only) */}
{/* Attachments (V2) */}
{isTeamV2 && attachments.length > 0 && (
<div className="rounded-md border p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">{t("tasks.detail.attachments")}</p>
@@ -311,7 +175,7 @@ export function TaskDetailDialog({
</div>
)}
{/* Timeline (V2 only) */}
{/* Timeline (V2) */}
{isTeamV2 && events.length > 0 && (
<div className="rounded-md border p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">{t("tasks.detail.timeline")}</p>
@@ -321,7 +185,7 @@ export function TaskDetailDialog({
<span className="text-muted-foreground">{formatDate(e.created_at)}</span>
<Badge variant="outline" className="text-[10px]">{e.event_type}</Badge>
<span className="text-muted-foreground">
{e.actor_type === "human" ? "Human" : e.actor_id.slice(0, 8)}
{e.actor_type === "human" ? "Human" : (resolveMember(e.actor_id) || e.actor_id.slice(0, 8))}
</span>
</div>
))}
@@ -329,50 +193,6 @@ export function TaskDetailDialog({
</div>
)}
{/* Comments (V2 only) */}
{isTeamV2 && <div className="rounded-md border p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">{t("tasks.detail.comments")}</p>
{comments.length > 0 ? (
<div className="mb-3 space-y-2">
{comments.map((c) => (
<div key={c.id} className="border-l-2 border-muted pl-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium">{c.agent_key || c.user_id || "—"}</span>
<span>{formatDate(c.created_at)}</span>
</div>
<p className="text-sm">{c.content}</p>
</div>
))}
</div>
) : (
<p className="mb-3 text-xs text-muted-foreground">{t("tasks.detail.noComments")}</p>
)}
{/* Comment input */}
<div className="flex gap-2">
<input
type="text"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder={t("tasks.detail.commentPlaceholder")}
className="flex-1 rounded-md border bg-background px-3 py-1.5 text-base md:text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleComment();
}
}}
/>
<Button
variant="outline"
size="sm"
onClick={handleComment}
disabled={submitting || !commentText.trim()}
>
{t("tasks.detail.addComment")}
</Button>
</div>
</div>}
</div>
</DialogContent>
</Dialog>
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { ClipboardList } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslation } from "react-i18next";
@@ -6,6 +6,7 @@ import type { TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment }
import type { TeamMemberData } from "@/types/team";
import { taskStatusBadgeVariant } from "./task-utils";
import { TaskDetailDialog } from "./task-detail-dialog";
import { buildTaskLookup, buildMemberLookup } from "../board/board-utils";
interface TaskListProps {
tasks: TeamTaskData[];
@@ -14,30 +15,22 @@ interface TaskListProps {
members: TeamMemberData[];
isTeamV2?: boolean;
getTaskDetail: (teamId: string, taskId: string) => Promise<{
task: TeamTaskData;
comments: TeamTaskComment[];
events: TeamTaskEvent[];
attachments: TeamTaskAttachment[];
task: TeamTaskData; comments: TeamTaskComment[];
events: TeamTaskEvent[]; attachments: TeamTaskAttachment[];
}>;
approveTask: (teamId: string, taskId: string, comment?: string) => Promise<void>;
rejectTask: (teamId: string, taskId: string, reason?: string) => Promise<void>;
addTaskComment: (taskId: string, content: string, teamId?: string) => Promise<void>;
assignTask: (teamId: string, taskId: string, agentId: string) => Promise<void>;
}
export function TaskList({
tasks, loading, teamId, members, isTeamV2,
getTaskDetail, approveTask, rejectTask, addTaskComment, assignTask,
getTaskDetail,
}: TaskListProps) {
const { t } = useTranslation("teams");
const [selectedTask, setSelectedTask] = useState<TeamTaskData | null>(null);
const taskLookup = useMemo(() => buildTaskLookup(tasks), [tasks]);
const memberLookup = useMemo(() => buildMemberLookup(members), [members]);
if (loading && tasks.length === 0) {
return (
<div className="py-8 text-center text-sm text-muted-foreground">
{t("tasks.loading")}
</div>
);
return <div className="py-8 text-center text-sm text-muted-foreground">{t("tasks.loading")}</div>;
}
if (tasks.length === 0) {
@@ -45,9 +38,7 @@ export function TaskList({
<div className="flex flex-col items-center gap-2 py-8 text-center">
<ClipboardList className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">{t("tasks.noTasks")}</p>
<p className="text-xs text-muted-foreground">
{t("tasks.noTasksDescription")}
</p>
<p className="text-xs text-muted-foreground">{t("tasks.noTasksDescription")}</p>
</div>
);
}
@@ -68,38 +59,26 @@ export function TaskList({
className="grid min-w-[500px] cursor-pointer grid-cols-[70px_1fr_90px_100px_60px] items-center gap-2 border-b px-4 py-3 last:border-0 hover:bg-muted/30"
onClick={() => setSelectedTask(task)}
>
<span className="font-mono text-xs text-muted-foreground">
{task.identifier || "—"}
</span>
<span className="font-mono text-xs text-muted-foreground">{task.identifier || "—"}</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{task.subject}</p>
{task.description && (
<p className="truncate text-xs text-muted-foreground/70">
{task.description}
</p>
<p className="truncate text-xs text-muted-foreground/70">{task.description}</p>
)}
{task.task_type && task.task_type !== "general" && (
<Badge variant="outline" className="mt-0.5 text-[10px]">
{task.task_type}
</Badge>
<Badge variant="outline" className="mt-0.5 text-[10px]">{task.task_type}</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-1">
<Badge variant={taskStatusBadgeVariant(task.status)}>
{task.status.replace(/_/g, " ")}
</Badge>
<Badge variant={taskStatusBadgeVariant(task.status)}>{task.status.replace(/_/g, " ")}</Badge>
{isTeamV2 && task.followup_at && task.status === "in_progress" && (
<Badge variant="outline" className="border-amber-500/50 bg-amber-500/10 text-[10px] text-amber-700 dark:text-amber-400">
{t("tasks.badges.awaitingReply")}
</Badge>
)}
</div>
<span className="truncate text-sm text-muted-foreground">
{task.owner_agent_key || "—"}
</span>
<span className="text-sm text-muted-foreground">
{task.priority}
</span>
<span className="truncate text-sm text-muted-foreground">{task.owner_agent_key || "—"}</span>
<span className="text-sm text-muted-foreground">{task.priority}</span>
</div>
))}
</div>
@@ -108,14 +87,11 @@ export function TaskList({
<TaskDetailDialog
task={selectedTask}
teamId={teamId}
members={members}
isTeamV2={isTeamV2}
onClose={() => setSelectedTask(null)}
getTaskDetail={getTaskDetail}
approveTask={approveTask}
rejectTask={rejectTask}
addTaskComment={addTaskComment}
assignTask={assignTask}
taskLookup={taskLookup}
memberLookup={memberLookup}
/>
)}
</>
+69 -127
View File
@@ -1,18 +1,14 @@
import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "react-router";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Users, Trash2 } from "lucide-react";
import { DetailPageSkeleton } from "@/components/shared/loading-skeleton";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { useTranslation } from "react-i18next";
import { useTeams } from "./hooks/use-teams";
import { TeamMembersTab } from "./team-members-tab";
import { TeamTasksTab } from "./team-tasks-tab";
import { TeamDelegationsTab } from "./team-delegations-tab";
import { TeamSettingsTab } from "./team-settings-tab";
import { TeamWorkspaceTab } from "./team-workspace-tab";
import { BoardHeader } from "./board/board-header";
import { BoardContainer } from "./board/board-container";
import { TeamInfoDialog } from "./board/team-info-dialog";
import { TeamMembersDialog } from "./board/team-members-dialog";
import { TeamWorkspaceDialog } from "./board/team-workspace-dialog";
import { TeamVersionModal } from "./team-version-modal";
import type { TeamData, TeamMemberData, TeamAccessSettings, ScopeEntry } from "@/types/team";
interface TeamDetailPageProps {
@@ -24,36 +20,27 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
const { t } = useTranslation("teams");
const {
getTeam, getTeamTasks, getTeamScopes, addMember, removeMember, deleteTeam,
getTaskDetail, approveTask, rejectTask, addTaskComment,
createTask, assignTask,
getTaskDetail,
} = useTeams();
const [team, setTeam] = useState<TeamData | null>(null);
const [members, setMembers] = useState<TeamMemberData[]>([]);
const [loading, setLoading] = useState(true);
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") || "members";
const setActiveTab = useCallback((tab: string) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (tab === "members") {
next.delete("tab");
} else {
next.set("tab", tab);
}
return next;
}, { replace: true });
}, [setSearchParams]);
const [deleteOpen, setDeleteOpen] = useState(false);
const [scopes, setScopes] = useState<ScopeEntry[]>([]);
const [loading, setLoading] = useState(true);
// Dialog states
const [deleteOpen, setDeleteOpen] = useState(false);
const [infoOpen, setInfoOpen] = useState(false);
const [membersOpen, setMembersOpen] = useState(false);
const [workspaceOpen, setWorkspaceOpen] = useState(false);
const [versionModalOpen, setVersionModalOpen] = useState(false);
const reload = useCallback(async () => {
try {
const res = await getTeam(teamId);
setTeam(res.team);
setMembers(res.members ?? []);
} catch {
// ignore
}
} catch { /* ignore */ }
}, [teamId, getTeam]);
useEffect(() => {
@@ -70,11 +57,8 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
setMembers(res.members ?? []);
setScopes(scopeList);
}
} catch {
// ignore
} finally {
if (!cancelled) setLoading(false);
}
} catch { /* ignore */ }
finally { if (!cancelled) setLoading(false); }
})();
return () => { cancelled = true; };
}, [teamId, getTeam, getTeamScopes]);
@@ -90,107 +74,65 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
}, [teamId, removeMember, reload]);
if (loading || !team) {
return <DetailPageSkeleton tabs={5} />;
return <DetailPageSkeleton tabs={3} />;
}
const settings = (team.settings ?? {}) as TeamAccessSettings;
const isTeamV2 = (settings.version ?? 1) >= 2;
return (
<div className="p-4 sm:p-6">
{/* Header */}
<div className="mb-6 flex items-start gap-4">
<Button variant="ghost" size="icon" onClick={onBack} className="mt-0.5 shrink-0">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Users className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="truncate text-xl font-semibold">{team.name}</h2>
<Badge variant={team.status === "active" ? "success" : "secondary"}>
{team.status}
</Badge>
{((team.settings ?? {}) as TeamAccessSettings).version != null &&
((team.settings ?? {}) as TeamAccessSettings).version! >= 2 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">V2 Beta</Badge>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
{team.lead_agent_key && (
<>
<span>{t("detail.lead")}: {team.lead_display_name || team.lead_agent_key}</span>
<span className="text-border">|</span>
</>
)}
<span>
{members.length !== 1
? t("detail.memberCountPlural", { count: members.length })
: t("detail.memberCount", { count: members.length })}
</span>
</div>
{team.description && (
<p className="mt-1 text-sm text-muted-foreground/70">{team.description}</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="flex h-full flex-col">
<BoardHeader
team={team}
members={members}
onBack={onBack}
onDelete={() => setDeleteOpen(true)}
onSettings={() => setInfoOpen(true)}
onMembers={() => setMembersOpen(true)}
onWorkspace={() => setWorkspaceOpen(true)}
onV2Click={() => setVersionModalOpen(true)}
/>
{/* Tabs */}
<div className="max-w-4xl rounded-xl border bg-card p-3 shadow-sm sm:p-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full justify-start overflow-x-auto overflow-y-hidden">
<TabsTrigger value="members">{t("detail.tabs.members")}</TabsTrigger>
<TabsTrigger value="tasks">{t("detail.tabs.tasks")}</TabsTrigger>
<TabsTrigger value="delegations">{t("detail.tabs.delegations")}</TabsTrigger>
<TabsTrigger value="workspace">{t("detail.tabs.workspace")}</TabsTrigger>
<TabsTrigger value="settings">{t("detail.tabs.settings")}</TabsTrigger>
</TabsList>
<BoardContainer
teamId={teamId}
members={members}
scopes={scopes}
isTeamV2={isTeamV2}
getTeamTasks={getTeamTasks}
getTaskDetail={getTaskDetail}
/>
<TabsContent value="members" className="mt-4">
<TeamMembersTab
teamId={teamId}
members={members}
onAddMember={handleAddMember}
onRemoveMember={handleRemoveMember}
/>
</TabsContent>
{/* Team info + settings + members dialog */}
<TeamInfoDialog
open={infoOpen}
onOpenChange={setInfoOpen}
team={team}
teamId={teamId}
members={members}
onSaved={reload}
/>
<TabsContent value="tasks" className="mt-4">
<TeamTasksTab
teamId={teamId}
members={members}
scopes={scopes}
isTeamV2={((team.settings ?? {}) as TeamAccessSettings).version != null && ((team.settings ?? {}) as TeamAccessSettings).version! >= 2}
getTeamTasks={getTeamTasks}
getTaskDetail={getTaskDetail}
approveTask={approveTask}
rejectTask={rejectTask}
addTaskComment={addTaskComment}
createTask={createTask}
assignTask={assignTask}
/>
</TabsContent>
{/* Members dialog */}
<TeamMembersDialog
open={membersOpen}
onOpenChange={setMembersOpen}
members={members}
onAddMember={handleAddMember}
onRemoveMember={handleRemoveMember}
/>
<TabsContent value="delegations" className="mt-4">
<TeamDelegationsTab teamId={teamId} />
</TabsContent>
{/* Workspace dialog */}
<TeamWorkspaceDialog
open={workspaceOpen}
onOpenChange={setWorkspaceOpen}
teamId={teamId}
scopes={scopes}
/>
<TabsContent value="workspace" className="mt-4">
<TeamWorkspaceTab teamId={teamId} scopes={scopes} />
</TabsContent>
<TabsContent value="settings" className="mt-4">
<TeamSettingsTab teamId={teamId} team={team} onSaved={reload} />
</TabsContent>
</Tabs>
</div>
{/* V2 version comparison modal */}
<TeamVersionModal open={versionModalOpen} onOpenChange={setVersionModalOpen} />
{/* Delete confirmation */}
<ConfirmDeleteDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
+11 -69
View File
@@ -7,7 +7,6 @@ import { X, Save, Check, Bell, ShieldAlert, Clock, Info, FolderLock, FolderSync
import { useTranslation } from "react-i18next";
import { CHANNEL_TYPES } from "@/constants/channels";
import type { TeamData, TeamAccessSettings, EscalationMode, EscalationAction } from "@/types/team";
import { ESCALATION_ACTIONS } from "@/types/team";
import { useTeams } from "./hooks/use-teams";
import { TeamVersionModal } from "./team-version-modal";
@@ -325,79 +324,22 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
</div>
</div>
{/* Escalation Policy (V2 only) */}
{isTeamV2 && <div className="space-y-4">
<h3 className="text-sm font-medium">{t("settings.escalationPolicy")}</h3>
{/* Escalation Policy (V2 only — coming soon) */}
{isTeamV2 && <div className="space-y-4 opacity-40 pointer-events-none select-none">
<h3 className="text-sm font-medium flex items-center gap-2">
{t("settings.escalationPolicy")}
<span className="rounded border px-1.5 py-0.5 text-[9px] font-normal text-muted-foreground">{t("settings.versionModal.comingSoon")}</span>
</h3>
<div className="rounded-lg border bg-gradient-to-r from-orange-500/5 to-red-500/5 p-4">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-orange-500/10 p-2.5 text-orange-600 dark:text-orange-400">
<ShieldAlert className="h-5 w-5" />
</div>
<div className="flex-1 space-y-4">
<div className="space-y-1">
<span className="text-sm font-semibold">{t("settings.escalationMode")}</span>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.escalationModeHint")}
</p>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{([
{ value: "", labelKey: "escalationModeNone", descKey: "escalationModeNoneDesc" },
{ value: "auto", labelKey: "escalationModeAuto", descKey: "escalationModeAutoDesc" },
{ value: "review", labelKey: "escalationModeReview", descKey: "escalationModeReviewDesc" },
{ value: "reject", labelKey: "escalationModeReject", descKey: "escalationModeRejectDesc" },
] as const).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setEscalationMode(opt.value as EscalationMode | "")}
className={
"rounded-lg border p-3 text-left transition-colors " +
(escalationMode === opt.value
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50")
}
>
<div className="text-sm font-medium">{t(`settings.${opt.labelKey}`)}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{t(`settings.${opt.descKey}`)}
</div>
</button>
))}
</div>
{escalationMode && (
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("settings.escalationActions")}</label>
<p className="text-xs text-muted-foreground">
{t("settings.escalationActionsHint")}
</p>
<div className="flex flex-wrap gap-2">
{ESCALATION_ACTIONS.map((action) => (
<button
key={action}
type="button"
onClick={() => {
setEscalationActions((prev) =>
prev.includes(action)
? prev.filter((a) => a !== action)
: [...prev, action],
);
}}
className={
"rounded-md border px-3 py-1.5 text-xs font-medium transition-colors " +
(escalationActions.includes(action)
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50")
}
>
{action}
</button>
))}
</div>
</div>
)}
<div className="flex-1 space-y-1">
<span className="text-sm font-semibold">{t("settings.escalationMode")}</span>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("settings.escalationModeHint")}
</p>
</div>
</div>
</div>
+2 -6
View File
@@ -54,8 +54,8 @@ const v2Filters: { value: StatusFilter; labelKey: string }[] = [
export function TeamTasksTab({
teamId, members, scopes, isTeamV2,
getTeamTasks,
getTaskDetail, approveTask, rejectTask, addTaskComment,
createTask, assignTask,
getTaskDetail,
createTask,
}: TeamTasksTabProps) {
const { t } = useTranslation("teams");
const [tasks, setTasks] = useState<TeamTaskData[]>([]);
@@ -197,10 +197,6 @@ export function TeamTasksTab({
members={members}
isTeamV2={isTeamV2}
getTaskDetail={getTaskDetail}
approveTask={approveTask}
rejectTask={rejectTask}
addTaskComment={addTaskComment}
assignTask={assignTask}
/>
{/* Create Task Dialog */}
+88 -43
View File
@@ -3,9 +3,15 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Check, Minus } from "lucide-react";
import {
Check, Minus,
ListTodo, FolderOpen, Lock, Bell,
ClipboardCheck, BarChart3, MessageSquare, RotateCcw, ShieldCheck,
} from "lucide-react";
import { useTranslation } from "react-i18next";
interface TeamVersionModalProps {
@@ -14,15 +20,15 @@ interface TeamVersionModalProps {
}
const FEATURES = [
{ key: "taskManagement", v1: true },
{ key: "sharedWorkspace", v1: true },
{ key: "executionLocking", v1: false },
{ key: "followupReminders", v1: false },
{ key: "reviewWorkflow", v1: false },
{ key: "progressTracking", v1: false },
{ key: "commentsAudit", v1: false },
{ key: "autoRecovery", v1: false },
{ key: "escalationPolicy", v1: false },
{ key: "taskManagement", icon: ListTodo, v1: true },
{ key: "sharedWorkspace", icon: FolderOpen, v1: true },
{ key: "executionLocking", icon: Lock, v1: false },
{ key: "followupReminders", icon: Bell, v1: false },
{ key: "progressTracking", icon: BarChart3, v1: false },
{ key: "autoRecovery", icon: RotateCcw, v1: false },
{ key: "commentsAudit", icon: MessageSquare, v1: false, comingSoon: true },
{ key: "escalationPolicy", icon: ShieldCheck, v1: false, comingSoon: true },
{ key: "reviewWorkflow", icon: ClipboardCheck, v1: false, comingSoon: true },
] as const;
export function TeamVersionModal({ open, onOpenChange }: TeamVersionModalProps) {
@@ -30,44 +36,83 @@ export function TeamVersionModal({ open, onOpenChange }: TeamVersionModalProps)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] sm:max-w-2xl">
<DialogContent className="max-h-[85vh] w-[95vw] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{t("settings.versionModal.title")}</DialogTitle>
<DialogTitle className="flex items-center gap-2">
{t("settings.versionModal.title")}
<Badge className="bg-gradient-to-r from-violet-500 to-indigo-500 text-[10px] px-2 py-0.5 text-white border-0 font-semibold">
Beta
</Badge>
</DialogTitle>
<DialogDescription className="sr-only">{t("settings.versionModal.title")}</DialogDescription>
</DialogHeader>
<div className="overflow-x-auto">
<table className="w-full min-w-[400px] text-sm">
<thead>
<tr className="border-b">
<th className="py-2 pr-4 text-left font-medium">{t("settings.versionModal.feature")}</th>
<th className="w-16 py-2 text-center font-medium">V1</th>
<th className="w-16 py-2 text-center font-medium">V2</th>
</tr>
</thead>
<tbody>
{FEATURES.map((f) => (
<tr key={f.key} className="border-b last:border-0">
<td className="py-2.5 pr-4">
<div className="font-medium">{t(`settings.versionModal.${f.key}`)}</div>
<div className="text-xs text-muted-foreground">{t(`settings.versionModal.${f.key}Desc`)}</div>
</td>
<td className="py-2.5 text-center">
{f.v1 ? (
<Check className="mx-auto h-4 w-4 text-green-600 dark:text-green-400" />
) : (
<Minus className="mx-auto h-4 w-4 text-muted-foreground/40" />
)}
</td>
<td className="py-2.5 text-center">
<Check className="mx-auto h-4 w-4 text-green-600 dark:text-green-400" />
</td>
</tr>
))}
</tbody>
</table>
{/* Version columns header */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border bg-muted/30 p-3 text-center">
<p className="text-lg font-bold">V1</p>
<p className="text-xs text-muted-foreground">{t("settings.versionBasicDesc")}</p>
</div>
<div className="rounded-lg border border-violet-500/30 bg-gradient-to-br from-violet-500/5 to-indigo-500/5 p-3 text-center">
<p className="text-lg font-bold bg-gradient-to-r from-violet-600 to-indigo-600 bg-clip-text text-transparent">V2 Super Team</p>
<p className="text-xs text-muted-foreground">{t("settings.versionAdvancedDesc")}</p>
</div>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
{/* Feature list */}
<div className="space-y-1">
{FEATURES.map((f) => {
const Icon = f.icon;
const dimmed = "comingSoon" in f && f.comingSoon;
return (
<div
key={f.key}
className={`group grid grid-cols-[1fr_60px_60px] items-center gap-2 rounded-lg px-3 py-2.5 hover:bg-muted/30 ${dimmed ? "opacity-40" : ""}`}
>
<div className="flex items-start gap-3 min-w-0">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-sm font-medium flex items-center gap-2">
{t(`settings.versionModal.${f.key}`)}
{dimmed && (
<Badge variant="outline" className="text-[9px] px-1.5 py-0 font-normal">
{t("settings.versionModal.comingSoon")}
</Badge>
)}
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{t(`settings.versionModal.${f.key}Desc`)}
</p>
</div>
</div>
<div className="flex justify-center">
{f.v1 ? (
<Check className="h-4 w-4 text-green-600 dark:text-green-400" />
) : (
<Minus className="h-4 w-4 text-muted-foreground/30" />
)}
</div>
<div className="flex justify-center">
{dimmed ? (
<Minus className="h-4 w-4 text-muted-foreground/30" />
) : (
<Check className="h-4 w-4 text-green-600 dark:text-green-400" />
)}
</div>
</div>
);
})}
</div>
{/* Column labels for V1/V2 */}
<div className="grid grid-cols-[1fr_60px_60px] gap-2 px-3 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
<span />
<span className="text-center">V1</span>
<span className="text-center">V2</span>
</div>
{/* Notes */}
<div className="space-y-1 rounded-lg border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
<p>{t("settings.versionModal.downgradeNote")}</p>
<p>{t("settings.versionModal.betaNote")}</p>
</div>