修正企微主办边界与工作台摘要(portal-task-first-redesign)
这个提交包含在:
@@ -28,6 +28,7 @@ type WorkItem = {
|
||||
severity: "normal" | "warning" | "high";
|
||||
icon: string;
|
||||
priority: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type EmptyAction = {
|
||||
@@ -107,6 +108,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: "warning",
|
||||
icon: "fact_check",
|
||||
priority: 100,
|
||||
count: payload.pendingTasks.count,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,6 +127,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: highPriorityCount > 0 ? "high" : "normal",
|
||||
icon: "check_circle",
|
||||
priority: 90,
|
||||
count: openTodoCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,6 +142,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: payload.weeklyReport.needsHelp ? "high" : "warning",
|
||||
icon: "article",
|
||||
priority: 72,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,6 +158,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: "high",
|
||||
icon: "priority_high",
|
||||
priority: 96,
|
||||
count: payload.financeSummary.failedItemCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,6 +173,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: "high",
|
||||
icon: "shield_person",
|
||||
priority: 94,
|
||||
count: payload.financeSummary.riskCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -182,6 +188,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: "warning",
|
||||
icon: "payments",
|
||||
priority: 86,
|
||||
count: payload.financeSummary.pendingBatchCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -197,6 +204,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: "warning",
|
||||
icon: "workspaces",
|
||||
priority: 64,
|
||||
count: payload.projectSummary.highPriorityTaskCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,6 +219,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: "warning",
|
||||
icon: "event_upcoming",
|
||||
priority: 62,
|
||||
count: payload.projectSummary.followUpDueTodayCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -231,6 +240,7 @@ function buildProgressItems(payload: PortalHomePayload) {
|
||||
severity: "normal",
|
||||
icon: "done_all",
|
||||
priority: 30,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -255,6 +265,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
|
||||
severity: "warning",
|
||||
icon: "article",
|
||||
priority: 78,
|
||||
count: payload.weeklySummary?.missingCount ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -269,6 +280,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
|
||||
severity: "high",
|
||||
icon: "support_agent",
|
||||
priority: 82,
|
||||
count: payload.weeklySummary?.needsHelpCount ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -283,6 +295,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
|
||||
severity: "warning",
|
||||
icon: "groups",
|
||||
priority: 58,
|
||||
count: payload.teamPresence?.unspecified ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -297,6 +310,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
|
||||
severity: "warning",
|
||||
icon: "flag",
|
||||
priority: 74,
|
||||
count: payload.projectSummary?.overdueMilestonesCount ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -311,6 +325,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
|
||||
severity: "high",
|
||||
icon: "warning",
|
||||
priority: 76,
|
||||
count: payload.projectSummary?.openRiskCount ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -325,6 +340,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
|
||||
severity: "warning",
|
||||
icon: "inventory_2",
|
||||
priority: 60,
|
||||
count: (payload.peopleLifecycle?.offboardingCount ?? 0) + (payload.peopleLifecycle?.assetReturnPendingCount ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -353,6 +369,7 @@ function buildManagementReminders(
|
||||
severity: "warning",
|
||||
icon: "settings",
|
||||
priority: 88,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -370,6 +387,7 @@ function buildManagementReminders(
|
||||
severity: "warning",
|
||||
icon: "manage_accounts",
|
||||
priority: 52,
|
||||
count: inactiveLoginCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,6 +409,10 @@ function buildEmptyActions(permissions: string[], roleCapabilities: RoleCapabili
|
||||
return actions.slice(0, roleCapabilities.canManageSystem && actions.length > 2 ? 3 : 2);
|
||||
}
|
||||
|
||||
function sumWorkItemCount(items: WorkItem[]) {
|
||||
return items.reduce((total, item) => total + Math.max(1, item.count), 0);
|
||||
}
|
||||
|
||||
function SummaryStrip({
|
||||
userName,
|
||||
actionCount,
|
||||
@@ -561,6 +583,10 @@ export function PortalHomePage() {
|
||||
const managementReminders = buildManagementReminders(payload, roleCapabilities, canSetupWorkspace);
|
||||
const emptyActions = buildEmptyActions(permissions, roleCapabilities);
|
||||
const primaryItem = todayActionItems[0] ?? null;
|
||||
const actionCount = sumWorkItemCount(todayActionItems);
|
||||
const progressCount = sumWorkItemCount(progressItems);
|
||||
const blockerCount = sumWorkItemCount(teamBlockers);
|
||||
const reminderCount = sumWorkItemCount(managementReminders);
|
||||
|
||||
function handleCloseWelcome() {
|
||||
clearWelcomeDialogFlag();
|
||||
@@ -575,10 +601,10 @@ export function PortalHomePage() {
|
||||
|
||||
<SummaryStrip
|
||||
userName={payload.greeting.name}
|
||||
actionCount={todayActionItems.length}
|
||||
progressCount={progressItems.length}
|
||||
blockerCount={teamBlockers.length}
|
||||
reminderCount={managementReminders.length}
|
||||
actionCount={actionCount}
|
||||
progressCount={progressCount}
|
||||
blockerCount={blockerCount}
|
||||
reminderCount={reminderCount}
|
||||
primaryItem={primaryItem}
|
||||
emptyActions={emptyActions}
|
||||
/>
|
||||
|
||||
@@ -587,17 +587,6 @@ export function ReimbursementsPage() {
|
||||
</div>
|
||||
|
||||
<div className="ops-command-bar__actions">
|
||||
{canApproveExpense ? (
|
||||
<Button
|
||||
data-testid="reimbursements-actionable"
|
||||
size="sm"
|
||||
variant={status === "reviewing" && scope === "all" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status: "reviewing", page: "1", scope: "all" })}
|
||||
type="button"
|
||||
>
|
||||
待我处理
|
||||
</Button>
|
||||
) : null}
|
||||
{canShowLedgerEntry ? (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -698,6 +687,15 @@ export function ReimbursementsPage() {
|
||||
|
||||
{canViewAll ? (
|
||||
<>
|
||||
<Button
|
||||
data-testid="reimbursements-reviewing-ledger"
|
||||
size="sm"
|
||||
variant={status === "reviewing" && scope === "all" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status: "reviewing", page: "1", scope: "all" })}
|
||||
type="button"
|
||||
>
|
||||
审批中台账
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="reimbursements-scope-own"
|
||||
size="sm"
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import {
|
||||
approveWorkflowTask,
|
||||
fetchWorkflowTasks,
|
||||
fetchWorkflowTaskSummary,
|
||||
type WorkflowTaskSummary,
|
||||
} from "../api/workflow";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import {
|
||||
@@ -28,7 +25,6 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { formatDisplayDateTime } from "../lib/displayFormat";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { getWorkflowConfirmCopy } from "../lib/workflowActionConfirm";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const WORKFLOW_DEFAULT_DUE_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -191,18 +187,6 @@ function isTaskDefaultOverdue(task: WorkflowTaskSummary) {
|
||||
return task.status === "pending" && !isWithinDefaultDueWindow(task.createdAt);
|
||||
}
|
||||
|
||||
function parseDurationDays(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const match = /([0-9]+(?:\.[0-9]+)?)/.exec(value);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const days = Number(match[1]);
|
||||
return Number.isFinite(days) ? days : null;
|
||||
}
|
||||
|
||||
function workflowTaskStatusLabel(status: string) {
|
||||
const match = WORKFLOW_STATUS_OPTIONS.find((item) => item.value === status);
|
||||
return match?.label ?? status;
|
||||
@@ -221,37 +205,12 @@ function taskSecondaryMeta(task: WorkflowTaskSummary) {
|
||||
return [formatReceivedTime(task.createdAt), displayReference(task)].join(" · ");
|
||||
}
|
||||
|
||||
function isLowRiskQuickApproveTask(task: WorkflowTaskSummary) {
|
||||
if (task.status !== "pending" || !(task.allowedActions ?? []).includes("approve")) {
|
||||
return false;
|
||||
}
|
||||
if (!displayTitle(task) || displayApplicant(task) === "-") {
|
||||
return false;
|
||||
}
|
||||
const entityType = task.workflowInstance?.entityType;
|
||||
if (entityType === "leave") {
|
||||
const days = parseDurationDays(task.businessSummary?.duration);
|
||||
if (days === null || days > 1) {
|
||||
return false;
|
||||
}
|
||||
} else if (entityType !== "attendance_correction") {
|
||||
return false;
|
||||
}
|
||||
return isWithinDefaultDueWindow(task.createdAt);
|
||||
}
|
||||
|
||||
function isAlreadyProcessedTaskError(error: unknown) {
|
||||
return isApiError(error) && error.details?.task === "already_processed";
|
||||
}
|
||||
|
||||
export function WorkflowTasksPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const status = normalizeStatusFilter(searchParams.get("status"));
|
||||
const entityType = normalizeEntityFilter(searchParams.get("entityType"));
|
||||
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||
const [confirmApproveTaskId, setConfirmApproveTaskId] = useState<string | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(() => status !== "pending" || entityType !== "all");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function updateSearchParams(next: {
|
||||
status?: WorkflowStatusFilter;
|
||||
@@ -286,35 +245,8 @@ export function WorkflowTasksPage() {
|
||||
queryFn: () => fetchWorkflowTaskSummary(),
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (taskId: string) => approveWorkflowTask(taskId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
|
||||
]);
|
||||
toast.success("审批已通过");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isAlreadyProcessedTaskError(error)) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] });
|
||||
}
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "审批失败,请稍后重试";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
|
||||
const taskItems = query.data?.items ?? [];
|
||||
const confirmCopy = confirmApproveTaskId ? getWorkflowConfirmCopy("approve") : null;
|
||||
const confirmApproveTask = confirmApproveTaskId
|
||||
? taskItems.find((task) => task.id === confirmApproveTaskId) ?? null
|
||||
: null;
|
||||
const isDefaultTaskView = status === "pending" && entityType === "all";
|
||||
const advancedVisible = showAdvanced || !isDefaultTaskView;
|
||||
const pendingTotal = summaryQuery.data?.pendingTotal ?? "-";
|
||||
@@ -339,7 +271,6 @@ export function WorkflowTasksPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
eyebrow="审批"
|
||||
@@ -485,7 +416,7 @@ export function WorkflowTasksPage() {
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
asChild
|
||||
className={`min-h-11 ${isLowRiskQuickApproveTask(task) ? "" : "col-span-2"}`}
|
||||
className="col-span-2 min-h-11"
|
||||
size="sm"
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
@@ -493,19 +424,6 @@ export function WorkflowTasksPage() {
|
||||
{task.status === "pending" ? "查看处理" : "查看详情"}
|
||||
</Link>
|
||||
</Button>
|
||||
{isLowRiskQuickApproveTask(task) ? (
|
||||
<Button
|
||||
className="min-h-11"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
data-testid={TEST_IDS.workflow.quickApprove}
|
||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||
onClick={() => setConfirmApproveTaskId(task.id)}
|
||||
>
|
||||
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
@@ -567,18 +485,6 @@ export function WorkflowTasksPage() {
|
||||
{task.status === "pending" ? "查看处理" : "查看详情"}
|
||||
</Link>
|
||||
</Button>
|
||||
{isLowRiskQuickApproveTask(task) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
data-testid={TEST_IDS.workflow.quickApprove}
|
||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||
onClick={() => setConfirmApproveTaskId(task.id)}
|
||||
>
|
||||
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -603,36 +509,5 @@ export function WorkflowTasksPage() {
|
||||
testIdPrefix="workflow-tasks"
|
||||
/>
|
||||
</ListWorkspace>
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmCopy)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConfirmApproveTaskId(null);
|
||||
}
|
||||
}}
|
||||
title={confirmCopy?.title ?? ""}
|
||||
description={confirmCopy ? (
|
||||
<>
|
||||
<span>{confirmCopy.description}</span>
|
||||
{confirmApproveTask ? (
|
||||
<span className="mt-2 block rounded-lg bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-600">
|
||||
当前事项:{displayTaskTitle(confirmApproveTask)} · {taskLeadMeta(confirmApproveTask)}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : undefined}
|
||||
confirmLabel={confirmCopy?.confirmLabel}
|
||||
variant={confirmCopy?.variant}
|
||||
testId="workflow-action-confirm-dialog"
|
||||
confirmTestId="workflow-action-confirm-submit"
|
||||
cancelTestId="workflow-action-confirm-cancel"
|
||||
onConfirm={async () => {
|
||||
if (!confirmApproveTaskId) {
|
||||
return;
|
||||
}
|
||||
await approveMutation.mutateAsync(confirmApproveTaskId);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户