feat: Implement task approval, rejection, and cancellation with new statuses and task filtering capabilities.

This commit is contained in:
viettranx committed 2026-03-13 10:54:00 +07:00
1 parent 94e7aac3ee
commit 2308b2553c
13 files changed
+344 -30

No files matched your search

+2 -1
View File
@@ -42,4 +42,5 @@ k8s-*/*
# Test credentials (never commit)
tests/**/creds.json
ui/simple-saas
ui/simple-saas
*.tar
+9 -2
View File
@@ -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
+2
View File
@@ -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",
+29 -1
View File
@@ -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"
}
}
+29 -1
View File
@@ -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ụ"
}
}
+29 -1
View File
@@ -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": "取消任务失败"
}
}
+17 -3
View File
@@ -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 };
}
@@ -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<void>;
onReject: (taskId: string, reason?: string) => Promise<void>;
}
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 (
<Dialog open onOpenChange={() => onClose()}>
@@ -37,7 +76,7 @@ export function TaskDetailDialog({ task, onClose }: TaskDetailDialogProps) {
<div>
<span className="text-muted-foreground">{t("tasks.detail.status")}</span>{" "}
<Badge variant={taskStatusBadgeVariant(task.status)} className="text-xs">
{task.status.replace("_", " ")}
{t(`tasks.status.${task.status}`, task.status.replace("_", " "))}
</Badge>
</div>
<div>
@@ -87,6 +126,34 @@ export function TaskDetailDialog({ task, onClose }: TaskDetailDialogProps) {
</pre>
</div>
)}
{/* Actions */}
{showActions && (
<div className="space-y-3 border-t pt-4">
<Input
placeholder={t("tasks.actions.reasonPlaceholder")}
value={reason}
onChange={(e) => setReason(e.target.value)}
className="text-base md:text-sm"
/>
<div className="flex justify-end gap-2">
{task.status === "pending_approval" ? (
<>
<Button variant="outline" size="sm" onClick={handleReject} disabled={acting}>
{t("tasks.actions.reject")}
</Button>
<Button size="sm" onClick={handleApprove} disabled={acting}>
{t("tasks.actions.approve")}
</Button>
</>
) : (
<Button variant="destructive" size="sm" onClick={handleCancel} disabled={acting}>
{t("tasks.actions.cancel")}
</Button>
)}
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
@@ -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<void>;
onReject: (taskId: string, reason?: string) => Promise<void>;
}
export function TaskList({ tasks, loading }: TaskListProps) {
export function TaskList({ tasks, loading, onApprove, onReject }: TaskListProps) {
const { t } = useTranslation("teams");
const [selectedTask, setSelectedTask] = useState<TeamTaskData | null>(null);
@@ -38,16 +41,17 @@ export function TaskList({ tasks, loading }: TaskListProps) {
return (
<>
<div className="overflow-x-auto rounded-lg border">
<div className="grid min-w-[400px] grid-cols-[1fr_90px_100px_60px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<div className="grid min-w-[500px] grid-cols-[1fr_110px_100px_60px_80px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>{t("tasks.columns.subject")}</span>
<span>{t("tasks.columns.status")}</span>
<span>{t("tasks.columns.owner")}</span>
<span>{t("tasks.columns.priority")}</span>
<span className="text-right">{t("tasks.columns.actions")}</span>
</div>
{tasks.map((task) => (
<div
key={task.id}
className="grid min-w-[400px] cursor-pointer grid-cols-[1fr_90px_100px_60px] items-center gap-2 border-b px-4 py-3 last:border-0 hover:bg-muted/30"
className="grid min-w-[500px] cursor-pointer grid-cols-[1fr_110px_100px_60px_80px] items-center gap-2 border-b px-4 py-3 last:border-0 hover:bg-muted/30"
onClick={() => setSelectedTask(task)}
>
<div className="min-w-0">
@@ -64,7 +68,7 @@ export function TaskList({ tasks, loading }: TaskListProps) {
)}
</div>
<Badge variant={taskStatusBadgeVariant(task.status)}>
{task.status.replace("_", " ")}
{t(`tasks.status.${task.status}`, task.status.replace("_", " "))}
</Badge>
<span className="truncate text-sm text-muted-foreground">
{task.owner_agent_key || "—"}
@@ -72,6 +76,41 @@ export function TaskList({ tasks, loading }: TaskListProps) {
<span className="text-sm text-muted-foreground">
{task.priority}
</span>
<div className="flex justify-end gap-1" onClick={(e) => e.stopPropagation()}>
{task.status === "pending_approval" && (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-emerald-600 hover:text-emerald-700"
title={t("tasks.actions.approve")}
onClick={() => onApprove(task.id)}
>
<Check className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive/80"
title={t("tasks.actions.reject")}
onClick={() => onReject(task.id)}
>
<X className="h-4 w-4" />
</Button>
</>
)}
{isTaskActionable(task.status) && task.status !== "pending_approval" && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive/80"
title={t("tasks.actions.cancel")}
onClick={() => onReject(task.id, "Cancelled by user")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
))}
</div>
@@ -80,6 +119,8 @@ export function TaskList({ tasks, loading }: TaskListProps) {
<TaskDetailDialog
task={selectedTask}
onClose={() => setSelectedTask(null)}
onApprove={onApprove}
onReject={onReject}
/>
)}
</>
@@ -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";
}
+8 -2
View File
@@ -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<TeamData | null>(null);
const [members, setMembers] = useState<TeamMemberData[]>([]);
const [loading, setLoading] = useState(true);
@@ -134,7 +134,13 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
</TabsContent>
<TabsContent value="tasks" className="mt-4">
<TeamTasksTab teamId={teamId} getTeamTasks={getTeamTasks} />
<TeamTasksTab
teamId={teamId}
getTeamTasks={getTeamTasks}
getKnownUsers={getKnownUsers}
onApprove={approveTask}
onReject={rejectTask}
/>
</TabsContent>
<TabsContent value="delegations" className="mt-4">
+93 -9
View File
@@ -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<string[]>;
onApprove: (taskId: string) => Promise<void>;
onReject: (taskId: string, reason?: string) => Promise<void>;
}
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<TeamTaskData[]>([]);
const [loading, setLoading] = useState(true);
const spinning = useMinLoading(loading);
const [statusFilter, setStatusFilter] = useState<string>("all");
const [userFilter, setUserFilter] = useState<string>("");
const [knownUsers, setKnownUsers] = useState<string[]>([]);
// 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 (
<div className="space-y-4">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={load} disabled={spinning} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {t("tasks.refresh")}
</Button>
{/* Filter bar */}
<div className="flex flex-wrap items-center gap-2">
{/* Status filter toggle buttons */}
<div className="flex rounded-md border">
{STATUS_FILTERS.map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={
"px-3 py-1.5 text-xs font-medium transition-colors first:rounded-l-md last:rounded-r-md" +
(statusFilter === f
? " bg-primary text-primary-foreground"
: " hover:bg-muted")
}
>
{t(`tasks.filter.${f}`)}
</button>
))}
</div>
{/* User filter dropdown */}
{knownUsers.length > 0 && (
<Select value={userFilter} onValueChange={(v) => setUserFilter(v === "_all" ? "" : v)}>
<SelectTrigger className="h-8 w-auto min-w-[140px] text-xs">
<SelectValue placeholder={t("tasks.filter.allUsers")} />
</SelectTrigger>
<SelectContent position="popper" className="max-h-[50vh] sm:max-h-96">
<SelectItem value="_all">{t("tasks.filter.allUsers")}</SelectItem>
{knownUsers.map((u) => (
<SelectItem key={u} value={u}>{u}</SelectItem>
))}
</SelectContent>
</Select>
)}
<div className="ml-auto">
<Button variant="outline" size="sm" onClick={load} disabled={spinning} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {t("tasks.refresh")}
</Button>
</div>
</div>
<TaskList tasks={tasks} loading={loading} />
<TaskList
tasks={tasks}
loading={loading}
onApprove={handleApprove}
onReject={handleReject}
/>
</div>
);
}
+2 -1
View File
@@ -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;
}