MIF7 移动信息架构整改收口

这个提交包含在:
2026-05-13 00:02:21 +08:00
父节点 39a930399f
当前提交 c83dbb7376
共修改 10 个文件,包含 691 行新增和 206 行删除
+92 -26
查看文件
@@ -1,6 +1,6 @@
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave";
@@ -22,6 +22,7 @@ import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -47,6 +48,8 @@ import {
type MobileAction = "approve" | "reject" | "return" | "transfer";
type MobileTaskTab = "pending" | "completed";
type EmployeeProgressTab = "draft" | "reviewing" | "approved" | "returned";
type EmployeeProgressQueryStatus = "draft" | "reviewing" | "approved" | "rejected";
type EntityFilter = "all" | "leave" | "reimbursement" | "purchase_request" | "travel_request";
type ReplayClassification = "failed" | "conflicted";
@@ -83,6 +86,24 @@ const ENTITY_FILTER_OPTIONS: Array<{ value: EntityFilter; label: string }> = [
{ value: "travel_request", label: "差旅" },
];
const EMPLOYEE_PROGRESS_TABS: Array<{ value: EmployeeProgressTab; label: string }> = [
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "returned", label: "退回/驳回" },
];
function resolveProgressTab(value: string | null | undefined): EmployeeProgressTab {
if (value === "draft" || value === "reviewing" || value === "approved" || value === "returned") {
return value;
}
return "reviewing";
}
function progressStatusQuery(tab: EmployeeProgressTab): EmployeeProgressQueryStatus {
return tab === "returned" ? "rejected" : tab;
}
function entityLabel(entityType: string | undefined | null) {
if (entityType === "leave") {
return "请假";
@@ -320,14 +341,18 @@ function toEmployeeProgressCards(input: {
export function MobileApprovalsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const routeParams = useParams();
const { me, permissions, accessToken, offlineAccessMode } = useAuth();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const deepLinkTaskId = routeParams.taskId?.trim() || searchParams.get("taskId")?.trim() || "";
const isPushDetail = Boolean(deepLinkTaskId);
const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId);
const [taskTab, setTaskTab] = useState<MobileTaskTab>("pending");
const [progressTab, setProgressTab] = useState<EmployeeProgressTab>(() =>
resolveProgressTab(searchParams.get("progress")),
);
const [entityFilter, setEntityFilter] = useState<EntityFilter>("all");
const [searchInput, setSearchInput] = useState("");
const deferredSearch = useDeferredValue(searchInput);
@@ -347,29 +372,35 @@ export function MobileApprovalsPage() {
const canAccessRemoteApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
const canUseRemote = isOnline && Boolean(accessToken) && canAccessRemoteApprovals;
const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending";
const currentMobileSource = `${location.pathname}${location.search}`;
const employeeProgressStatus = progressStatusQuery(progressTab);
const reimbursementProgressQuery = useQuery({
enabled: !canAccessRemoteApprovals,
queryKey: ["mobile", "approvals", "progress", "reimbursements"],
queryFn: () => fetchReimbursements({ scope: "own", page: 1, pageSize: 5 }),
queryKey: ["mobile", "approvals", "progress", "reimbursements", employeeProgressStatus],
queryFn: () => fetchReimbursements({ scope: "own", status: employeeProgressStatus, page: 1, pageSize: 8 }),
});
const leaveProgressQuery = useQuery({
enabled: !canAccessRemoteApprovals,
queryKey: ["mobile", "approvals", "progress", "leave"],
queryFn: () => fetchLeaveRequests({ scope: "own", page: 1, pageSize: 5 }),
queryKey: ["mobile", "approvals", "progress", "leave", employeeProgressStatus],
queryFn: () => fetchLeaveRequests({ scope: "own", status: employeeProgressStatus, page: 1, pageSize: 8 }),
});
const purchaseProgressQuery = useQuery({
enabled: !canAccessRemoteApprovals,
queryKey: ["mobile", "approvals", "progress", "purchase"],
queryFn: () => fetchPurchaseRequests({ mine: true, page: 1, pageSize: 5 }),
queryKey: ["mobile", "approvals", "progress", "purchase", employeeProgressStatus],
queryFn: () => fetchPurchaseRequests({ mine: true, status: employeeProgressStatus, page: 1, pageSize: 8 }),
});
useEffect(() => {
setSelectedTaskId(deepLinkTaskId);
}, [deepLinkTaskId]);
useEffect(() => {
setProgressTab(resolveProgressTab(searchParams.get("progress")));
}, [searchParams]);
useEffect(() => {
if (taskTab === "completed") {
setBatchMode(false);
@@ -379,7 +410,15 @@ export function MobileApprovalsPage() {
}, [taskTab]);
function handleTaskSelection(taskId: string) {
navigate(`/mobile/approvals/${encodeURIComponent(taskId)}`);
navigate(`/mobile/approvals/${encodeURIComponent(taskId)}`, { state: { from: currentMobileSource } });
}
function handleProgressTabChange(value: string) {
const nextTab = value as EmployeeProgressTab;
setProgressTab(nextTab);
const nextParams = new URLSearchParams(searchParams);
nextParams.set("progress", nextTab);
setSearchParams(nextParams, { replace: true });
}
async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) {
@@ -882,21 +921,30 @@ export function MobileApprovalsPage() {
});
}
const listTitle = taskTab === "pending" ? "待办任务" : "已处理任务";
const listTitle = taskTab === "pending" ? "待我审批" : "已处理";
const allVisibleSelected =
filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id));
return (
<PageScaffold className="space-y-4">
<PageHeader
title={isPushDetail ? "审批详情" : canAccessRemoteApprovals ? "审批" : "审批进度"}
subtitle={isPushDetail ? "查看并处理指定待办。" : canAccessRemoteApprovals ? "待我审批和已处理。" : "查看我发起的申请进度。"}
actions={isPushDetail ? (
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/mobile/approvals">返回</Link>
</Button>
) : null}
/>
{isPushDetail ? (
<MobileDetailHeader
title="审批详情"
subtitle={selectedDetailTitle || "查看审批任务"}
status={
selectedTask ? (
<StatusBadge status={selectedTask.task.status} label={workflowStatusLabel(selectedTask.task.status)} />
) : null
}
fallbackTo="/mobile/approvals"
testId="mobile-approvals-detail-header"
/>
) : (
<PageHeader
title={canAccessRemoteApprovals ? "审批" : "审批进度"}
subtitle={canAccessRemoteApprovals ? "待我审批和已处理。" : "查看我发起的申请进度。"}
/>
)}
{!isOnline && canAccessRemoteApprovals ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
@@ -916,12 +964,22 @@ export function MobileApprovalsPage() {
<section className="mobile-card space-y-3" data-testid="mobile-approvals-progress">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">我发起的</h3>
<h3 className="text-base font-semibold text-zinc-900">我发起的进度</h3>
<p className="mt-1 text-sm text-zinc-500">报销、请假、采购进度</p>
</div>
<span className="badge-neutral">{employeeProgressCards.length} 条</span>
</div>
<Tabs value={progressTab} onValueChange={handleProgressTabChange}>
<TabsList className="grid h-auto w-full grid-cols-4">
{EMPLOYEE_PROGRESS_TABS.map((item) => (
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-progress-tab-${item.value}`}>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{employeeProgressLoading ? <div className="mobile-empty-state">加载中...</div> : null}
{employeeProgressError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
@@ -934,7 +992,13 @@ export function MobileApprovalsPage() {
<div className="space-y-2">
{employeeProgressCards.map((item) => (
<Link className="mobile-list-card block" data-testid="mobile-approval-progress-card" key={item.id} to={item.to}>
<Link
className="mobile-list-card block"
data-testid="mobile-approval-progress-card"
key={item.id}
to={item.to}
state={{ from: currentMobileSource }}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{item.title}</div>
@@ -960,7 +1024,7 @@ export function MobileApprovalsPage() {
<Tabs value={taskTab} onValueChange={(value) => setTaskTab(value as MobileTaskTab)}>
<TabsList className="flex w-full justify-start gap-1">
<TabsTrigger value="pending">待处理</TabsTrigger>
<TabsTrigger value="pending">待我审批</TabsTrigger>
<TabsTrigger value="completed" disabled={!canUseRemote}>已处理</TabsTrigger>
</TabsList>
</Tabs>
@@ -970,7 +1034,7 @@ export function MobileApprovalsPage() {
data-testid="mobile-approvals-search"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="搜索待办"
placeholder="搜索审批"
/>
<details className="mobile-subcard bg-zinc-50/70 text-sm">
@@ -1086,10 +1150,10 @@ export function MobileApprovalsPage() {
{taskTab === "completed"
? "最近 30 天暂无符合筛选条件的已处理任务。"
: !canAccessRemoteApprovals && isOnline
? "暂无审批待办;收到企业微信审批消息后可从这里查看。"
? "暂无待我审批;收到企业微信审批消息后可从这里查看。"
: canUseRemote
? "暂无符合筛选条件的待办任务。"
: "暂无符合筛选条件的已缓存待办。"}
? "暂无符合筛选条件的审批任务。"
: "暂无符合筛选条件的已缓存审批。"}
</p>
) : null}
@@ -1324,6 +1388,7 @@ export function MobileApprovalsPage() {
</section>
) : null}
{!isPushDetail ? (
<section className="mobile-card space-y-3" data-testid="mobile-approvals-queue">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">离线草稿</h3>
@@ -1405,6 +1470,7 @@ export function MobileApprovalsPage() {
</div>
))}
</section>
) : null}
</PageScaffold>
);
}
+16 -7
查看文件
@@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -17,6 +17,7 @@ import {
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { Button } from "../components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { PageScaffold } from "../components/patterns/PageScaffold";
@@ -45,6 +46,13 @@ const STATUS_LABEL: Record<LeaveRequestStatus, string> = {
cancelled: "已取消",
};
function leaveProgressFallback(status: LeaveRequestStatus | undefined) {
if (status === "draft") return "/mobile/approvals?progress=draft";
if (status === "approved") return "/mobile/approvals?progress=approved";
if (status === "rejected" || status === "cancelled") return "/mobile/approvals?progress=returned";
return "/mobile/approvals?progress=reviewing";
}
function todayDate() {
return new Date().toISOString().slice(0, 10);
}
@@ -277,15 +285,16 @@ export function MobileLeavePage() {
};
if (leaveRequestId) {
const detail = detailQuery.data;
return (
<PageScaffold className="space-y-3 pb-4">
<PageHeader
<MobileDetailHeader
title="请假详情"
actions={
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/mobile/leave">返回</Link>
</Button>
}
subtitle={detail?.leaveType?.name ?? "请假"}
fallbackTo={leaveProgressFallback(detail?.status)}
status={detail ? <span className="badge-neutral">{STATUS_LABEL[detail.status]}</span> : null}
testId="mobile-leave-detail-header"
/>
{detailQuery.isLoading ? <div className="mobile-card text-sm text-zinc-500">加载中...</div> : null}
+18 -4
查看文件
@@ -1,4 +1,4 @@
import { useNavigate, useSearchParams } from "react-router-dom";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchNotifications, markAllNotificationsRead, markNotificationRead, type NotificationItem } from "../api/notifications";
@@ -16,7 +16,7 @@ const PAGE_SIZE = 20;
const NOTIFICATION_TYPE_OPTIONS = [
{ label: "全部", value: "" },
{ label: "审批待办", value: "workflow.task_assigned,workflow.task_reminded,workflow.task_overdue" },
{ label: "审批事件", value: "workflow.task_assigned,workflow.task_reminded,workflow.task_overdue" },
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" },
{ label: "请假", value: "leave.approved,leave.rejected" },
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
@@ -36,10 +36,23 @@ function formatTime(value: string) {
return value.slice(0, 19).replace("T", " ");
}
function notificationTypeLabel(type: string) {
if (type.startsWith("workflow.")) return "审批事件";
if (type.startsWith("reimbursement.")) return "报销事件";
if (type.startsWith("leave.")) return "请假事件";
if (type.startsWith("purchase_request.")) return "采购事件";
if (type.startsWith("travel_request.")) return "差旅事件";
if (type.startsWith("asset.")) return "资产事件";
if (type.startsWith("inventory_check.")) return "盘点事件";
return "系统事件";
}
export function MobileNotificationsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const currentMobileSource = `${location.pathname}${location.search}`;
const unreadOnlyRaw = searchParams.get("unreadOnly") ?? "0";
const unreadOnly = unreadOnlyRaw === "1" || unreadOnlyRaw === "true";
@@ -84,7 +97,7 @@ export function MobileNotificationsPage() {
if (!item.readAt) {
await markReadMutation.mutateAsync(item.id);
}
navigate(targetLink);
navigate(targetLink, { state: { from: currentMobileSource } });
} catch (error) {
toast.error(resolveErrorMessage(error));
}
@@ -193,6 +206,7 @@ export function MobileNotificationsPage() {
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium text-primary">{notificationTypeLabel(item.type)}</div>
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
{item.body ? <div className="mt-1 text-xs leading-5 text-zinc-500">{item.body}</div> : null}
</div>
@@ -202,7 +216,7 @@ export function MobileNotificationsPage() {
</div>
<div className="mt-3 text-xs text-zinc-500">{formatTime(item.createdAt)}</div>
<div className="mt-3 text-xs text-primary">{targetLink ? "查看详情" : "暂无直达详情"}</div>
<div className="mt-3 text-xs text-primary">{targetLink ? "直达详情" : "暂无直达详情"}</div>
</button>
);
})}
+151 -76
查看文件
@@ -1,4 +1,4 @@
import { Link } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -8,11 +8,12 @@ import {
type AttendanceTodayResponse,
} from "../api/attendance";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchNotifications } from "../api/notifications";
import { fetchNotifications, markNotificationRead, type NotificationItem } from "../api/notifications";
import { fetchPortalHome } from "../api/portal";
import { isApiError } from "../api/types";
import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
import { hasAnyPermission, hasBusinessApproverRole, hasFinanceRole } from "../auth/permissions";
import { businessEntityLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
import { resolveMobileNotificationTargetLink } from "../lib/notificationLinks";
import { PageHeader } from "../components/common/PageHeader";
@@ -21,6 +22,7 @@ import { MobileIcon } from "../components/mobile/MobileIcon";
import { Button } from "../components/ui/button";
import {
MOBILE_CAPTURE_PERMISSIONS,
MOBILE_ASSET_SCAN_PERMISSIONS,
MOBILE_LEAVE_PERMISSIONS,
MOBILE_REIMBURSEMENT_PERMISSIONS,
} from "../features/mobile/navigation";
@@ -89,6 +91,16 @@ function formatWorkMinutes(value: number | null | undefined) {
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
}
function notificationTypeLabel(type: string) {
if (type.startsWith("workflow.")) return "审批事件";
if (type.startsWith("reimbursement.")) return "报销事件";
if (type.startsWith("leave.")) return "请假事件";
if (type.startsWith("purchase_request.")) return "采购事件";
if (type.startsWith("travel_request.")) return "差旅事件";
if (type.startsWith("asset.")) return "资产事件";
return "系统事件";
}
function attendanceExemptionLabel(reason: unknown) {
if (reason === "leave") return "今日请假";
if (reason === "travel") return "今日差旅";
@@ -147,11 +159,17 @@ function ActionTile({ action }: { action: MobileAction }) {
export function MobilePortalPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const { me, permissions } = useAuth();
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
const canUseFinance = hasFinanceRole(me?.roleCodes, permissions);
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
const canAccessAssetScan = hasAnyPermission(permissions, [...MOBILE_ASSET_SCAN_PERMISSIONS]);
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
const currentMobileSource = `${location.pathname}${location.search}`;
const attendanceTodayQuery = useQuery({
queryKey: ["mobile", "portal", "attendance", "today"],
@@ -176,6 +194,22 @@ export function MobilePortalPage() {
queryFn: () => fetchNotifications({ page: 1, pageSize: 6 }),
});
const portalHomeQuery = useQuery({
enabled: canUseFinance,
queryKey: ["mobile", "portal", "home"],
queryFn: () => fetchPortalHome(),
});
const markHomeNotificationReadMutation = useMutation({
mutationFn: (notificationId: string) => markNotificationRead(notificationId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mobile", "portal", "notifications"] }),
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
]);
},
});
const attendanceClockInMutation = useMutation({
mutationFn: () => clockInAttendance(),
onSuccess: async (response) => {
@@ -212,51 +246,51 @@ export function MobilePortalPage() {
const pendingApprovalCount = canAccessApprovals
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
: 0;
const workflowEntityTypeCount = workflowSummaryQuery.data?.byEntityType?.length ?? 0;
const pendingTasks = pendingCountQuery.data?.items ?? [];
const urgentPendingTask = pendingTasks[0] ?? null;
const notifications = notificationsQuery.data?.items ?? [];
const homeNotifications = notifications
.filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_")))
.slice(0, 3);
const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
const financeSummary = portalHomeQuery.data?.financeSummary ?? null;
const primaryActions = [
canAccessReimbursements
async function openHomeNotification(item: NotificationItem) {
const targetLink = resolveMobileNotificationTargetLink(item);
if (!targetLink) {
navigate("/mobile/notifications", { state: { from: currentMobileSource } });
return;
}
try {
if (!item.readAt) {
await markHomeNotificationReadMutation.mutateAsync(item.id);
}
navigate(targetLink, { state: { from: currentMobileSource } });
} catch (error) {
toast.error(resolveErrorMessage(error));
}
}
const actions = [
canCreateExpense && canAccessReimbursements
? {
key: "reimbursements",
label: "报销",
meta: "新建与进度",
key: "new-reimbursement",
label: "新建报销",
meta: "填写明细",
icon: "request_quote",
to: "/mobile/reimbursements",
to: "/mobile/reimbursements?intent=create",
tone: "primary",
}
: null,
canAccessCapture
? {
key: "capture",
label: "传票据",
label: "上传票据",
meta: "上传 PDF",
icon: "receipt_long",
to: "/mobile/capture",
tone: "primary",
}
: null,
{
key: "approvals",
label: canAccessApprovals ? "审批" : "进度",
meta: canAccessApprovals ? "待办与已处理" : "我的申请进度",
icon: "fact_check",
to: "/mobile/approvals",
tone: "primary",
},
{
key: "me",
label: "我的",
meta: unreadNotificationCount > 0 ? `${unreadNotificationCount} 条新通知` : "资料和通知",
icon: "person",
to: "/mobile/me",
},
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
const secondaryActions = [
canAccessLeave
? {
key: "leave",
@@ -266,8 +300,16 @@ export function MobilePortalPage() {
to: "/mobile/leave",
}
: null,
].filter((item): item is MobileAction => Boolean(item));
const actions = [...primaryActions, ...secondaryActions].slice(0, 4);
canAccessAssetScan
? {
key: "asset-scan",
label: "资产扫码",
meta: "扫码盘点",
icon: "qr_code_scanner",
to: "/mobile/assets/scan",
}
: null,
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
return (
<PageScaffold className="space-y-4">
@@ -275,57 +317,85 @@ export function MobilePortalPage() {
{canAccessApprovals && pendingApprovalCount > 0 ? (
<section className="mobile-card" data-testid="mobile-pending-summary">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">待处理</div>
<span className="badge-warning">{pendingApprovalCount} 待批</span>
</div>
{workflowSummaryQuery.data ? (
<div className="mb-3 grid grid-cols-3 gap-2 text-center text-xs">
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className="font-semibold text-zinc-900">{workflowSummaryQuery.data.pendingTotal}</div>
<div className="mt-1 text-zinc-500">待办</div>
</div>
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className={workflowSummaryQuery.data.overdueTotal > 0 ? "font-semibold text-amber-700" : "font-semibold text-zinc-900"}>
{workflowSummaryQuery.data.overdueTotal}
</div>
<div className="mt-1 text-zinc-500">超时</div>
</div>
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className="font-semibold text-zinc-900">{workflowEntityTypeCount}</div>
<div className="mt-1 text-zinc-500">类型</div>
</div>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-zinc-900">待处理</div>
<div className="mt-1 text-xs text-zinc-500">待我审批</div>
</div>
) : null}
<div className="space-y-2">
{pendingTasks.slice(0, 3).map((task) => {
const entityType = task.workflowInstance?.entityType;
const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId);
return (
<Link
key={task.id}
to={`/mobile/approvals/${encodeURIComponent(task.id)}`}
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 truncate font-medium text-zinc-900">
<div className="text-right">
<div className="text-3xl font-semibold leading-none text-zinc-900" data-testid="mobile-pending-summary-count">
{pendingApprovalCount}
</div>
{workflowSummaryQuery.data?.overdueTotal ? (
<div className="mt-1 text-xs font-medium text-amber-700">超时 {workflowSummaryQuery.data.overdueTotal}</div>
) : null}
</div>
</div>
{urgentPendingTask ? (
<div className="space-y-2">
{(() => {
const task = urgentPendingTask;
const entityType = task.workflowInstance?.entityType;
const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId);
return (
<Link
key={task.id}
to={`/mobile/approvals/${encodeURIComponent(task.id)}`}
state={{ from: currentMobileSource }}
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
data-testid="mobile-pending-summary-primary"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 truncate font-medium text-zinc-900">
{reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
</div>
<span className="badge-warning shrink-0">待处理</span>
</div>
<div className="mt-1 truncate text-xs text-zinc-500">
{joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
</div>
</Link>
);
})}
</div>
</div>
</Link>
);
})()}
</div>
) : null}
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
<Link to="/mobile/approvals">进入审批</Link>
<Link to="/mobile/approvals">查看全部待办</Link>
</Button>
</section>
) : null}
{canUseFinance ? (
<section className="mobile-card" data-testid="mobile-finance-summary">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">财务</div>
<span className="badge-neutral">复核与付款</span>
</div>
{portalHomeQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
{portalHomeQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(portalHomeQuery.error)}
</p>
) : null}
<div className="mobile-metric-grid text-sm">
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">待付款</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{financeSummary?.pendingBatchCount ?? 0}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">异常</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">
{(financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0)}
</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">台账/导出</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">可用</div>
</div>
</div>
</section>
) : null}
<section className="mobile-card" data-testid="mobile-today-focus">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">常用</div>
@@ -402,17 +472,22 @@ export function MobilePortalPage() {
<div className="space-y-2">
{homeNotifications.map((item) => (
<Link
<button
key={item.id}
to={resolveMobileNotificationTargetLink(item) ?? "/mobile/notifications"}
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
type="button"
className="block w-full rounded-lg border border-zinc-200 px-3 py-2 text-left text-sm"
onClick={() => void openHomeNotification(item)}
data-testid="mobile-home-notification-card"
>
<div className="flex items-center justify-between gap-3">
<div className="truncate font-medium text-zinc-900">{item.title}</div>
<div className="min-w-0">
<div className="text-xs text-zinc-500">{notificationTypeLabel(item.type)}</div>
<div className="mt-0.5 truncate font-medium text-zinc-900">{item.title}</div>
</div>
{!item.readAt ? <span className="badge-warning shrink-0">新</span> : null}
</div>
<div className="mt-1 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
</Link>
</button>
))}
</div>
</section>
@@ -1,10 +1,9 @@
import { Link, useParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchPurchaseRequestDetail } from "../api/purchaseRequests";
import { PageHeader } from "../components/common/PageHeader";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import {
formatApprovalHistoryTime,
resolvePurchaseDisplayCode,
@@ -36,6 +35,13 @@ function formatBytes(value: number) {
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function purchaseProgressFallback(status: string | undefined) {
if (status === "draft") return "/mobile/approvals?progress=draft";
if (status === "approved") return "/mobile/approvals?progress=approved";
if (status === "rejected" || status === "cancelled") return "/mobile/approvals?progress=returned";
return "/mobile/approvals?progress=reviewing";
}
export function MobilePurchaseRequestDetailPage() {
const { purchaseRequestId = "" } = useParams();
@@ -50,14 +56,12 @@ export function MobilePurchaseRequestDetailPage() {
return (
<PageScaffold className="space-y-3 pb-4">
<PageHeader
title="采购详情"
meta={detail ? <StatusBadge status={detail.status} /> : null}
actions={
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/mobile/approvals">返回</Link>
</Button>
}
<MobileDetailHeader
title={displayCode}
subtitle="采购详情"
status={detail ? <StatusBadge status={detail.status} /> : null}
fallbackTo={purchaseProgressFallback(detail?.status)}
testId="mobile-purchase-detail-header"
/>
{!purchaseRequestId ? (
@@ -33,6 +33,7 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import {
@@ -92,6 +93,10 @@ const STATUS_TONE: Record<ReimbursementStatus, string> = {
cancelled: "bg-zinc-100 text-zinc-500",
};
function reimbursementFallback(status: ReimbursementStatus) {
return `/mobile/reimbursements?status=${encodeURIComponent(status)}`;
}
function todayDate() {
return new Date().toISOString().slice(0, 10);
}
@@ -795,18 +800,17 @@ export function MobileReimbursementDetailPage() {
)}
data-testid="mobile-reimbursement-detail"
>
<header className="flex items-center gap-3" data-testid="mobile-reimbursement-detail">
<Button asChild size="sm" variant="secondary">
<Link to="/mobile/reimbursements">返回</Link>
</Button>
<div className="min-w-0 flex-1">
<div className="truncate text-base font-semibold text-zinc-900">{displayCode}</div>
<div className="text-xs text-zinc-500">报销详情</div>
</div>
<span className={cn("shrink-0 rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>
{STATUS_LABEL[detail.status]}
</span>
</header>
<MobileDetailHeader
title={displayCode}
subtitle="报销详情"
fallbackTo={reimbursementFallback(detail.status)}
status={
<span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>
{STATUS_LABEL[detail.status]}
</span>
}
testId="mobile-reimbursement-detail-header"
/>
<section className="mobile-card">
<div className="text-xs text-zinc-500">合计金额</div>
@@ -828,49 +832,6 @@ export function MobileReimbursementDetailPage() {
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
</section>
{detail.status === "reviewing" || detail.approvalHistory.length ? (
<section className="mobile-card" data-testid="mobile-reimbursement-approval-history">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">审批</h2>
<p className="mt-1 text-xs text-zinc-500">
当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"}
</p>
</div>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
>
{remindMutation.isPending ? "催办中..." : "催办"}
</Button>
) : null}
</div>
{detail.approvalHistory.length ? (
<div className="mt-3 space-y-2">
{detail.approvalHistory.map((item) => (
<article key={item.id} className="mobile-list-card">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-zinc-900">{workflowActionLabel(item.action)}</div>
<div className="shrink-0 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
</div>
{item.comment ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.comment}</p> : null}
</article>
))}
</div>
) : (
<p className="mt-3 text-sm text-zinc-500">暂无审批记录。</p>
)}
</section>
) : null}
<section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
@@ -1139,6 +1100,49 @@ export function MobileReimbursementDetailPage() {
)}
</section>
{detail.status === "reviewing" || detail.approvalHistory.length ? (
<section className="mobile-card" data-testid="mobile-reimbursement-approval-history">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">审批</h2>
<p className="mt-1 text-xs text-zinc-500">
当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"}
</p>
</div>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
>
{remindMutation.isPending ? "催办中..." : "催办"}
</Button>
) : null}
</div>
{detail.approvalHistory.length ? (
<div className="mt-3 space-y-2">
{detail.approvalHistory.map((item) => (
<article key={item.id} className="mobile-list-card">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-zinc-900">{workflowActionLabel(item.action)}</div>
<div className="shrink-0 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
</div>
{item.comment ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.comment}</p> : null}
</article>
))}
</div>
) : (
<p className="mt-3 text-sm text-zinc-500">暂无审批记录。</p>
)}
</section>
) : null}
{showSecondaryActions ? (
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
+40 -5
查看文件
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -35,6 +35,10 @@ const STATUS_LABEL: Record<ReimbursementStatus, string> = {
cancelled: "已取消",
};
function isReimbursementStatus(value: string | null): value is ReimbursementStatus {
return ["draft", "reviewing", "approved", "paid", "rejected", "cancelled"].includes(value ?? "");
}
function formatCurrency(value: string | null | undefined) {
const numberValue = Number(value ?? 0);
if (!Number.isFinite(numberValue)) {
@@ -53,11 +57,13 @@ function formatDate(value: string | null | undefined) {
function ReimbursementCard({
item,
canDeleteDraft,
currentMobileSource,
isDeleting,
onDelete,
}: {
item: ReimbursementListItem;
canDeleteDraft: boolean;
currentMobileSource: string;
isDeleting: boolean;
onDelete: (item: ReimbursementListItem) => void;
}) {
@@ -89,7 +95,9 @@ function ReimbursementCard({
<div className={canDeleteDraft ? "mobile-two-action-grid mt-4" : "mt-4"}>
<Button asChild className="w-full" type="button">
<Link to={`/mobile/reimbursements/${item.id}`}>{actionLabel}</Link>
<Link to={`/mobile/reimbursements/${item.id}`} state={{ from: currentMobileSource }}>
{actionLabel}
</Link>
</Button>
{canDeleteDraft ? (
<Button
@@ -110,12 +118,19 @@ function ReimbursementCard({
export function MobileReimbursementsPage() {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const { permissions } = useAuth();
const [status, setStatus] = useState<ReimbursementStatus>("draft");
const [status, setStatus] = useState<ReimbursementStatus>(() => {
const queryStatus = searchParams.get("status");
return isReimbursementStatus(queryStatus) ? queryStatus : "draft";
});
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
const createIntentHandledRef = useRef(false);
const canCreateExpense = hasPermission(permissions, "expense.create");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const currentMobileSource = `${location.pathname}${location.search}`;
const listQuery = useQuery({
queryKey: ["mobile", "reimbursements", status],
@@ -127,13 +142,32 @@ export function MobileReimbursementsPage() {
onSuccess: async (response) => {
await queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] });
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
navigate(`/mobile/reimbursements/${response.id}`);
navigate(`/mobile/reimbursements/${response.id}`, { state: { from: currentMobileSource } });
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
useEffect(() => {
const queryStatus = searchParams.get("status");
if (isReimbursementStatus(queryStatus)) {
setStatus(queryStatus);
}
}, [searchParams]);
useEffect(() => {
if (searchParams.get("intent") !== "create" || !canCreateExpense || createIntentHandledRef.current) {
return;
}
createIntentHandledRef.current = true;
const nextParams = new URLSearchParams(searchParams);
nextParams.delete("intent");
setSearchParams(nextParams, { replace: true });
createMutation.mutate();
}, [canCreateExpense, createMutation, searchParams, setSearchParams]);
const deleteMutation = useMutation({
mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId),
onSuccess: async () => {
@@ -211,6 +245,7 @@ export function MobileReimbursementsPage() {
{items.map((item) => (
<ReimbursementCard
canDeleteDraft={canDeleteExpense && item.status === "draft"}
currentMobileSource={currentMobileSource}
isDeleting={deleteMutation.isPending}
item={item}
key={item.id}