MIF7 移动信息架构整改收口
这个提交包含在:
@@ -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 (
|
||||||
|
<header className="mobile-detail-header flex min-h-12 items-center gap-3" data-testid={testId}>
|
||||||
|
<Button className="shrink-0 px-3" size="sm" type="button" variant="secondary" onClick={handleBack}>
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-base font-semibold text-zinc-900">{title}</div>
|
||||||
|
{subtitle ? <div className="mt-0.5 truncate text-xs text-zinc-500">{subtitle}</div> : null}
|
||||||
|
</div>
|
||||||
|
{status ? <div className="shrink-0">{status}</div> : null}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { toast } from "sonner";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave";
|
import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave";
|
||||||
@@ -22,6 +22,7 @@ import { useAuth } from "../auth/AuthContext";
|
|||||||
import { hasAnyPermission } from "../auth/permissions";
|
import { hasAnyPermission } from "../auth/permissions";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { StatusBadge } from "../components/common/StatusBadge";
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
|
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
@@ -47,6 +48,8 @@ import {
|
|||||||
|
|
||||||
type MobileAction = "approve" | "reject" | "return" | "transfer";
|
type MobileAction = "approve" | "reject" | "return" | "transfer";
|
||||||
type MobileTaskTab = "pending" | "completed";
|
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 EntityFilter = "all" | "leave" | "reimbursement" | "purchase_request" | "travel_request";
|
||||||
type ReplayClassification = "failed" | "conflicted";
|
type ReplayClassification = "failed" | "conflicted";
|
||||||
|
|
||||||
@@ -83,6 +86,24 @@ const ENTITY_FILTER_OPTIONS: Array<{ value: EntityFilter; label: string }> = [
|
|||||||
{ value: "travel_request", label: "差旅" },
|
{ 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) {
|
function entityLabel(entityType: string | undefined | null) {
|
||||||
if (entityType === "leave") {
|
if (entityType === "leave") {
|
||||||
return "请假";
|
return "请假";
|
||||||
@@ -320,14 +341,18 @@ function toEmployeeProgressCards(input: {
|
|||||||
export function MobileApprovalsPage() {
|
export function MobileApprovalsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const routeParams = useParams();
|
const routeParams = useParams();
|
||||||
const { me, permissions, accessToken, offlineAccessMode } = useAuth();
|
const { me, permissions, accessToken, offlineAccessMode } = useAuth();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const deepLinkTaskId = routeParams.taskId?.trim() || searchParams.get("taskId")?.trim() || "";
|
const deepLinkTaskId = routeParams.taskId?.trim() || searchParams.get("taskId")?.trim() || "";
|
||||||
const isPushDetail = Boolean(deepLinkTaskId);
|
const isPushDetail = Boolean(deepLinkTaskId);
|
||||||
|
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId);
|
const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId);
|
||||||
const [taskTab, setTaskTab] = useState<MobileTaskTab>("pending");
|
const [taskTab, setTaskTab] = useState<MobileTaskTab>("pending");
|
||||||
|
const [progressTab, setProgressTab] = useState<EmployeeProgressTab>(() =>
|
||||||
|
resolveProgressTab(searchParams.get("progress")),
|
||||||
|
);
|
||||||
const [entityFilter, setEntityFilter] = useState<EntityFilter>("all");
|
const [entityFilter, setEntityFilter] = useState<EntityFilter>("all");
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const deferredSearch = useDeferredValue(searchInput);
|
const deferredSearch = useDeferredValue(searchInput);
|
||||||
@@ -347,29 +372,35 @@ export function MobileApprovalsPage() {
|
|||||||
const canAccessRemoteApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
const canAccessRemoteApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
||||||
const canUseRemote = isOnline && Boolean(accessToken) && canAccessRemoteApprovals;
|
const canUseRemote = isOnline && Boolean(accessToken) && canAccessRemoteApprovals;
|
||||||
const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending";
|
const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending";
|
||||||
|
const currentMobileSource = `${location.pathname}${location.search}`;
|
||||||
|
const employeeProgressStatus = progressStatusQuery(progressTab);
|
||||||
|
|
||||||
const reimbursementProgressQuery = useQuery({
|
const reimbursementProgressQuery = useQuery({
|
||||||
enabled: !canAccessRemoteApprovals,
|
enabled: !canAccessRemoteApprovals,
|
||||||
queryKey: ["mobile", "approvals", "progress", "reimbursements"],
|
queryKey: ["mobile", "approvals", "progress", "reimbursements", employeeProgressStatus],
|
||||||
queryFn: () => fetchReimbursements({ scope: "own", page: 1, pageSize: 5 }),
|
queryFn: () => fetchReimbursements({ scope: "own", status: employeeProgressStatus, page: 1, pageSize: 8 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const leaveProgressQuery = useQuery({
|
const leaveProgressQuery = useQuery({
|
||||||
enabled: !canAccessRemoteApprovals,
|
enabled: !canAccessRemoteApprovals,
|
||||||
queryKey: ["mobile", "approvals", "progress", "leave"],
|
queryKey: ["mobile", "approvals", "progress", "leave", employeeProgressStatus],
|
||||||
queryFn: () => fetchLeaveRequests({ scope: "own", page: 1, pageSize: 5 }),
|
queryFn: () => fetchLeaveRequests({ scope: "own", status: employeeProgressStatus, page: 1, pageSize: 8 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const purchaseProgressQuery = useQuery({
|
const purchaseProgressQuery = useQuery({
|
||||||
enabled: !canAccessRemoteApprovals,
|
enabled: !canAccessRemoteApprovals,
|
||||||
queryKey: ["mobile", "approvals", "progress", "purchase"],
|
queryKey: ["mobile", "approvals", "progress", "purchase", employeeProgressStatus],
|
||||||
queryFn: () => fetchPurchaseRequests({ mine: true, page: 1, pageSize: 5 }),
|
queryFn: () => fetchPurchaseRequests({ mine: true, status: employeeProgressStatus, page: 1, pageSize: 8 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedTaskId(deepLinkTaskId);
|
setSelectedTaskId(deepLinkTaskId);
|
||||||
}, [deepLinkTaskId]);
|
}, [deepLinkTaskId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProgressTab(resolveProgressTab(searchParams.get("progress")));
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (taskTab === "completed") {
|
if (taskTab === "completed") {
|
||||||
setBatchMode(false);
|
setBatchMode(false);
|
||||||
@@ -379,7 +410,15 @@ export function MobileApprovalsPage() {
|
|||||||
}, [taskTab]);
|
}, [taskTab]);
|
||||||
|
|
||||||
function handleTaskSelection(taskId: string) {
|
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) {
|
async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) {
|
||||||
@@ -882,21 +921,30 @@ export function MobileApprovalsPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const listTitle = taskTab === "pending" ? "待办任务" : "已处理任务";
|
const listTitle = taskTab === "pending" ? "待我审批" : "已处理";
|
||||||
const allVisibleSelected =
|
const allVisibleSelected =
|
||||||
filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id));
|
filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScaffold className="space-y-4">
|
<PageScaffold className="space-y-4">
|
||||||
<PageHeader
|
{isPushDetail ? (
|
||||||
title={isPushDetail ? "审批详情" : canAccessRemoteApprovals ? "审批" : "审批进度"}
|
<MobileDetailHeader
|
||||||
subtitle={isPushDetail ? "查看并处理指定待办。" : canAccessRemoteApprovals ? "待我审批和已处理。" : "查看我发起的申请进度。"}
|
title="审批详情"
|
||||||
actions={isPushDetail ? (
|
subtitle={selectedDetailTitle || "查看审批任务"}
|
||||||
<Button asChild size="sm" type="button" variant="secondary">
|
status={
|
||||||
<Link to="/mobile/approvals">返回</Link>
|
selectedTask ? (
|
||||||
</Button>
|
<StatusBadge status={selectedTask.task.status} label={workflowStatusLabel(selectedTask.task.status)} />
|
||||||
) : null}
|
) : null
|
||||||
|
}
|
||||||
|
fallbackTo="/mobile/approvals"
|
||||||
|
testId="mobile-approvals-detail-header"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<PageHeader
|
||||||
|
title={canAccessRemoteApprovals ? "审批" : "审批进度"}
|
||||||
|
subtitle={canAccessRemoteApprovals ? "待我审批和已处理。" : "查看我发起的申请进度。"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isOnline && canAccessRemoteApprovals ? (
|
{!isOnline && canAccessRemoteApprovals ? (
|
||||||
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
|
<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">
|
<section className="mobile-card space-y-3" data-testid="mobile-approvals-progress">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<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>
|
<p className="mt-1 text-sm text-zinc-500">报销、请假、采购进度</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge-neutral">{employeeProgressCards.length} 条</span>
|
<span className="badge-neutral">{employeeProgressCards.length} 条</span>
|
||||||
</div>
|
</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}
|
{employeeProgressLoading ? <div className="mobile-empty-state">加载中...</div> : null}
|
||||||
{employeeProgressError ? (
|
{employeeProgressError ? (
|
||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
<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">
|
<div className="space-y-2">
|
||||||
{employeeProgressCards.map((item) => (
|
{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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold text-zinc-900">{item.title}</div>
|
<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)}>
|
<Tabs value={taskTab} onValueChange={(value) => setTaskTab(value as MobileTaskTab)}>
|
||||||
<TabsList className="flex w-full justify-start gap-1">
|
<TabsList className="flex w-full justify-start gap-1">
|
||||||
<TabsTrigger value="pending">待处理</TabsTrigger>
|
<TabsTrigger value="pending">待我审批</TabsTrigger>
|
||||||
<TabsTrigger value="completed" disabled={!canUseRemote}>已处理</TabsTrigger>
|
<TabsTrigger value="completed" disabled={!canUseRemote}>已处理</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -970,7 +1034,7 @@ export function MobileApprovalsPage() {
|
|||||||
data-testid="mobile-approvals-search"
|
data-testid="mobile-approvals-search"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(event) => setSearchInput(event.target.value)}
|
onChange={(event) => setSearchInput(event.target.value)}
|
||||||
placeholder="搜索待办"
|
placeholder="搜索审批"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<details className="mobile-subcard bg-zinc-50/70 text-sm">
|
<details className="mobile-subcard bg-zinc-50/70 text-sm">
|
||||||
@@ -1086,10 +1150,10 @@ export function MobileApprovalsPage() {
|
|||||||
{taskTab === "completed"
|
{taskTab === "completed"
|
||||||
? "最近 30 天暂无符合筛选条件的已处理任务。"
|
? "最近 30 天暂无符合筛选条件的已处理任务。"
|
||||||
: !canAccessRemoteApprovals && isOnline
|
: !canAccessRemoteApprovals && isOnline
|
||||||
? "暂无审批待办;收到企业微信审批消息后可从这里查看。"
|
? "暂无待我审批;收到企业微信审批消息后可从这里查看。"
|
||||||
: canUseRemote
|
: canUseRemote
|
||||||
? "暂无符合筛选条件的待办任务。"
|
? "暂无符合筛选条件的审批任务。"
|
||||||
: "暂无符合筛选条件的已缓存待办。"}
|
: "暂无符合筛选条件的已缓存审批。"}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -1324,6 +1388,7 @@ export function MobileApprovalsPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{!isPushDetail ? (
|
||||||
<section className="mobile-card space-y-3" data-testid="mobile-approvals-queue">
|
<section className="mobile-card space-y-3" data-testid="mobile-approvals-queue">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">离线草稿</h3>
|
<h3 className="text-base font-semibold text-zinc-900">离线草稿</h3>
|
||||||
@@ -1405,6 +1470,7 @@ export function MobileApprovalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
</PageScaffold>
|
</PageScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
@@ -45,6 +46,13 @@ const STATUS_LABEL: Record<LeaveRequestStatus, string> = {
|
|||||||
cancelled: "已取消",
|
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() {
|
function todayDate() {
|
||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
@@ -277,15 +285,16 @@ export function MobileLeavePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (leaveRequestId) {
|
if (leaveRequestId) {
|
||||||
|
const detail = detailQuery.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScaffold className="space-y-3 pb-4">
|
<PageScaffold className="space-y-3 pb-4">
|
||||||
<PageHeader
|
<MobileDetailHeader
|
||||||
title="请假详情"
|
title="请假详情"
|
||||||
actions={
|
subtitle={detail?.leaveType?.name ?? "请假"}
|
||||||
<Button asChild size="sm" type="button" variant="secondary">
|
fallbackTo={leaveProgressFallback(detail?.status)}
|
||||||
<Link to="/mobile/leave">返回</Link>
|
status={detail ? <span className="badge-neutral">{STATUS_LABEL[detail.status]}</span> : null}
|
||||||
</Button>
|
testId="mobile-leave-detail-header"
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{detailQuery.isLoading ? <div className="mobile-card text-sm text-zinc-500">加载中...</div> : null}
|
{detailQuery.isLoading ? <div className="mobile-card text-sm text-zinc-500">加载中...</div> : null}
|
||||||
|
|||||||
@@ -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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { fetchNotifications, markAllNotificationsRead, markNotificationRead, type NotificationItem } from "../api/notifications";
|
import { fetchNotifications, markAllNotificationsRead, markNotificationRead, type NotificationItem } from "../api/notifications";
|
||||||
@@ -16,7 +16,7 @@ const PAGE_SIZE = 20;
|
|||||||
|
|
||||||
const NOTIFICATION_TYPE_OPTIONS = [
|
const NOTIFICATION_TYPE_OPTIONS = [
|
||||||
{ label: "全部", value: "" },
|
{ 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: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" },
|
||||||
{ label: "请假", value: "leave.approved,leave.rejected" },
|
{ label: "请假", value: "leave.approved,leave.rejected" },
|
||||||
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
|
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
|
||||||
@@ -36,10 +36,23 @@ function formatTime(value: string) {
|
|||||||
return value.slice(0, 19).replace("T", " ");
|
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() {
|
export function MobileNotificationsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const currentMobileSource = `${location.pathname}${location.search}`;
|
||||||
|
|
||||||
const unreadOnlyRaw = searchParams.get("unreadOnly") ?? "0";
|
const unreadOnlyRaw = searchParams.get("unreadOnly") ?? "0";
|
||||||
const unreadOnly = unreadOnlyRaw === "1" || unreadOnlyRaw === "true";
|
const unreadOnly = unreadOnlyRaw === "1" || unreadOnlyRaw === "true";
|
||||||
@@ -84,7 +97,7 @@ export function MobileNotificationsPage() {
|
|||||||
if (!item.readAt) {
|
if (!item.readAt) {
|
||||||
await markReadMutation.mutateAsync(item.id);
|
await markReadMutation.mutateAsync(item.id);
|
||||||
}
|
}
|
||||||
navigate(targetLink);
|
navigate(targetLink, { state: { from: currentMobileSource } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(resolveErrorMessage(error));
|
toast.error(resolveErrorMessage(error));
|
||||||
}
|
}
|
||||||
@@ -193,6 +206,7 @@ export function MobileNotificationsPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<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>
|
<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}
|
{item.body ? <div className="mt-1 text-xs leading-5 text-zinc-500">{item.body}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +216,7 @@ export function MobileNotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 text-xs text-zinc-500">{formatTime(item.createdAt)}</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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -8,11 +8,12 @@ import {
|
|||||||
type AttendanceTodayResponse,
|
type AttendanceTodayResponse,
|
||||||
} from "../api/attendance";
|
} from "../api/attendance";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
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 { isApiError } from "../api/types";
|
||||||
import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow";
|
import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
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 { businessEntityLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
|
||||||
import { resolveMobileNotificationTargetLink } from "../lib/notificationLinks";
|
import { resolveMobileNotificationTargetLink } from "../lib/notificationLinks";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
@@ -21,6 +22,7 @@ import { MobileIcon } from "../components/mobile/MobileIcon";
|
|||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import {
|
import {
|
||||||
MOBILE_CAPTURE_PERMISSIONS,
|
MOBILE_CAPTURE_PERMISSIONS,
|
||||||
|
MOBILE_ASSET_SCAN_PERMISSIONS,
|
||||||
MOBILE_LEAVE_PERMISSIONS,
|
MOBILE_LEAVE_PERMISSIONS,
|
||||||
MOBILE_REIMBURSEMENT_PERMISSIONS,
|
MOBILE_REIMBURSEMENT_PERMISSIONS,
|
||||||
} from "../features/mobile/navigation";
|
} from "../features/mobile/navigation";
|
||||||
@@ -89,6 +91,16 @@ function formatWorkMinutes(value: number | null | undefined) {
|
|||||||
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
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) {
|
function attendanceExemptionLabel(reason: unknown) {
|
||||||
if (reason === "leave") return "今日请假";
|
if (reason === "leave") return "今日请假";
|
||||||
if (reason === "travel") return "今日差旅";
|
if (reason === "travel") return "今日差旅";
|
||||||
@@ -147,11 +159,17 @@ function ActionTile({ action }: { action: MobileAction }) {
|
|||||||
|
|
||||||
export function MobilePortalPage() {
|
export function MobilePortalPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { me, permissions } = useAuth();
|
const { me, permissions } = useAuth();
|
||||||
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||||
|
const canUseFinance = hasFinanceRole(me?.roleCodes, permissions);
|
||||||
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
|
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
|
||||||
|
const canAccessAssetScan = hasAnyPermission(permissions, [...MOBILE_ASSET_SCAN_PERMISSIONS]);
|
||||||
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
|
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
|
||||||
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
|
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
|
||||||
|
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
|
||||||
|
const currentMobileSource = `${location.pathname}${location.search}`;
|
||||||
|
|
||||||
const attendanceTodayQuery = useQuery({
|
const attendanceTodayQuery = useQuery({
|
||||||
queryKey: ["mobile", "portal", "attendance", "today"],
|
queryKey: ["mobile", "portal", "attendance", "today"],
|
||||||
@@ -176,6 +194,22 @@ export function MobilePortalPage() {
|
|||||||
queryFn: () => fetchNotifications({ page: 1, pageSize: 6 }),
|
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({
|
const attendanceClockInMutation = useMutation({
|
||||||
mutationFn: () => clockInAttendance(),
|
mutationFn: () => clockInAttendance(),
|
||||||
onSuccess: async (response) => {
|
onSuccess: async (response) => {
|
||||||
@@ -212,51 +246,51 @@ export function MobilePortalPage() {
|
|||||||
const pendingApprovalCount = canAccessApprovals
|
const pendingApprovalCount = canAccessApprovals
|
||||||
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
|
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
|
||||||
: 0;
|
: 0;
|
||||||
const workflowEntityTypeCount = workflowSummaryQuery.data?.byEntityType?.length ?? 0;
|
|
||||||
const pendingTasks = pendingCountQuery.data?.items ?? [];
|
const pendingTasks = pendingCountQuery.data?.items ?? [];
|
||||||
|
const urgentPendingTask = pendingTasks[0] ?? null;
|
||||||
const notifications = notificationsQuery.data?.items ?? [];
|
const notifications = notificationsQuery.data?.items ?? [];
|
||||||
const homeNotifications = notifications
|
const homeNotifications = notifications
|
||||||
.filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_")))
|
.filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_")))
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
|
const financeSummary = portalHomeQuery.data?.financeSummary ?? null;
|
||||||
|
|
||||||
const primaryActions = [
|
async function openHomeNotification(item: NotificationItem) {
|
||||||
canAccessReimbursements
|
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",
|
key: "new-reimbursement",
|
||||||
label: "报销",
|
label: "新建报销",
|
||||||
meta: "新建与进度",
|
meta: "填写明细",
|
||||||
icon: "request_quote",
|
icon: "request_quote",
|
||||||
to: "/mobile/reimbursements",
|
to: "/mobile/reimbursements?intent=create",
|
||||||
|
tone: "primary",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
canAccessCapture
|
canAccessCapture
|
||||||
? {
|
? {
|
||||||
key: "capture",
|
key: "capture",
|
||||||
label: "传票据",
|
label: "上传票据",
|
||||||
meta: "上传 PDF",
|
meta: "上传 PDF",
|
||||||
icon: "receipt_long",
|
icon: "receipt_long",
|
||||||
to: "/mobile/capture",
|
to: "/mobile/capture",
|
||||||
tone: "primary",
|
|
||||||
}
|
}
|
||||||
: null,
|
: 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
|
canAccessLeave
|
||||||
? {
|
? {
|
||||||
key: "leave",
|
key: "leave",
|
||||||
@@ -266,8 +300,16 @@ export function MobilePortalPage() {
|
|||||||
to: "/mobile/leave",
|
to: "/mobile/leave",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
].filter((item): item is MobileAction => Boolean(item));
|
canAccessAssetScan
|
||||||
const actions = [...primaryActions, ...secondaryActions].slice(0, 4);
|
? {
|
||||||
|
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 (
|
return (
|
||||||
<PageScaffold className="space-y-4">
|
<PageScaffold className="space-y-4">
|
||||||
@@ -275,37 +317,33 @@ export function MobilePortalPage() {
|
|||||||
|
|
||||||
{canAccessApprovals && pendingApprovalCount > 0 ? (
|
{canAccessApprovals && pendingApprovalCount > 0 ? (
|
||||||
<section className="mobile-card" data-testid="mobile-pending-summary">
|
<section className="mobile-card" data-testid="mobile-pending-summary">
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
<div className="text-base font-semibold text-zinc-900">待处理</div>
|
<div className="text-base font-semibold text-zinc-900">待处理</div>
|
||||||
<span className="badge-warning">{pendingApprovalCount} 待批</span>
|
<div className="mt-1 text-xs text-zinc-500">待我审批</div>
|
||||||
</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>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-3xl font-semibold leading-none text-zinc-900" data-testid="mobile-pending-summary-count">
|
||||||
|
{pendingApprovalCount}
|
||||||
</div>
|
</div>
|
||||||
|
{workflowSummaryQuery.data?.overdueTotal ? (
|
||||||
|
<div className="mt-1 text-xs font-medium text-amber-700">超时 {workflowSummaryQuery.data.overdueTotal}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{urgentPendingTask ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{pendingTasks.slice(0, 3).map((task) => {
|
{(() => {
|
||||||
|
const task = urgentPendingTask;
|
||||||
const entityType = task.workflowInstance?.entityType;
|
const entityType = task.workflowInstance?.entityType;
|
||||||
const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId);
|
const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={task.id}
|
key={task.id}
|
||||||
to={`/mobile/approvals/${encodeURIComponent(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"
|
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="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0 truncate font-medium text-zinc-900">
|
<div className="min-w-0 truncate font-medium text-zinc-900">
|
||||||
@@ -318,14 +356,46 @@ export function MobilePortalPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
|
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
|
||||||
<Link to="/mobile/approvals">进入审批</Link>
|
<Link to="/mobile/approvals">查看全部待办</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : 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">
|
<section className="mobile-card" data-testid="mobile-today-focus">
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<div className="text-base font-semibold text-zinc-900">常用</div>
|
<div className="text-base font-semibold text-zinc-900">常用</div>
|
||||||
@@ -402,17 +472,22 @@ export function MobilePortalPage() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{homeNotifications.map((item) => (
|
{homeNotifications.map((item) => (
|
||||||
<Link
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
to={resolveMobileNotificationTargetLink(item) ?? "/mobile/notifications"}
|
type="button"
|
||||||
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
|
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="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}
|
{!item.readAt ? <span className="badge-warning shrink-0">新</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
|
<div className="mt-1 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
|
||||||
</Link>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchPurchaseRequestDetail } from "../api/purchaseRequests";
|
import { fetchPurchaseRequestDetail } from "../api/purchaseRequests";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||||
import { StatusBadge } from "../components/common/StatusBadge";
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { Button } from "../components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
formatApprovalHistoryTime,
|
formatApprovalHistoryTime,
|
||||||
resolvePurchaseDisplayCode,
|
resolvePurchaseDisplayCode,
|
||||||
@@ -36,6 +35,13 @@ function formatBytes(value: number) {
|
|||||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
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() {
|
export function MobilePurchaseRequestDetailPage() {
|
||||||
const { purchaseRequestId = "" } = useParams();
|
const { purchaseRequestId = "" } = useParams();
|
||||||
|
|
||||||
@@ -50,14 +56,12 @@ export function MobilePurchaseRequestDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScaffold className="space-y-3 pb-4">
|
<PageScaffold className="space-y-3 pb-4">
|
||||||
<PageHeader
|
<MobileDetailHeader
|
||||||
title="采购详情"
|
title={displayCode}
|
||||||
meta={detail ? <StatusBadge status={detail.status} /> : null}
|
subtitle="采购详情"
|
||||||
actions={
|
status={detail ? <StatusBadge status={detail.status} /> : null}
|
||||||
<Button asChild size="sm" type="button" variant="secondary">
|
fallbackTo={purchaseProgressFallback(detail?.status)}
|
||||||
<Link to="/mobile/approvals">返回</Link>
|
testId="mobile-purchase-detail-header"
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!purchaseRequestId ? (
|
{!purchaseRequestId ? (
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { useAuth } from "../auth/AuthContext";
|
|||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||||
|
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -92,6 +93,10 @@ const STATUS_TONE: Record<ReimbursementStatus, string> = {
|
|||||||
cancelled: "bg-zinc-100 text-zinc-500",
|
cancelled: "bg-zinc-100 text-zinc-500",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function reimbursementFallback(status: ReimbursementStatus) {
|
||||||
|
return `/mobile/reimbursements?status=${encodeURIComponent(status)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function todayDate() {
|
function todayDate() {
|
||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
@@ -795,18 +800,17 @@ export function MobileReimbursementDetailPage() {
|
|||||||
)}
|
)}
|
||||||
data-testid="mobile-reimbursement-detail"
|
data-testid="mobile-reimbursement-detail"
|
||||||
>
|
>
|
||||||
<header className="flex items-center gap-3" data-testid="mobile-reimbursement-detail">
|
<MobileDetailHeader
|
||||||
<Button asChild size="sm" variant="secondary">
|
title={displayCode}
|
||||||
<Link to="/mobile/reimbursements">返回</Link>
|
subtitle="报销详情"
|
||||||
</Button>
|
fallbackTo={reimbursementFallback(detail.status)}
|
||||||
<div className="min-w-0 flex-1">
|
status={
|
||||||
<div className="truncate text-base font-semibold text-zinc-900">{displayCode}</div>
|
<span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>
|
||||||
<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]}
|
{STATUS_LABEL[detail.status]}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
}
|
||||||
|
testId="mobile-reimbursement-detail-header"
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="mobile-card">
|
<section className="mobile-card">
|
||||||
<div className="text-xs text-zinc-500">合计金额</div>
|
<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}
|
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
|
||||||
</section>
|
</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">
|
<section className="mobile-card" data-testid="mobile-reimbursement-items">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
|
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
|
||||||
@@ -1139,6 +1100,49 @@ export function MobileReimbursementDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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 ? (
|
{showSecondaryActions ? (
|
||||||
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
|
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
|
||||||
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
|
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +35,10 @@ const STATUS_LABEL: Record<ReimbursementStatus, string> = {
|
|||||||
cancelled: "已取消",
|
cancelled: "已取消",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isReimbursementStatus(value: string | null): value is ReimbursementStatus {
|
||||||
|
return ["draft", "reviewing", "approved", "paid", "rejected", "cancelled"].includes(value ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
function formatCurrency(value: string | null | undefined) {
|
function formatCurrency(value: string | null | undefined) {
|
||||||
const numberValue = Number(value ?? 0);
|
const numberValue = Number(value ?? 0);
|
||||||
if (!Number.isFinite(numberValue)) {
|
if (!Number.isFinite(numberValue)) {
|
||||||
@@ -53,11 +57,13 @@ function formatDate(value: string | null | undefined) {
|
|||||||
function ReimbursementCard({
|
function ReimbursementCard({
|
||||||
item,
|
item,
|
||||||
canDeleteDraft,
|
canDeleteDraft,
|
||||||
|
currentMobileSource,
|
||||||
isDeleting,
|
isDeleting,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
item: ReimbursementListItem;
|
item: ReimbursementListItem;
|
||||||
canDeleteDraft: boolean;
|
canDeleteDraft: boolean;
|
||||||
|
currentMobileSource: string;
|
||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
onDelete: (item: ReimbursementListItem) => void;
|
onDelete: (item: ReimbursementListItem) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -89,7 +95,9 @@ function ReimbursementCard({
|
|||||||
|
|
||||||
<div className={canDeleteDraft ? "mobile-two-action-grid mt-4" : "mt-4"}>
|
<div className={canDeleteDraft ? "mobile-two-action-grid mt-4" : "mt-4"}>
|
||||||
<Button asChild className="w-full" type="button">
|
<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>
|
</Button>
|
||||||
{canDeleteDraft ? (
|
{canDeleteDraft ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -110,12 +118,19 @@ function ReimbursementCard({
|
|||||||
|
|
||||||
export function MobileReimbursementsPage() {
|
export function MobileReimbursementsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { permissions } = useAuth();
|
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 [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
|
||||||
|
const createIntentHandledRef = useRef(false);
|
||||||
const canCreateExpense = hasPermission(permissions, "expense.create");
|
const canCreateExpense = hasPermission(permissions, "expense.create");
|
||||||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||||
|
const currentMobileSource = `${location.pathname}${location.search}`;
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: ["mobile", "reimbursements", status],
|
queryKey: ["mobile", "reimbursements", status],
|
||||||
@@ -127,13 +142,32 @@ export function MobileReimbursementsPage() {
|
|||||||
onSuccess: async (response) => {
|
onSuccess: async (response) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] });
|
await queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||||
navigate(`/mobile/reimbursements/${response.id}`);
|
navigate(`/mobile/reimbursements/${response.id}`, { state: { from: currentMobileSource } });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(resolveErrorMessage(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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId),
|
mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -211,6 +245,7 @@ export function MobileReimbursementsPage() {
|
|||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<ReimbursementCard
|
<ReimbursementCard
|
||||||
canDeleteDraft={canDeleteExpense && item.status === "draft"}
|
canDeleteDraft={canDeleteExpense && item.status === "draft"}
|
||||||
|
currentMobileSource={currentMobileSource}
|
||||||
isDeleting={deleteMutation.isPending}
|
isDeleting={deleteMutation.isPending}
|
||||||
item={item}
|
item={item}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import path from "node:path";
|
|||||||
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
|
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
||||||
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/mobile-oa-information-dedupe-governance");
|
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/mobile-ia-flow-remediation");
|
||||||
const IOS_WECOM_UA =
|
const IOS_WECOM_UA =
|
||||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
|
||||||
|
|
||||||
type RolePreset = "employee" | "manager";
|
type RolePreset = "employee" | "manager" | "finance" | "admin";
|
||||||
|
|
||||||
type MockNotification = {
|
type MockNotification = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,17 +38,25 @@ function screenshotPath(fileName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildMe(role: RolePreset) {
|
function buildMe(role: RolePreset) {
|
||||||
|
const roleMeta: Record<RolePreset, { employeeNo: string; name: string; email: string; roleCodes: string[] }> = {
|
||||||
|
employee: { employeeNo: "E-DG7", name: "测试员工", email: "[email protected]", roleCodes: ["employee"] },
|
||||||
|
manager: { employeeNo: "M-DG7", name: "测试审批人", email: "[email protected]", roleCodes: ["manager"] },
|
||||||
|
finance: { employeeNo: "F-DG7", name: "测试财务", email: "[email protected]", roleCodes: ["finance"] },
|
||||||
|
admin: { employeeNo: "A-DG7", name: "测试管理员", email: "[email protected]", roleCodes: ["admin"] },
|
||||||
|
};
|
||||||
|
const meta = roleMeta[role];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `employee-dg7-${role}`,
|
id: `employee-dg7-${role}`,
|
||||||
employeeNo: role === "manager" ? "M-DG7" : "E-DG7",
|
employeeNo: meta.employeeNo,
|
||||||
name: role === "manager" ? "测试审批人" : "测试员工",
|
name: meta.name,
|
||||||
phone: "13800000000",
|
phone: "13800000000",
|
||||||
email: `${role}@hyinda.com`,
|
email: meta.email,
|
||||||
departmentId: "dept-dg7",
|
departmentId: "dept-dg7",
|
||||||
directManagerId: role === "manager" ? null : "employee-dg7-manager",
|
directManagerId: role === "manager" ? null : "employee-dg7-manager",
|
||||||
status: "active",
|
status: "active",
|
||||||
notificationPushEnabled: true,
|
notificationPushEnabled: true,
|
||||||
roleCodes: role === "manager" ? ["manager"] : ["employee"],
|
roleCodes: meta.roleCodes,
|
||||||
profileCompleteness: {
|
profileCompleteness: {
|
||||||
hasEmergencyContact: true,
|
hasEmergencyContact: true,
|
||||||
hasBankAccount: true,
|
hasBankAccount: true,
|
||||||
@@ -337,6 +345,81 @@ function reimbursementListItem() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reimbursementDetail() {
|
||||||
|
return {
|
||||||
|
...reimbursementListItem(),
|
||||||
|
submitterId: "employee-dg7-employee",
|
||||||
|
ownerId: "employee-dg7-employee",
|
||||||
|
departmentId: "dept-dg7",
|
||||||
|
department: { id: "dept-dg7", name: "研发部" },
|
||||||
|
submittedAt: "2026-05-12T08:00:00.000Z",
|
||||||
|
projectId: null,
|
||||||
|
linkedTravelRequestId: null,
|
||||||
|
linkedTravelRequest: null,
|
||||||
|
invoices: [
|
||||||
|
{
|
||||||
|
id: "invoice-dg7",
|
||||||
|
status: "bound",
|
||||||
|
invoiceCode: "044001900111",
|
||||||
|
invoiceNumber: "12345678",
|
||||||
|
invoiceDate: "2026-05-10",
|
||||||
|
sellerName: "测试供应商",
|
||||||
|
totalAmount: "168.00",
|
||||||
|
originalName: "发票.pdf",
|
||||||
|
linkedItemId: "item-dg7",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
id: "attachment-reimbursement-dg7",
|
||||||
|
originalName: "报销说明.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
fileSize: 20480,
|
||||||
|
fileSha256: "sha256-dg7",
|
||||||
|
uploadedBy: { id: "employee-dg7-employee", name: "测试员工", employeeNo: "E-DG7" },
|
||||||
|
createdAt: "2026-05-12T08:05:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
budgetHint: null,
|
||||||
|
workflowProgress: {
|
||||||
|
currentStepIndex: 1,
|
||||||
|
totalSteps: 2,
|
||||||
|
steps: [
|
||||||
|
{ id: "step-submit", name: "提交", status: "completed" },
|
||||||
|
{ id: "step-dg7", name: "主管审批", status: "current" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
approvalHistory: [
|
||||||
|
{
|
||||||
|
id: "history-reimbursement-dg7",
|
||||||
|
taskId: "task-dg7",
|
||||||
|
actorName: "测试审批人",
|
||||||
|
action: "submit",
|
||||||
|
comment: "提交审批",
|
||||||
|
stepName: "提交",
|
||||||
|
createdAt: "2026-05-12T08:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
riskFlags: [],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "item-dg7",
|
||||||
|
expenseTypeId: "expense-type-dg7",
|
||||||
|
expenseDate: "2026-05-12",
|
||||||
|
amount: "168.00",
|
||||||
|
description: "差旅报销",
|
||||||
|
expenseType: {
|
||||||
|
id: "expense-type-dg7",
|
||||||
|
code: "travel",
|
||||||
|
name: "交通费",
|
||||||
|
category: "travel",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function mockApis(page: Page, role: RolePreset, notifications: MockNotification[]) {
|
async function mockApis(page: Page, role: RolePreset, notifications: MockNotification[]) {
|
||||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
@@ -409,6 +492,7 @@ async function mockApis(page: Page, role: RolePreset, notifications: MockNotific
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathName === "/api/portal/home") {
|
if (pathName === "/api/portal/home") {
|
||||||
|
const isFinance = role === "finance";
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, {
|
||||||
workspace: { id: "workspace-dg7", persisted: true, profile: "small_team_internal" },
|
workspace: { id: "workspace-dg7", persisted: true, profile: "small_team_internal" },
|
||||||
greeting: { name: buildMe(role).name },
|
greeting: { name: buildMe(role).name },
|
||||||
@@ -418,6 +502,22 @@ async function mockApis(page: Page, role: RolePreset, notifications: MockNotific
|
|||||||
riskSummary: null,
|
riskSummary: null,
|
||||||
announcements: [],
|
announcements: [],
|
||||||
quickLinks: [],
|
quickLinks: [],
|
||||||
|
roleCapabilities: {
|
||||||
|
canSubmitApplications: role !== "admin",
|
||||||
|
canApproveTasks: role === "manager" || role === "finance",
|
||||||
|
canUseFinance: isFinance,
|
||||||
|
canManageSystem: role === "admin",
|
||||||
|
canViewTeam: role === "manager",
|
||||||
|
isAdminOnly: role === "admin",
|
||||||
|
},
|
||||||
|
financeSummary: isFinance
|
||||||
|
? {
|
||||||
|
pendingBatchCount: 2,
|
||||||
|
failedItemCount: 1,
|
||||||
|
riskCount: 3,
|
||||||
|
href: "/ap-operations",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
recentActivities: [],
|
recentActivities: [],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -511,6 +611,26 @@ async function mockApis(page: Page, role: RolePreset, notifications: MockNotific
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/reimbursements/reimbursement-dg7" && request.method() === "GET") {
|
||||||
|
await fulfillJson(route, reimbursementDetail());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/expense-types") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "expense-type-dg7",
|
||||||
|
code: "travel",
|
||||||
|
name: "交通费",
|
||||||
|
category: "travel",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (pathName === "/api/leave-types") {
|
if (pathName === "/api/leave-types") {
|
||||||
await fulfillJson(route, { items: [leaveType] });
|
await fulfillJson(route, { items: [leaveType] });
|
||||||
return;
|
return;
|
||||||
@@ -621,9 +741,9 @@ async function expectNoMobileShellRegression(page: Page) {
|
|||||||
|
|
||||||
async function expectActionTileGapStable(page: Page) {
|
async function expectActionTileGapStable(page: Page) {
|
||||||
const gaps = await page.evaluate(() =>
|
const gaps = await page.evaluate(() =>
|
||||||
Array.from(document.querySelectorAll<HTMLElement>("[data-testid='mobile-action-grid'] a")).map((tile) => {
|
Array.from(document.querySelectorAll<HTMLElement>("[data-testid='mobile-action-grid'] .mobile-icon-action")).map((tile) => {
|
||||||
const icon = tile.querySelector<HTMLElement>("[data-testid$='-icon']");
|
const icon = tile.querySelector<HTMLElement>(".mobile-icon-action__icon");
|
||||||
const label = tile.querySelector<HTMLElement>("[data-testid$='-label']");
|
const label = tile.querySelector<HTMLElement>(".mobile-icon-action__label");
|
||||||
if (!icon || !label) return null;
|
if (!icon || !label) return null;
|
||||||
return label.getBoundingClientRect().top - icon.getBoundingClientRect().bottom;
|
return label.getBoundingClientRect().top - icon.getBoundingClientRect().bottom;
|
||||||
}).filter((value): value is number => value !== null),
|
}).filter((value): value is number => value !== null),
|
||||||
@@ -634,10 +754,27 @@ async function expectActionTileGapStable(page: Page) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("DG7 移动信息架构与展示去重治理", () => {
|
async function expectCommonActionsDoNotDuplicateBottomTabs(page: Page) {
|
||||||
test.skip(process.env.E2E_MODE === "live", "DG7 本地专项使用 mock 数据验证信息架构。");
|
const labels = await page.evaluate(() => ({
|
||||||
|
actions: Array.from(document.querySelectorAll("[data-testid^='mobile-action-'][data-testid$='-label']"))
|
||||||
|
.map((item) => item.textContent?.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
bottomTabs: Array.from(document.querySelectorAll("[data-testid^='mobile-nav-'][data-testid$='-label']"))
|
||||||
|
.map((item) => item.textContent?.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
}));
|
||||||
|
expect(labels.actions).not.toContain("首页");
|
||||||
|
expect(labels.actions).not.toContain("审批");
|
||||||
|
expect(labels.actions).not.toContain("进度");
|
||||||
|
expect(labels.actions).not.toContain("报销");
|
||||||
|
expect(labels.actions).not.toContain("我的");
|
||||||
|
expect(labels.actions.filter((label) => labels.bottomTabs.includes(label)).length).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
test("普通员工底部入口变为进度视角,首页宫格间距在多视口稳定", async ({ browser }) => {
|
test.describe("MIF7 移动信息架构整改", () => {
|
||||||
|
test.skip(process.env.E2E_MODE === "live", "MIF7 本地专项使用 mock 数据验证信息架构。");
|
||||||
|
|
||||||
|
test("首页常用入口不重复底部 Tab,员工进度页按状态分标签", async ({ browser }) => {
|
||||||
for (const width of [320, 360, 375, 390, 393, 414, 430]) {
|
for (const width of [320, 360, 375, 390, 393, 414, 430]) {
|
||||||
const notifications = buildNotifications();
|
const notifications = buildNotifications();
|
||||||
const mobile = await newMobilePage(browser, width);
|
const mobile = await newMobilePage(browser, width);
|
||||||
@@ -647,14 +784,21 @@ test.describe("DG7 移动信息架构与展示去重治理", () => {
|
|||||||
|
|
||||||
await mobile.page.goto("/mobile");
|
await mobile.page.goto("/mobile");
|
||||||
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("进度");
|
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("进度");
|
||||||
await expect(mobile.page.getByTestId("mobile-action-approvals-label")).toHaveText("进度");
|
await expect(mobile.page.getByTestId("mobile-action-approvals-label")).toHaveCount(0);
|
||||||
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(0);
|
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(0);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-action-grid")).toBeVisible();
|
||||||
|
await expectCommonActionsDoNotDuplicateBottomTabs(mobile.page);
|
||||||
await expectActionTileGapStable(mobile.page);
|
await expectActionTileGapStable(mobile.page);
|
||||||
await expectNoMobileShellRegression(mobile.page);
|
await expectNoMobileShellRegression(mobile.page);
|
||||||
|
await mobile.page.screenshot({ path: screenshotPath(`home-employee-w${width}`), fullPage: true });
|
||||||
|
|
||||||
await mobile.page.getByTestId("mobile-nav-approvals").click();
|
await mobile.page.getByTestId("mobile-nav-approvals").click();
|
||||||
await expect(mobile.page).toHaveURL(/\/mobile\/approvals$/);
|
await expect(mobile.page).toHaveURL(/\/mobile\/approvals$/);
|
||||||
await expect(mobile.page.getByTestId("mobile-approvals-progress")).toBeVisible();
|
await expect(mobile.page.getByTestId("mobile-approvals-progress")).toBeVisible();
|
||||||
|
await expect(mobile.page.getByTestId("mobile-progress-tab-draft")).toBeVisible();
|
||||||
|
await expect(mobile.page.getByTestId("mobile-progress-tab-reviewing")).toBeVisible();
|
||||||
|
await expect(mobile.page.getByTestId("mobile-progress-tab-approved")).toBeVisible();
|
||||||
|
await expect(mobile.page.getByTestId("mobile-progress-tab-returned")).toBeVisible();
|
||||||
await expect(mobile.page.getByTestId("mobile-approvals-list")).toHaveCount(0);
|
await expect(mobile.page.getByTestId("mobile-approvals-list")).toHaveCount(0);
|
||||||
await expect(mobile.page.getByTestId("mobile-approvals-approve")).toHaveCount(0);
|
await expect(mobile.page.getByTestId("mobile-approvals-approve")).toHaveCount(0);
|
||||||
await expect(mobile.page.getByTestId("mobile-approval-progress-card")).toHaveCount(3);
|
await expect(mobile.page.getByTestId("mobile-approval-progress-card")).toHaveCount(3);
|
||||||
@@ -666,7 +810,7 @@ test.describe("DG7 移动信息架构与展示去重治理", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("审批人首页只保留一处待办摘要,审批详情使用 Push 页并隐藏底部 Tab", async ({ browser }) => {
|
test("审批人首页只保留轻待办摘要,审批页按待我审批和已处理分组", async ({ browser }) => {
|
||||||
const notifications = buildNotifications();
|
const notifications = buildNotifications();
|
||||||
const mobile = await newMobilePage(browser, 390);
|
const mobile = await newMobilePage(browser, 390);
|
||||||
try {
|
try {
|
||||||
@@ -677,7 +821,12 @@ test.describe("DG7 移动信息架构与展示去重治理", () => {
|
|||||||
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("审批");
|
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("审批");
|
||||||
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(1);
|
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(1);
|
||||||
await expect(mobile.page.getByTestId("mobile-notifications")).not.toContainText("报销待审批");
|
await expect(mobile.page.getByTestId("mobile-notifications")).not.toContainText("报销待审批");
|
||||||
await expect(mobile.page.getByTestId("mobile-action-approvals")).not.toContainText("待批");
|
await expect(mobile.page.getByTestId("mobile-action-approvals")).toHaveCount(0);
|
||||||
|
await expectCommonActionsDoNotDuplicateBottomTabs(mobile.page);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-pending-summary-count")).toHaveText("1");
|
||||||
|
await expect(mobile.page.getByTestId("mobile-pending-summary-primary")).toHaveCount(1);
|
||||||
|
await expect(mobile.page.getByRole("link", { name: "查看全部待办" })).toBeVisible();
|
||||||
|
await mobile.page.screenshot({ path: screenshotPath("home-manager-pending-summary"), fullPage: true });
|
||||||
|
|
||||||
const badgeMetrics = await mobile.page.evaluate(() => {
|
const badgeMetrics = await mobile.page.evaluate(() => {
|
||||||
const nav = document.querySelector<HTMLElement>("[data-testid='mobile-nav-approvals']");
|
const nav = document.querySelector<HTMLElement>("[data-testid='mobile-nav-approvals']");
|
||||||
@@ -706,37 +855,121 @@ test.describe("DG7 移动信息架构与展示去重治理", () => {
|
|||||||
await expect(mobile.page.getByTestId("mobile-approvals-detail")).toContainText("报销单 BX20260512001");
|
await expect(mobile.page.getByTestId("mobile-approvals-detail")).toContainText("报销单 BX20260512001");
|
||||||
await expectNoMobileShellRegression(mobile.page);
|
await expectNoMobileShellRegression(mobile.page);
|
||||||
await mobile.page.screenshot({ path: screenshotPath("manager-approval-push-detail"), fullPage: true });
|
await mobile.page.screenshot({ path: screenshotPath("manager-approval-push-detail"), fullPage: true });
|
||||||
|
|
||||||
|
await mobile.page.getByTestId("mobile-approvals-detail-header").getByRole("button", { name: "返回" }).click();
|
||||||
|
await expect(mobile.page).toHaveURL(/\/mobile$/);
|
||||||
|
await mobile.page.goto("/mobile/approvals");
|
||||||
|
await expect(mobile.page.getByRole("tab", { name: "待我审批" })).toBeVisible();
|
||||||
|
await expect(mobile.page.getByRole("tab", { name: "已处理" })).toBeVisible();
|
||||||
|
await mobile.page.screenshot({ path: screenshotPath("approver-task-tabs"), fullPage: true });
|
||||||
} finally {
|
} finally {
|
||||||
await mobile.context.close();
|
await mobile.context.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("通知中心点击通知直达请假和采购详情,不再回到通知列表或首页", async ({ browser }) => {
|
test("通知中心事件化,点击通知直达业务详情且返回来源页", async ({ browser }) => {
|
||||||
const notifications = buildNotifications();
|
const notifications = buildNotifications();
|
||||||
const mobile = await newMobilePage(browser, 390);
|
const mobile = await newMobilePage(browser, 390);
|
||||||
try {
|
try {
|
||||||
await mockApis(mobile.page, "employee", notifications);
|
await mockApis(mobile.page, "employee", notifications);
|
||||||
await loginForMobile(mobile.page, "employee");
|
await loginForMobile(mobile.page, "employee");
|
||||||
|
|
||||||
|
await mobile.page.goto("/mobile");
|
||||||
|
await expect(mobile.page.getByTestId("mobile-home-notification-card")).toHaveCount(3);
|
||||||
|
await mobile.page.getByTestId("mobile-home-notification-card").filter({ hasText: "采购已通过" }).click();
|
||||||
|
await expect(mobile.page).toHaveURL(/\/mobile\/purchase-requests\/purchase-dg7-approved$/);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-purchase-detail-header")).toBeVisible();
|
||||||
|
await mobile.page.getByTestId("mobile-purchase-detail-header").getByRole("button", { name: "返回" }).click();
|
||||||
|
await expect(mobile.page).toHaveURL(/\/mobile$/);
|
||||||
|
|
||||||
await mobile.page.goto("/mobile/notifications");
|
await mobile.page.goto("/mobile/notifications");
|
||||||
await expect(mobile.page.getByTestId("mobile-notification-card")).toHaveCount(3);
|
await expect(mobile.page.getByTestId("mobile-notification-card")).toHaveCount(3);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-notification-card").first()).toContainText("事件");
|
||||||
|
await expect(mobile.page.getByTestId("mobile-approvals-approve")).toHaveCount(0);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-approvals-reject")).toHaveCount(0);
|
||||||
|
await mobile.page.screenshot({ path: screenshotPath("notifications-event-inbox"), fullPage: true });
|
||||||
|
|
||||||
await mobile.page.getByTestId("mobile-notification-card").filter({ hasText: "采购已通过" }).click();
|
await mobile.page.getByTestId("mobile-notification-card").filter({ hasText: "采购已通过" }).click();
|
||||||
await expect(mobile.page).toHaveURL(/\/mobile\/purchase-requests\/purchase-dg7-approved$/);
|
await expect(mobile.page).toHaveURL(/\/mobile\/purchase-requests\/purchase-dg7-approved$/);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-purchase-detail-header")).toBeVisible();
|
||||||
await expect(mobile.page.getByTestId("mobile-purchase-detail-summary")).toBeVisible();
|
await expect(mobile.page.getByTestId("mobile-purchase-detail-summary")).toBeVisible();
|
||||||
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
|
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
|
||||||
await expectNoMobileShellRegression(mobile.page);
|
await expectNoMobileShellRegression(mobile.page);
|
||||||
|
await mobile.page.screenshot({ path: screenshotPath("purchase-detail-shell"), fullPage: true });
|
||||||
|
await mobile.page.getByTestId("mobile-purchase-detail-header").getByRole("button", { name: "返回" }).click();
|
||||||
|
await expect(mobile.page).toHaveURL(/\/mobile\/notifications/);
|
||||||
|
|
||||||
await mobile.page.goto("/mobile/notifications");
|
await mobile.page.goto("/mobile/notifications");
|
||||||
await mobile.page.getByTestId("mobile-notification-card").filter({ hasText: "请假已通过" }).click();
|
await mobile.page.getByTestId("mobile-notification-card").filter({ hasText: "请假已通过" }).click();
|
||||||
await expect(mobile.page).toHaveURL(/\/mobile\/leave\/leave-dg7-approved$/);
|
await expect(mobile.page).toHaveURL(/\/mobile\/leave\/leave-dg7-approved$/);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-leave-detail-header")).toBeVisible();
|
||||||
await expect(mobile.page.getByTestId("mobile-leave-record-card")).toBeVisible();
|
await expect(mobile.page.getByTestId("mobile-leave-record-card")).toBeVisible();
|
||||||
await expect(mobile.page.getByTestId("mobile-leave-form")).toHaveCount(0);
|
await expect(mobile.page.getByTestId("mobile-leave-form")).toHaveCount(0);
|
||||||
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
|
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
|
||||||
await expectNoMobileShellRegression(mobile.page);
|
await expectNoMobileShellRegression(mobile.page);
|
||||||
await mobile.page.screenshot({ path: screenshotPath("notification-direct-leave-detail"), fullPage: true });
|
await mobile.page.screenshot({ path: screenshotPath("notification-direct-leave-detail"), fullPage: true });
|
||||||
|
await mobile.page.getByTestId("mobile-leave-detail-header").getByRole("button", { name: "返回" }).click();
|
||||||
|
await expect(mobile.page).toHaveURL(/\/mobile\/notifications/);
|
||||||
} finally {
|
} finally {
|
||||||
await mobile.context.close();
|
await mobile.context.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("报销详情统一详情壳,直接进入请假详情返回进度标签", async ({ browser }) => {
|
||||||
|
const notifications = buildNotifications();
|
||||||
|
const mobile = await newMobilePage(browser, 390);
|
||||||
|
try {
|
||||||
|
await mockApis(mobile.page, "employee", notifications);
|
||||||
|
await loginForMobile(mobile.page, "employee");
|
||||||
|
|
||||||
|
await mobile.page.goto("/mobile/reimbursements/reimbursement-dg7");
|
||||||
|
await expect(mobile.page.getByTestId("mobile-reimbursement-detail-header")).toBeVisible();
|
||||||
|
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
|
||||||
|
const sectionOrder = await mobile.page.evaluate(() =>
|
||||||
|
[
|
||||||
|
"mobile-reimbursement-items",
|
||||||
|
"mobile-reimbursement-attachments",
|
||||||
|
"mobile-reimbursement-approval-history",
|
||||||
|
].map((testId) => document.querySelector(`[data-testid='${testId}']`)?.getBoundingClientRect().top ?? -1),
|
||||||
|
);
|
||||||
|
expect(sectionOrder[0]).toBeLessThan(sectionOrder[1]);
|
||||||
|
expect(sectionOrder[1]).toBeLessThan(sectionOrder[2]);
|
||||||
|
await expectNoMobileShellRegression(mobile.page);
|
||||||
|
await mobile.page.screenshot({ path: screenshotPath("reimbursement-detail-shell"), fullPage: true });
|
||||||
|
|
||||||
|
await mobile.page.goto("/mobile/leave/leave-dg7-approved");
|
||||||
|
await expect(mobile.page.getByTestId("mobile-leave-detail-header")).toBeVisible();
|
||||||
|
await mobile.page.getByTestId("mobile-leave-detail-header").getByRole("button", { name: "返回" }).click();
|
||||||
|
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\?progress=approved/);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-leave-form")).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await mobile.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("财务首页角色化摘要只对财务角色展示", async ({ browser }) => {
|
||||||
|
const financeMobile = await newMobilePage(browser, 390);
|
||||||
|
try {
|
||||||
|
await mockApis(financeMobile.page, "finance", buildNotifications());
|
||||||
|
await loginForMobile(financeMobile.page, "finance");
|
||||||
|
await financeMobile.page.goto("/mobile");
|
||||||
|
await expect(financeMobile.page.getByTestId("mobile-finance-summary")).toBeVisible();
|
||||||
|
await expect(financeMobile.page.getByTestId("mobile-finance-summary")).toContainText("待付款");
|
||||||
|
await expect(financeMobile.page.getByTestId("mobile-finance-summary")).toContainText("台账/导出");
|
||||||
|
await expectNoMobileShellRegression(financeMobile.page);
|
||||||
|
await financeMobile.page.screenshot({ path: screenshotPath("home-finance-summary"), fullPage: true });
|
||||||
|
} finally {
|
||||||
|
await financeMobile.context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminMobile = await newMobilePage(browser, 390);
|
||||||
|
try {
|
||||||
|
await mockApis(adminMobile.page, "admin", buildNotifications());
|
||||||
|
await loginForMobile(adminMobile.page, "admin");
|
||||||
|
await adminMobile.page.goto("/mobile");
|
||||||
|
await expect(adminMobile.page.getByTestId("mobile-finance-summary")).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await adminMobile.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
MIF7-1,移动首页去重和常用动作重排,"按 MIA7 标准重排移动首页:常用入口只保留快捷动作,移除与底部 Tab 重复的报销、审批/进度、我的;审批人待处理摘要压缩为一张轻摘要。","1) 首页常用入口与底部导航重复数为 0;2) 审批人首页待处理只保留主数字、最紧急一条和查看全部待办入口;3) 按钮文案从进入审批改为查看全部待办或进入待办列表;4) 员工首页不显示不可处理的审批待办。","流程级验证:Playwright 以员工、审批人、财务账号访问 /mobile;断言常用入口和底部 Tab 无重复;审批人首页待处理数量表达不超过两层;点击摘要进详情,点击查看全部进列表。",playwright|npm,TODO,TODO,TODO,client/src/pages/MobilePortalPage.tsx|client/src/components/mobile/MobileBottomNav.tsx|client/src/features/mobile/navigation.ts,[],不改服务端权限;不新增预算、ERP、AI、复杂 BPM。
|
MIF7-1,移动首页去重和常用动作重排,"按 MIA7 标准重排移动首页:常用入口只保留快捷动作,移除与底部 Tab 重复的报销、审批/进度、我的;审批人待处理摘要压缩为一张轻摘要。","1) 首页常用入口与底部导航重复数为 0;2) 审批人首页待处理只保留主数字、最紧急一条和查看全部待办入口;3) 按钮文案从进入审批改为查看全部待办或进入待办列表;4) 员工首页不显示不可处理的审批待办。","流程级验证:Playwright 以员工、审批人、财务账号访问 /mobile;断言常用入口和底部 Tab 无重复;审批人首页待处理数量表达不超过两层;点击摘要进详情,点击查看全部进列表。",playwright|npm,DONE,DONE,DONE,client/src/pages/MobilePortalPage.tsx|client/src/components/mobile/MobileBottomNav.tsx|client/src/features/mobile/navigation.ts,[],不改服务端权限;不新增预算、ERP、AI、复杂 BPM。
|
||||||
MIF7-2,审批与进度分标签,"重构移动审批/进度页的信息架构:员工按状态查看我发起的事项,审批人按待我审批/已处理查看任务。","1) 员工页展示草稿、审批中、已通过、退回驳回标签;2) 默认优先显示审批中或待处理进度;3) 审批人页保留待处理和已处理标签;4) 通知事件不混入进度列表。","流程级验证:Playwright 使用员工账号构造报销/请假/采购草稿、审批中、已通过、退回驳回记录并切换标签;审批人账号验证待我审批/已处理;无权限审批动作不可见。",playwright|npm,TODO,TODO,TODO,client/src/pages/MobileApprovalsPage.tsx|client/src/lib/businessDisplay.ts,["MIF7-1"],只做前端信息架构;不改变 workflow 状态机或审批权限。
|
MIF7-2,审批与进度分标签,"重构移动审批/进度页的信息架构:员工按状态查看我发起的事项,审批人按待我审批/已处理查看任务。","1) 员工页展示草稿、审批中、已通过、退回驳回标签;2) 默认优先显示审批中或待处理进度;3) 审批人页保留待处理和已处理标签;4) 通知事件不混入进度列表。","流程级验证:Playwright 使用员工账号构造报销/请假/采购草稿、审批中、已通过、退回驳回记录并切换标签;审批人账号验证待我审批/已处理;无权限审批动作不可见。",playwright|npm,DONE,DONE,DONE,client/src/pages/MobileApprovalsPage.tsx|client/src/lib/businessDisplay.ts,["MIF7-1"],只做前端信息架构;不改变 workflow 状态机或审批权限。
|
||||||
MIF7-3,通知中心事件化,"把移动通知中心从业务卡片列表调整为事件收件箱,强化事件类型、已读状态、时间和直达语义,避免与审批进度页观感重复。","1) 通知卡片展示事件类型、标题、时间和已读/未读;2) 通知卡片不展示审批动作按钮;3) 有目标通知点击后标记已读并直达目标详情;4) 无目标或无权限显示可理解提示,不跳首页。","流程级验证:Playwright 构造审批、报销、请假、采购通知;点击首页通知和通知中心通知;验证目标路径、已读状态、无目标提示和无审批动作按钮。",playwright|npm,TODO,TODO,TODO,client/src/pages/MobileNotificationsPage.tsx|client/src/pages/MobilePortalPage.tsx|client/src/lib/notificationLinks.ts,["MIF7-2"],不新增通知通道;继续复用企业微信个人应用消息 deeplink 语义。
|
MIF7-3,通知中心事件化,"把移动通知中心从业务卡片列表调整为事件收件箱,强化事件类型、已读状态、时间和直达语义,避免与审批进度页观感重复。","1) 通知卡片展示事件类型、标题、时间和已读/未读;2) 通知卡片不展示审批动作按钮;3) 有目标通知点击后标记已读并直达目标详情;4) 无目标或无权限显示可理解提示,不跳首页。","流程级验证:Playwright 构造审批、报销、请假、采购通知;点击首页通知和通知中心通知;验证目标路径、已读状态、无目标提示和无审批动作按钮。",playwright|npm,DONE,DONE,DONE,client/src/pages/MobileNotificationsPage.tsx|client/src/pages/MobilePortalPage.tsx|client/src/lib/notificationLinks.ts,["MIF7-2"],不新增通知通道;继续复用企业微信个人应用消息 deeplink 语义。
|
||||||
MIF7-4,统一移动详情壳和返回语义,"抽出或复用统一移动详情壳,统一报销、请假、采购、审批详情的返回位置、标题/状态、内容顺序和底部 Tab 隐藏规则。","1) 所有移动深层详情左侧返回、右侧状态且隐藏底部 Tab;2) 内容顺序统一为摘要、关键字段、材料/附件/发票、审批流、下一步动作;3) 返回优先回来源;无来源时回业务列表或进度标签;4) 请假详情不回新建表单顶部,采购详情不无条件回审批首页。","流程级验证:Playwright 从首页摘要、审批/进度、通知中心分别进入报销/请假/采购/审批详情;断言返回落点、底部 Tab 隐藏、统一返回控件和关键区块顺序。",playwright|npm,TODO,TODO,TODO,client/src/components/mobile|client/src/pages/MobileReimbursementDetailPage.tsx|client/src/pages/MobileLeavePage.tsx|client/src/pages/MobilePurchaseRequestDetailPage.tsx|client/src/pages/MobileApprovalsPage.tsx|client/src/lib/runtimeContext.ts,["MIF7-3"],若实现需要新增后端接口或改状态机,停止并拆新 Issue。
|
MIF7-4,统一移动详情壳和返回语义,"抽出或复用统一移动详情壳,统一报销、请假、采购、审批详情的返回位置、标题/状态、内容顺序和底部 Tab 隐藏规则。","1) 所有移动深层详情左侧返回、右侧状态且隐藏底部 Tab;2) 内容顺序统一为摘要、关键字段、材料/附件/发票、审批流、下一步动作;3) 返回优先回来源;无来源时回业务列表或进度标签;4) 请假详情不回新建表单顶部,采购详情不无条件回审批首页。","流程级验证:Playwright 从首页摘要、审批/进度、通知中心分别进入报销/请假/采购/审批详情;断言返回落点、底部 Tab 隐藏、统一返回控件和关键区块顺序。",playwright|npm,DONE,DONE,DONE,client/src/components/mobile|client/src/pages/MobileReimbursementDetailPage.tsx|client/src/pages/MobileLeavePage.tsx|client/src/pages/MobilePurchaseRequestDetailPage.tsx|client/src/pages/MobileApprovalsPage.tsx|client/src/lib/runtimeContext.ts,["MIF7-3"],若实现需要新增后端接口或改状态机,停止并拆新 Issue。
|
||||||
MIF7-5,财务移动首页角色化摘要,"优化财务账号移动首页,突出财务待复核、待付款、台账/导出和风险异常入口,避免与经理首页同构。","1) 财务账号首页出现财务摘要或财务快捷入口;2) 普通员工不显示财务入口;3) 管理员单角色不默认获得财务业务能力;4) 财务首页仍使用同一移动框架。","流程级验证:Playwright 使用员工、审批人、财务、管理员账号访问 /mobile;验证财务摘要裁剪和入口权限;如直连 API 涉及权限补跑 governance 相关门禁。",playwright|npm|bash,TODO,TODO,TODO,client/src/pages/MobilePortalPage.tsx|client/src/api|client/src/auth/permissions.ts,["MIF7-4"],优先复用现有 FL6 财务接口;如必须新增后端接口则拆新 Issue。
|
MIF7-5,财务移动首页角色化摘要,"优化财务账号移动首页,突出财务待复核、待付款、台账/导出和风险异常入口,避免与经理首页同构。","1) 财务账号首页出现财务摘要或财务快捷入口;2) 普通员工不显示财务入口;3) 管理员单角色不默认获得财务业务能力;4) 财务首页仍使用同一移动框架。","流程级验证:Playwright 使用员工、审批人、财务、管理员账号访问 /mobile;验证财务摘要裁剪和入口权限;如直连 API 涉及权限补跑 governance 相关门禁。",playwright|npm|bash,DONE,DONE,DONE,client/src/pages/MobilePortalPage.tsx|client/src/api|client/src/auth/permissions.ts,["MIF7-4"],优先复用现有 FL6 财务接口;如必须新增后端接口则拆新 Issue。
|
||||||
MIF7-6,移动 IA 自动化断言和视觉矩阵,"补充入口重复、摘要数量重复、进度标签、通知直达、详情壳统一、返回目标和财务首页差异的自动化规则与截图矩阵。","1) 新增或更新 Playwright 用例覆盖 320/360/375/390/393/414/430;2) check-ui-design-rules 保持 strict 通过;3) 输出截图到 output/playwright/mobile-ia-flow-remediation/;4) 真机门禁 ME6-3 仍不被自动化替代。","流程级验证:cd client && npm run typecheck && npm run build;node scripts/qa/check-ui-design-rules.mjs --strict;移动专项 Playwright;git diff --check。",npm|node|playwright|git,TODO,TODO,TODO,client/tests/e2e|scripts/qa/check-ui-design-rules.mjs|output/playwright,["MIF7-5"],截图产物按项目规则处理;敏感截图不提交。
|
MIF7-6,移动 IA 自动化断言和视觉矩阵,"补充入口重复、摘要数量重复、进度标签、通知直达、详情壳统一、返回目标和财务首页差异的自动化规则与截图矩阵。","1) 新增或更新 Playwright 用例覆盖 320/360/375/390/393/414/430;2) check-ui-design-rules 保持 strict 通过;3) 输出截图到 output/playwright/mobile-ia-flow-remediation/;4) 真机门禁 ME6-3 仍不被自动化替代。","流程级验证:cd client && npm run typecheck && npm run build;node scripts/qa/check-ui-design-rules.mjs --strict;移动专项 Playwright;git diff --check。",npm|node|playwright|git,DONE,DONE,DONE,client/tests/e2e|scripts/qa/check-ui-design-rules.mjs|output/playwright,["MIF7-5"],截图产物按项目规则处理;敏感截图不提交。
|
||||||
MIF7-7,本地回归部署和真机复测清单,"完成 MIF7 本地回归、受控部署、公网复检,并给出 iPhone12/iPhone14/Redmi K60 企业微信复测清单。","1) 本地 typecheck/build、设计规则和移动专项通过;2) 部署公网后 /ready、public bundle、live smoke readiness 通过;3) docs/trialrun/manual-trialrun-report.md 记录 MIF7 收口;4) ME6-3 仅在代表性真机复测通过或用户明确接受风险后关闭。","流程级验证:bash deploy/trialrun/deploy.sh;curl -fsS https://oa.hyinda.com/ready;PUBLIC_BASE_URL=https://oa.hyinda.com node scripts/qa/check-public-bundle-copy.mjs;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;人工真机复测首页、进度、通知、详情返回和财务首页。",bash|curl|node|manual,TODO,TODO,TODO,deploy/trialrun|docs/trialrun/manual-trialrun-report.md,["MIF7-6"],面向用户可测改动必须部署后再提示公网/企业微信验证。
|
MIF7-7,本地回归部署和真机复测清单,"完成 MIF7 本地回归、受控部署、公网复检,并给出 iPhone12/iPhone14/Redmi K60 企业微信复测清单。","1) 本地 typecheck/build、设计规则和移动专项通过;2) 部署公网后 /ready、public bundle、live smoke readiness 通过;3) docs/trialrun/manual-trialrun-report.md 记录 MIF7 收口;4) ME6-3 仅在代表性真机复测通过或用户明确接受风险后关闭。","流程级验证:bash deploy/trialrun/deploy.sh;curl -fsS https://oa.hyinda.com/ready;PUBLIC_BASE_URL=https://oa.hyinda.com node scripts/qa/check-public-bundle-copy.mjs;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;人工真机复测首页、进度、通知、详情返回和财务首页。",bash|curl|node|manual,TODO,TODO,TODO,deploy/trialrun|docs/trialrun/manual-trialrun-report.md,["MIF7-6"],面向用户可测改动必须部署后再提示公网/企业微信验证。
|
||||||
|
|||||||
|
无法渲染此文件,因为它包含了意外字符,其位于第 3 行和第 719 列。
|
在新工单中引用
屏蔽一个用户