diff --git a/cmd/gateway.go b/cmd/gateway.go index 7275492c..ba41df92 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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) diff --git a/internal/gateway/methods/teams_workspace.go b/internal/gateway/methods/teams_workspace.go index f1f04711..918fa93e 100644 --- a/internal/gateway/methods/teams_workspace.go +++ b/internal/gateway/methods/teams_workspace.go @@ -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 diff --git a/ui/web/src/i18n/locales/en/teams.json b/ui/web/src/i18n/locales/en/teams.json index 91c109a4..c3e87196 100644 --- a/ui/web/src/i18n/locales/en/teams.json +++ b/ui/web/src/i18n/locales/en/teams.json @@ -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" } } diff --git a/ui/web/src/i18n/locales/vi/teams.json b/ui/web/src/i18n/locales/vi/teams.json index 77ef4923..b83b36f9 100644 --- a/ui/web/src/i18n/locales/vi/teams.json +++ b/ui/web/src/i18n/locales/vi/teams.json @@ -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" } } diff --git a/ui/web/src/i18n/locales/zh/teams.json b/ui/web/src/i18n/locales/zh/teams.json index 4f8a1aa7..8aefde61 100644 --- a/ui/web/src/i18n/locales/zh/teams.json +++ b/ui/web/src/i18n/locales/zh/teams.json @@ -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": "添加成员" } } diff --git a/ui/web/src/pages/teams/board/board-container.tsx b/ui/web/src/pages/teams/board/board-container.tsx new file mode 100644 index 00000000..2c53bc99 --- /dev/null +++ b/ui/web/src/pages/teams/board/board-container.tsx @@ -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([]); + const [initialized, setInitialized] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [statusFilter, setStatusFilter] = useState("all"); + const [selectedScope, setSelectedScope] = useState(null); + const [selectedTask, setSelectedTask] = useState(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>(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 ( +
+ + +
+ {!initialized ? ( +
{t("tasks.loading")}
+ ) : viewMode === "kanban" ? ( + + ) : ( + + )} +
+ + {selectedTask && ( + + )} +
+ ); +}); diff --git a/ui/web/src/pages/teams/board/board-header.tsx b/ui/web/src/pages/teams/board/board-header.tsx new file mode 100644 index 00000000..763adfa2 --- /dev/null +++ b/ui/web/src/pages/teams/board/board-header.tsx @@ -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 ( +
+ + + {/* Team info */} +
+
+

{team.name}

+ + {team.status} + + {isV2 && ( + + v2 Super Team (Beta) + + )} +
+
+ {leadName && ( + {t("detail.lead")}: {leadName} + )} + + {memberCount !== 1 + ? t("detail.memberCountPlural", { count: memberCount }) + : t("detail.memberCount", { count: memberCount })} + +
+
+ + {/* Actions */} + + + + +
+ ); +} diff --git a/ui/web/src/pages/teams/board/board-toolbar.tsx b/ui/web/src/pages/teams/board/board-toolbar.tsx new file mode 100644 index 00000000..e9444558 --- /dev/null +++ b/ui/web/src/pages/teams/board/board-toolbar.tsx @@ -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 ( +
+
+ {/* Status filter */} +
+ {filters.map((f) => ( + + ))} +
+ + {/* Scope filter */} + {scopes.length > 0 && ( + + )} + + {/* Group by toggle (only in kanban view) */} + {viewMode === "kanban" && ( +
+ {GROUP_OPTIONS.map((g) => ( + + ))} +
+ )} +
+ +
+ {/* View toggle */} +
+ + +
+ + + +
+
+ ); +}); diff --git a/ui/web/src/pages/teams/board/board-utils.ts b/ui/web/src/pages/teams/board/board-utils.ts new file mode 100644 index 00000000..9359bb88 --- /dev/null +++ b/ui/web/src/pages/teams/board/board-utils.ts @@ -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 = { + 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 { + const map = new Map(); + 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 { + const map = new Map(); + 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 { + const map = new Map(); + 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(); +} diff --git a/ui/web/src/pages/teams/board/create-task-dialog.tsx b/ui/web/src/pages/teams/board/create-task-dialog.tsx new file mode 100644 index 00000000..21b174b6 --- /dev/null +++ b/ui/web/src/pages/teams/board/create-task-dialog.tsx @@ -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; + 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 ( + + + + {t("tasks.createTaskTitle")} + +
+
+ + 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 /> +
+
+ +