mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 20:17:20 +00:00
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:
1 parent
8053acfe8c
commit
f236d721a9
23 files changed
+1450
-510
No files matched your search
+1
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user