diff --git a/.gitignore b/.gitignore index 233bb5c0..87bb717d 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,5 @@ k8s-*/* # Test credentials (never commit) tests/**/creds.json -ui/simple-saas \ No newline at end of file +ui/simple-saas +*.tar \ No newline at end of file diff --git a/internal/gateway/methods/teams_crud.go b/internal/gateway/methods/teams_crud.go index 87c3b82f..a8528a03 100644 --- a/internal/gateway/methods/teams_crud.go +++ b/internal/gateway/methods/teams_crud.go @@ -125,7 +125,9 @@ func (m *TeamsMethods) handleDelete(ctx context.Context, client *gateway.Client, // --- Task List (admin view) --- type teamsTaskListParams struct { - TeamID string `json:"teamId"` + TeamID string `json:"teamId"` + StatusFilter string `json:"statusFilter,omitempty"` + UserID string `json:"userId,omitempty"` } func (m *TeamsMethods) handleTaskList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { @@ -152,7 +154,12 @@ func (m *TeamsMethods) handleTaskList(ctx context.Context, client *gateway.Clien return } - tasks, err := m.teamStore.ListTasks(ctx, teamID, "newest", store.TeamTaskFilterAll, "") + statusFilter := params.StatusFilter + if statusFilter == "" { + statusFilter = store.TeamTaskFilterAll + } + + tasks, err := m.teamStore.ListTasks(ctx, teamID, "newest", statusFilter, params.UserID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) return diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index e0689af3..497dbf04 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -132,6 +132,8 @@ export const Methods = { TEAMS_GET: "teams.get", TEAMS_DELETE: "teams.delete", TEAMS_TASK_LIST: "teams.tasks.list", + TEAMS_TASK_APPROVE: "teams.tasks.approve", + TEAMS_TASK_REJECT: "teams.tasks.reject", TEAMS_MEMBERS_ADD: "teams.members.add", TEAMS_MEMBERS_REMOVE: "teams.members.remove", TEAMS_UPDATE: "teams.update", diff --git a/ui/web/src/i18n/locales/en/teams.json b/ui/web/src/i18n/locales/en/teams.json index 8682acd0..8ab51d08 100644 --- a/ui/web/src/i18n/locales/en/teams.json +++ b/ui/web/src/i18n/locales/en/teams.json @@ -72,6 +72,28 @@ "taskId": "Task ID:", "noDescription": "No description", "noResult": "No result yet" + }, + "actions": { + "approve": "Approve", + "reject": "Reject", + "cancel": "Cancel", + "reasonPlaceholder": "Reason (optional)...", + "confirmCancel": "Cancel this task?", + "confirmReject": "Reject this task?" + }, + "filter": { + "all": "All", + "active": "Active", + "completed": "Completed", + "allUsers": "All users" + }, + "status": { + "pending": "pending", + "pending_approval": "pending approval", + "in_progress": "in progress", + "completed": "completed", + "blocked": "blocked", + "failed": "failed" } }, "delegations": { @@ -145,6 +167,12 @@ "failedDelete": "Failed to delete team", "failedUpdate": "Failed to update team", "failedAddMember": "Failed to add member", - "failedRemoveMember": "Failed to remove member" + "failedRemoveMember": "Failed to remove member", + "taskApproved": "Task approved", + "taskRejected": "Task rejected", + "taskCancelled": "Task cancelled", + "failedApproveTask": "Failed to approve task", + "failedRejectTask": "Failed to reject task", + "failedCancelTask": "Failed to cancel task" } } diff --git a/ui/web/src/i18n/locales/vi/teams.json b/ui/web/src/i18n/locales/vi/teams.json index 8fb720be..0ba9a456 100644 --- a/ui/web/src/i18n/locales/vi/teams.json +++ b/ui/web/src/i18n/locales/vi/teams.json @@ -72,6 +72,28 @@ "taskId": "ID nhiệm vụ:", "noDescription": "Không có mô tả", "noResult": "Chưa có kết quả" + }, + "actions": { + "approve": "Duyệt", + "reject": "Từ chối", + "cancel": "Huỷ", + "reasonPlaceholder": "Lý do (tùy chọn)...", + "confirmCancel": "Huỷ nhiệm vụ này?", + "confirmReject": "Từ chối nhiệm vụ này?" + }, + "filter": { + "all": "Tất cả", + "active": "Đang hoạt động", + "completed": "Đã hoàn thành", + "allUsers": "Tất cả người dùng" + }, + "status": { + "pending": "chờ xử lý", + "pending_approval": "chờ duyệt", + "in_progress": "đang thực hiện", + "completed": "hoàn thành", + "blocked": "bị chặn", + "failed": "thất bại" } }, "delegations": { @@ -145,6 +167,12 @@ "failedDelete": "Không thể xóa nhóm", "failedUpdate": "Không thể cập nhật nhóm", "failedAddMember": "Không thể thêm thành viên", - "failedRemoveMember": "Không thể xóa thành viên" + "failedRemoveMember": "Không thể xóa thành viên", + "taskApproved": "Đã duyệt nhiệm vụ", + "taskRejected": "Đã từ chối nhiệm vụ", + "taskCancelled": "Đã huỷ nhiệm vụ", + "failedApproveTask": "Không thể duyệt nhiệm vụ", + "failedRejectTask": "Không thể từ chối nhiệm vụ", + "failedCancelTask": "Không thể huỷ nhiệm vụ" } } diff --git a/ui/web/src/i18n/locales/zh/teams.json b/ui/web/src/i18n/locales/zh/teams.json index b0d3ae37..bcd637ff 100644 --- a/ui/web/src/i18n/locales/zh/teams.json +++ b/ui/web/src/i18n/locales/zh/teams.json @@ -72,6 +72,28 @@ "taskId": "任务 ID:", "noDescription": "无描述", "noResult": "暂无结果" + }, + "actions": { + "approve": "批准", + "reject": "拒绝", + "cancel": "取消", + "reasonPlaceholder": "原因(可选)...", + "confirmCancel": "取消此任务?", + "confirmReject": "拒绝此任务?" + }, + "filter": { + "all": "全部", + "active": "进行中", + "completed": "已完成", + "allUsers": "所有用户" + }, + "status": { + "pending": "待处理", + "pending_approval": "待审批", + "in_progress": "进行中", + "completed": "已完成", + "blocked": "被阻塞", + "failed": "失败" } }, "delegations": { @@ -145,6 +167,12 @@ "failedDelete": "删除Team失败", "failedUpdate": "更新Team失败", "failedAddMember": "添加成员失败", - "failedRemoveMember": "移除成员失败" + "failedRemoveMember": "移除成员失败", + "taskApproved": "任务已批准", + "taskRejected": "任务已拒绝", + "taskCancelled": "任务已取消", + "failedApproveTask": "批准任务失败", + "failedRejectTask": "拒绝任务失败", + "failedCancelTask": "取消任务失败" } } diff --git a/ui/web/src/pages/teams/hooks/use-teams.ts b/ui/web/src/pages/teams/hooks/use-teams.ts index b6576d2a..06c509ed 100644 --- a/ui/web/src/pages/teams/hooks/use-teams.ts +++ b/ui/web/src/pages/teams/hooks/use-teams.ts @@ -58,16 +58,30 @@ export function useTeams() { ); const getTeamTasks = useCallback( - async (teamId: string) => { + async (teamId: string, statusFilter?: string, userId?: string) => { const res = await ws.call<{ tasks: TeamTaskData[]; count: number }>( Methods.TEAMS_TASK_LIST, - { teamId }, + { teamId, statusFilter, userId }, ); return res; }, [ws], ); + const approveTask = useCallback( + async (taskId: string) => { + await ws.call(Methods.TEAMS_TASK_APPROVE, { task_id: taskId }); + }, + [ws], + ); + + const rejectTask = useCallback( + async (taskId: string, reason?: string) => { + await ws.call(Methods.TEAMS_TASK_REJECT, { task_id: taskId, reason }); + }, + [ws], + ); + const addMember = useCallback( async (teamId: string, agent: string, role?: string) => { await ws.call(Methods.TEAMS_MEMBERS_ADD, { teamId, agent, role }); @@ -100,5 +114,5 @@ export function useTeams() { [ws], ); - return { teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks, addMember, removeMember, updateTeamSettings, getKnownUsers }; + return { teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks, approveTask, rejectTask, addMember, removeMember, updateTeamSettings, getKnownUsers }; } diff --git a/ui/web/src/pages/teams/task-sections/task-detail-dialog.tsx b/ui/web/src/pages/teams/task-sections/task-detail-dialog.tsx index 5795a518..c3fd9ac3 100644 --- a/ui/web/src/pages/teams/task-sections/task-detail-dialog.tsx +++ b/ui/web/src/pages/teams/task-sections/task-detail-dialog.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Dialog, DialogContent, @@ -5,18 +6,56 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { useTranslation } from "react-i18next"; import { formatDate } from "@/lib/format"; import type { TeamTaskData } from "@/types/team"; -import { taskStatusBadgeVariant } from "./task-utils"; +import { taskStatusBadgeVariant, isTaskActionable } from "./task-utils"; interface TaskDetailDialogProps { task: TeamTaskData; onClose: () => void; + onApprove: (taskId: string) => Promise; + onReject: (taskId: string, reason?: string) => Promise; } -export function TaskDetailDialog({ task, onClose }: TaskDetailDialogProps) { +export function TaskDetailDialog({ task, onClose, onApprove, onReject }: TaskDetailDialogProps) { const { t } = useTranslation("teams"); + const [reason, setReason] = useState(""); + const [acting, setActing] = useState(false); + + const handleApprove = async () => { + setActing(true); + try { + await onApprove(task.id); + onClose(); + } finally { + setActing(false); + } + }; + + const handleReject = async () => { + setActing(true); + try { + await onReject(task.id, reason || undefined); + onClose(); + } finally { + setActing(false); + } + }; + + const handleCancel = async () => { + setActing(true); + try { + await onReject(task.id, reason || "Cancelled by user"); + onClose(); + } finally { + setActing(false); + } + }; + + const showActions = isTaskActionable(task.status); return ( onClose()}> @@ -37,7 +76,7 @@ export function TaskDetailDialog({ task, onClose }: TaskDetailDialogProps) {
{t("tasks.detail.status")}{" "} - {task.status.replace("_", " ")} + {t(`tasks.status.${task.status}`, task.status.replace("_", " "))}
@@ -87,6 +126,34 @@ export function TaskDetailDialog({ task, onClose }: TaskDetailDialogProps) {
)} + + {/* Actions */} + {showActions && ( +
+ setReason(e.target.value)} + className="text-base md:text-sm" + /> +
+ {task.status === "pending_approval" ? ( + <> + + + + ) : ( + + )} +
+
+ )}
diff --git a/ui/web/src/pages/teams/task-sections/task-list.tsx b/ui/web/src/pages/teams/task-sections/task-list.tsx index 1e5688ce..23e8a11b 100644 --- a/ui/web/src/pages/teams/task-sections/task-list.tsx +++ b/ui/web/src/pages/teams/task-sections/task-list.tsx @@ -1,17 +1,20 @@ import { useState } from "react"; -import { ClipboardList } from "lucide-react"; +import { ClipboardList, Check, X, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { useTranslation } from "react-i18next"; import type { TeamTaskData } from "@/types/team"; -import { taskStatusBadgeVariant } from "./task-utils"; +import { taskStatusBadgeVariant, isTaskActionable } from "./task-utils"; import { TaskDetailDialog } from "./task-detail-dialog"; interface TaskListProps { tasks: TeamTaskData[]; loading: boolean; + onApprove: (taskId: string) => Promise; + onReject: (taskId: string, reason?: string) => Promise; } -export function TaskList({ tasks, loading }: TaskListProps) { +export function TaskList({ tasks, loading, onApprove, onReject }: TaskListProps) { const { t } = useTranslation("teams"); const [selectedTask, setSelectedTask] = useState(null); @@ -38,16 +41,17 @@ export function TaskList({ tasks, loading }: TaskListProps) { return ( <>
-
+
{t("tasks.columns.subject")} {t("tasks.columns.status")} {t("tasks.columns.owner")} {t("tasks.columns.priority")} + {t("tasks.columns.actions")}
{tasks.map((task) => (
setSelectedTask(task)} >
@@ -64,7 +68,7 @@ export function TaskList({ tasks, loading }: TaskListProps) { )}
- {task.status.replace("_", " ")} + {t(`tasks.status.${task.status}`, task.status.replace("_", " "))} {task.owner_agent_key || "—"} @@ -72,6 +76,41 @@ export function TaskList({ tasks, loading }: TaskListProps) { {task.priority} +
e.stopPropagation()}> + {task.status === "pending_approval" && ( + <> + + + + )} + {isTaskActionable(task.status) && task.status !== "pending_approval" && ( + + )} +
))}
@@ -80,6 +119,8 @@ export function TaskList({ tasks, loading }: TaskListProps) { setSelectedTask(null)} + onApprove={onApprove} + onReject={onReject} /> )} diff --git a/ui/web/src/pages/teams/task-sections/task-utils.ts b/ui/web/src/pages/teams/task-sections/task-utils.ts index 3352622c..627a4136 100644 --- a/ui/web/src/pages/teams/task-sections/task-utils.ts +++ b/ui/web/src/pages/teams/task-sections/task-utils.ts @@ -1,9 +1,16 @@ export function taskStatusBadgeVariant(status: string) { switch (status) { case "pending": return "outline" as const; + case "pending_approval": return "default" as const; case "in_progress": return "info" as const; case "completed": return "success" as const; case "blocked": return "warning" as const; + case "failed": return "destructive" as const; default: return "outline" as const; } } + +/** Whether the task can be acted on (approve/reject/cancel) */ +export function isTaskActionable(status: string) { + return status !== "completed" && status !== "failed"; +} diff --git a/ui/web/src/pages/teams/team-detail-page.tsx b/ui/web/src/pages/teams/team-detail-page.tsx index 3e2a334f..4c8f3ec4 100644 --- a/ui/web/src/pages/teams/team-detail-page.tsx +++ b/ui/web/src/pages/teams/team-detail-page.tsx @@ -20,7 +20,7 @@ interface TeamDetailPageProps { export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) { const { t } = useTranslation("teams"); - const { getTeam, getTeamTasks, addMember, removeMember, deleteTeam } = useTeams(); + const { getTeam, getTeamTasks, approveTask, rejectTask, getKnownUsers, addMember, removeMember, deleteTeam } = useTeams(); const [team, setTeam] = useState(null); const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); @@ -134,7 +134,13 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) { - + diff --git a/ui/web/src/pages/teams/team-tasks-tab.tsx b/ui/web/src/pages/teams/team-tasks-tab.tsx index 00d21f2e..f6b79465 100644 --- a/ui/web/src/pages/teams/team-tasks-tab.tsx +++ b/ui/web/src/pages/teams/team-tasks-tab.tsx @@ -3,44 +3,128 @@ import { Button } from "@/components/ui/button"; import { RefreshCw } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useMinLoading } from "@/hooks/use-min-loading"; +import { toast } from "@/stores/use-toast-store"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import type { TeamTaskData } from "@/types/team"; import { TaskList } from "./task-sections"; interface TeamTasksTabProps { teamId: string; - getTeamTasks: (teamId: string) => Promise<{ tasks: TeamTaskData[]; count: number }>; + getTeamTasks: (teamId: string, statusFilter?: string, userId?: string) => Promise<{ tasks: TeamTaskData[]; count: number }>; + getKnownUsers: (teamId: string) => Promise; + onApprove: (taskId: string) => Promise; + onReject: (taskId: string, reason?: string) => Promise; } -export function TeamTasksTab({ teamId, getTeamTasks }: TeamTasksTabProps) { +const STATUS_FILTERS = ["all", "active", "completed"] as const; + +export function TeamTasksTab({ teamId, getTeamTasks, getKnownUsers, onApprove, onReject }: TeamTasksTabProps) { const { t } = useTranslation("teams"); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const spinning = useMinLoading(loading); + const [statusFilter, setStatusFilter] = useState("all"); + const [userFilter, setUserFilter] = useState(""); + const [knownUsers, setKnownUsers] = useState([]); + + // Load known users once + useEffect(() => { + getKnownUsers(teamId).then(setKnownUsers).catch(() => {}); + }, [teamId, getKnownUsers]); + const load = useCallback(async () => { setLoading(true); try { - const res = await getTeamTasks(teamId); + const res = await getTeamTasks(teamId, statusFilter, userFilter || undefined); setTasks(res.tasks ?? []); } catch { // ignore } finally { setLoading(false); } - }, [teamId, getTeamTasks]); + }, [teamId, getTeamTasks, statusFilter, userFilter]); useEffect(() => { load(); }, [load]); + const handleApprove = useCallback(async (taskId: string) => { + try { + await onApprove(taskId); + toast.success(t("toast.taskApproved")); + load(); + } catch { + toast.error(t("toast.failedApproveTask")); + } + }, [onApprove, load, t]); + + const handleReject = useCallback(async (taskId: string, reason?: string) => { + try { + await onReject(taskId, reason); + toast.success(t("toast.taskCancelled")); + load(); + } catch { + toast.error(t("toast.failedCancelTask")); + } + }, [onReject, load, t]); + return (
-
- + {/* Filter bar */} +
+ {/* Status filter toggle buttons */} +
+ {STATUS_FILTERS.map((f) => ( + + ))} +
+ + {/* User filter dropdown */} + {knownUsers.length > 0 && ( + + )} + +
+ +
- + +
); } diff --git a/ui/web/src/types/team.ts b/ui/web/src/types/team.ts index bfc8cd97..8f385a2d 100644 --- a/ui/web/src/types/team.ts +++ b/ui/web/src/types/team.ts @@ -36,12 +36,13 @@ export interface TeamTaskData { team_id: string; subject: string; description?: string; - status: "pending" | "in_progress" | "completed" | "blocked"; + status: "pending" | "pending_approval" | "in_progress" | "completed" | "blocked" | "failed"; owner_agent_id?: string; owner_agent_key?: string; blocked_by?: string[]; priority: number; result?: string; + user_id?: string; created_at?: string; updated_at?: string; }