diff --git a/client/src/components/mobile/MobileDetailHeader.tsx b/client/src/components/mobile/MobileDetailHeader.tsx new file mode 100644 index 00000000..c1ecd37f --- /dev/null +++ b/client/src/components/mobile/MobileDetailHeader.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { Button } from "../ui/button"; + +type MobileLocationState = { + from?: string; +}; + +type MobileDetailHeaderProps = { + title: string; + subtitle?: string; + status?: ReactNode; + fallbackTo: string; + testId?: string; +}; + +function isSafeMobileSource(value: unknown, currentPath: string) { + return typeof value === "string" && value.startsWith("/mobile") && value !== currentPath; +} + +export function MobileDetailHeader({ title, subtitle, status, fallbackTo, testId }: MobileDetailHeaderProps) { + const location = useLocation(); + const navigate = useNavigate(); + const state = location.state as MobileLocationState | null; + const currentPath = `${location.pathname}${location.search}`; + const from = isSafeMobileSource(state?.from, currentPath) ? state?.from : null; + + const handleBack = () => { + navigate(from ?? fallbackTo, { replace: true }); + }; + + return ( +
+ +
+
{title}
+ {subtitle ?
{subtitle}
: null} +
+ {status ?
{status}
: null} +
+ ); +} diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx index a806bf48..a731d393 100644 --- a/client/src/pages/MobileApprovalsPage.tsx +++ b/client/src/pages/MobileApprovalsPage.tsx @@ -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("pending"); + const [progressTab, setProgressTab] = useState(() => + resolveProgressTab(searchParams.get("progress")), + ); const [entityFilter, setEntityFilter] = useState("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 ( - - 返回 - - ) : null} - /> + {isPushDetail ? ( + + ) : null + } + fallbackTo="/mobile/approvals" + testId="mobile-approvals-detail-header" + /> + ) : ( + + )} {!isOnline && canAccessRemoteApprovals ? (

@@ -916,12 +964,22 @@ export function MobileApprovalsPage() {

-

我发起的

+

我发起的进度

报销、请假、采购进度

{employeeProgressCards.length} 条
+ + + {EMPLOYEE_PROGRESS_TABS.map((item) => ( + + {item.label} + + ))} + + + {employeeProgressLoading ?
加载中...
: null} {employeeProgressError ? (

@@ -934,7 +992,13 @@ export function MobileApprovalsPage() {

{employeeProgressCards.map((item) => ( - +
{item.title}
@@ -960,7 +1024,7 @@ export function MobileApprovalsPage() { setTaskTab(value as MobileTaskTab)}> - 待处理 + 待我审批 已处理 @@ -970,7 +1034,7 @@ export function MobileApprovalsPage() { data-testid="mobile-approvals-search" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} - placeholder="搜索待办" + placeholder="搜索审批" />
@@ -1086,10 +1150,10 @@ export function MobileApprovalsPage() { {taskTab === "completed" ? "最近 30 天暂无符合筛选条件的已处理任务。" : !canAccessRemoteApprovals && isOnline - ? "暂无审批待办;收到企业微信审批消息后可从这里查看。" + ? "暂无待我审批;收到企业微信审批消息后可从这里查看。" : canUseRemote - ? "暂无符合筛选条件的待办任务。" - : "暂无符合筛选条件的已缓存待办。"} + ? "暂无符合筛选条件的审批任务。" + : "暂无符合筛选条件的已缓存审批。"}

) : null} @@ -1324,6 +1388,7 @@ export function MobileApprovalsPage() {
) : null} + {!isPushDetail ? (

离线草稿

@@ -1405,6 +1470,7 @@ export function MobileApprovalsPage() {
))}
+ ) : null}
); } diff --git a/client/src/pages/MobileLeavePage.tsx b/client/src/pages/MobileLeavePage.tsx index 5ae57d1d..97588820 100644 --- a/client/src/pages/MobileLeavePage.tsx +++ b/client/src/pages/MobileLeavePage.tsx @@ -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 = { 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 ( - - 返回 - - } + subtitle={detail?.leaveType?.name ?? "请假"} + fallbackTo={leaveProgressFallback(detail?.status)} + status={detail ? {STATUS_LABEL[detail.status]} : null} + testId="mobile-leave-detail-header" /> {detailQuery.isLoading ?
加载中...
: null} diff --git a/client/src/pages/MobileNotificationsPage.tsx b/client/src/pages/MobileNotificationsPage.tsx index 14775de9..901f6ae8 100644 --- a/client/src/pages/MobileNotificationsPage.tsx +++ b/client/src/pages/MobileNotificationsPage.tsx @@ -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() { >
+
{notificationTypeLabel(item.type)}
{item.title}
{item.body ?
{item.body}
: null}
@@ -202,7 +216,7 @@ export function MobileNotificationsPage() {
{formatTime(item.createdAt)}
-
{targetLink ? "查看详情" : "暂无直达详情"}
+
{targetLink ? "直达详情" : "暂无直达详情"}
); })} diff --git a/client/src/pages/MobilePortalPage.tsx b/client/src/pages/MobilePortalPage.tsx index 2fe800d7..0f01d3f7 100644 --- a/client/src/pages/MobilePortalPage.tsx +++ b/client/src/pages/MobilePortalPage.tsx @@ -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 ( @@ -275,57 +317,85 @@ export function MobilePortalPage() { {canAccessApprovals && pendingApprovalCount > 0 ? (
-
-
待处理
- {pendingApprovalCount} 待批 -
- {workflowSummaryQuery.data ? ( -
-
-
{workflowSummaryQuery.data.pendingTotal}
-
待办
-
-
-
0 ? "font-semibold text-amber-700" : "font-semibold text-zinc-900"}> - {workflowSummaryQuery.data.overdueTotal} -
-
超时
-
-
-
{workflowEntityTypeCount}
-
类型
-
+
+
+
待处理
+
待我审批
- ) : null} -
- {pendingTasks.slice(0, 3).map((task) => { - const entityType = task.workflowInstance?.entityType; - const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId); - return ( - -
-
+
+
+ {pendingApprovalCount} +
+ {workflowSummaryQuery.data?.overdueTotal ? ( +
超时 {workflowSummaryQuery.data.overdueTotal}
+ ) : null} +
+
+ {urgentPendingTask ? ( +
+ {(() => { + const task = urgentPendingTask; + const entityType = task.workflowInstance?.entityType; + const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId); + return ( + +
+
{reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
待处理
{joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])} -
- - ); - })} -
+
+ + ); + })()} +
+ ) : null}
) : null} + {canUseFinance ? ( +
+
+
财务
+ 复核与付款 +
+ {portalHomeQuery.isLoading ?

读取中...

: null} + {portalHomeQuery.isError ? ( +

+ {resolveErrorMessage(portalHomeQuery.error)} +

+ ) : null} +
+
+
待付款
+
{financeSummary?.pendingBatchCount ?? 0}
+
+
+
异常
+
+ {(financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0)} +
+
+
+
台账/导出
+
可用
+
+
+
+ ) : null} +
常用
@@ -402,17 +472,22 @@ export function MobilePortalPage() {
{homeNotifications.map((item) => ( - void openHomeNotification(item)} + data-testid="mobile-home-notification-card" >
-
{item.title}
+
+
{notificationTypeLabel(item.type)}
+
{item.title}
+
{!item.readAt ? 新 : null}
{formatDateTime(item.createdAt)}
- + ))}
diff --git a/client/src/pages/MobilePurchaseRequestDetailPage.tsx b/client/src/pages/MobilePurchaseRequestDetailPage.tsx index fc84b2d8..8d47f8c3 100644 --- a/client/src/pages/MobilePurchaseRequestDetailPage.tsx +++ b/client/src/pages/MobilePurchaseRequestDetailPage.tsx @@ -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 ( - : null} - actions={ - - } + : null} + fallbackTo={purchaseProgressFallback(detail?.status)} + testId="mobile-purchase-detail-header" /> {!purchaseRequestId ? ( diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx index 5ee14429..e18686dc 100644 --- a/client/src/pages/MobileReimbursementDetailPage.tsx +++ b/client/src/pages/MobileReimbursementDetailPage.tsx @@ -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 = { 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" > -
- -
-
{displayCode}
-
报销详情
-
- - {STATUS_LABEL[detail.status]} - -
+ + {STATUS_LABEL[detail.status]} + + } + testId="mobile-reimbursement-detail-header" + />
合计金额
@@ -828,49 +832,6 @@ export function MobileReimbursementDetailPage() { {detail.description ?

{detail.description}

: null}
- {detail.status === "reviewing" || detail.approvalHistory.length ? ( -
-
-
-

审批

-

- 当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"} -

-
- {canRemindPendingWorkflow && pendingTaskId ? ( - - ) : null} -
- - {detail.approvalHistory.length ? ( -
- {detail.approvalHistory.map((item) => ( -
-
-
{workflowActionLabel(item.action)}
-
{formatDateTime(item.createdAt)}
-
-
- {item.stepName ?? "-"} · {item.actorName || "系统"} -
- {item.comment ?

{item.comment}

: null} -
- ))} -
- ) : ( -

暂无审批记录。

- )} -
- ) : null} -

报销明细

@@ -1139,6 +1100,49 @@ export function MobileReimbursementDetailPage() { )}
+ {detail.status === "reviewing" || detail.approvalHistory.length ? ( +
+
+
+

审批

+

+ 当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"} +

+
+ {canRemindPendingWorkflow && pendingTaskId ? ( + + ) : null} +
+ + {detail.approvalHistory.length ? ( +
+ {detail.approvalHistory.map((item) => ( +
+
+
{workflowActionLabel(item.action)}
+
{formatDateTime(item.createdAt)}
+
+
+ {item.stepName ?? "-"} · {item.actorName || "系统"} +
+ {item.comment ?

{item.comment}

: null} +
+ ))} +
+ ) : ( +

暂无审批记录。

+ )} +
+ ) : null} + {showSecondaryActions ? (

操作

diff --git a/client/src/pages/MobileReimbursementsPage.tsx b/client/src/pages/MobileReimbursementsPage.tsx index 2bb763b1..7e42d87e 100644 --- a/client/src/pages/MobileReimbursementsPage.tsx +++ b/client/src/pages/MobileReimbursementsPage.tsx @@ -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 = { 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({
{canDeleteDraft ? (