修复 R7E 真机反馈回流与移动入口 WF8-11A R7E-16A R7E-16B BIZ8-12A
这个提交包含在:
@@ -9,6 +9,7 @@ import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Spinner } from "../components/ui/spinner";
|
||||
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
|
||||
import { withUploadTimeout } from "../lib/uploadTimeout";
|
||||
import {
|
||||
enqueueInvoiceUpload,
|
||||
@@ -145,6 +146,17 @@ export function MobileCapturePage() {
|
||||
|
||||
const queuedItems = queueQuery.data ?? [];
|
||||
|
||||
function handlePdfSelection(files: FileList | null) {
|
||||
const { accepted, rejected } = splitPdfFiles(files);
|
||||
setSelectedFiles(accepted);
|
||||
const rejectMessage = rejectedPdfMessage(rejected);
|
||||
setError(rejectMessage);
|
||||
setMessage("");
|
||||
if (rejectMessage) {
|
||||
toast.warning(rejectMessage);
|
||||
}
|
||||
}
|
||||
|
||||
const queuedCountLabel = useMemo(() => {
|
||||
if (!queuedItems.length) return "无待上传项";
|
||||
return `待上传 ${queuedItems.length} 项`;
|
||||
@@ -210,12 +222,13 @@ export function MobileCapturePage() {
|
||||
</label>
|
||||
<input
|
||||
id="mobile-capture-pdf-file"
|
||||
accept="application/pdf"
|
||||
accept={PDF_FILE_ACCEPT}
|
||||
className="sr-only"
|
||||
data-testid="mobile-capture-pdf-file"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
setSelectedFiles(Array.from(event.target.files ?? []));
|
||||
handlePdfSelection(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fetchEmployeeProfile } from "../api/employees";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
|
||||
import { hasAnyPermission, hasBusinessApproverRole, hasFinanceRole } from "../auth/permissions";
|
||||
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
@@ -65,6 +65,7 @@ export function MobileMePage() {
|
||||
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
|
||||
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
|
||||
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||
const canAccessFinance = hasFinanceRole(me?.roleCodes, permissions);
|
||||
|
||||
const profileQuery = useQuery({
|
||||
enabled: Boolean(me?.id),
|
||||
@@ -128,6 +129,7 @@ export function MobileMePage() {
|
||||
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
|
||||
{canAccessPurchase ? <MobileActionLink to="/mobile/purchase-requests" label="采购" icon="shopping_cart" /> : null}
|
||||
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "审批" : "审批进度"} icon="fact_check" />
|
||||
{canAccessFinance ? <MobileActionLink to="/mobile#finance" label="财务" description="审批与台账" icon="account_balance_wallet" /> : null}
|
||||
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
|
||||
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
|
||||
</div>
|
||||
|
||||
@@ -257,6 +257,7 @@ export function MobilePortalPage() {
|
||||
.filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_")))
|
||||
.slice(0, 3);
|
||||
const financeSummary = portalHomeQuery.data?.financeSummary ?? null;
|
||||
const paymentBatchEnabled = Boolean(financeSummary?.paymentBatchEnabled);
|
||||
|
||||
async function openHomeNotification(item: NotificationItem) {
|
||||
const targetLink = resolveMobileNotificationTargetLink(item);
|
||||
@@ -378,10 +379,10 @@ export function MobilePortalPage() {
|
||||
) : null}
|
||||
|
||||
{canUseFinance ? (
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
|
||||
<section id="finance" className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
|
||||
<div className="mobile-section-header">
|
||||
<div className="mobile-section-title">财务</div>
|
||||
<span className="badge-neutral">复核与付款</span>
|
||||
<span className="badge-neutral">{paymentBatchEnabled ? "复核与付款" : "审批与台账"}</span>
|
||||
</div>
|
||||
{portalHomeQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
|
||||
{portalHomeQuery.isError ? (
|
||||
@@ -389,22 +390,30 @@ export function MobilePortalPage() {
|
||||
{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)}
|
||||
{!portalHomeQuery.isLoading && !portalHomeQuery.isError && paymentBatchEnabled ? (
|
||||
<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>
|
||||
<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>
|
||||
) : null}
|
||||
{!portalHomeQuery.isLoading && !portalHomeQuery.isError && !paymentBatchEnabled ? (
|
||||
<div className="mobile-subcard text-sm text-zinc-600" data-testid="mobile-finance-disabled-state">
|
||||
<div className="font-medium text-zinc-900">付款批次未启用</div>
|
||||
<div className="mt-1 leading-5">可查看已流转到财务审批的报销单,待付款和异常批次暂不展示。</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from "../components/ui/dialog";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
import {
|
||||
formatInvoiceDisplayTitle,
|
||||
@@ -224,6 +225,16 @@ export function MobileReimbursementDetailPage() {
|
||||
const canManageAttachments = Boolean(canEdit && (isParticipant || canViewAllExpense));
|
||||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||||
|
||||
function handleInvoicePdfSelection(files: FileList | null) {
|
||||
const { accepted, rejected } = splitPdfFiles(files);
|
||||
setSelectedInvoiceFiles(accepted);
|
||||
const message = rejectedPdfMessage(rejected);
|
||||
setInvoiceActionError(message);
|
||||
if (message) {
|
||||
toast.warning(message);
|
||||
}
|
||||
}
|
||||
|
||||
const bindableInvoicesQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId && isInvoiceDialogOpen && canOpenInvoiceDialog),
|
||||
queryKey: ["invoice-pool", "bindable", reimbursementId, invoiceSearch],
|
||||
@@ -1266,9 +1277,12 @@ export function MobileReimbursementDetailPage() {
|
||||
className="sr-only"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
accept={PDF_FILE_ACCEPT}
|
||||
multiple
|
||||
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
|
||||
onChange={(event) => {
|
||||
handleInvoicePdfSelection(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{selectedInvoiceFiles.length ? (
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
|
||||
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
||||
import {
|
||||
ReimbursementItemEditor,
|
||||
@@ -806,6 +807,16 @@ export function ReimbursementDetailPage() {
|
||||
const submitInvoiceNotice = canEditItems && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
|
||||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||||
|
||||
function handleInvoicePdfSelection(files: FileList | null) {
|
||||
const { accepted, rejected } = splitPdfFiles(files);
|
||||
setSelectedInvoiceFiles(accepted);
|
||||
const message = rejectedPdfMessage(rejected);
|
||||
setActionError(message);
|
||||
if (message) {
|
||||
toast.warning(message);
|
||||
}
|
||||
}
|
||||
|
||||
const expenseTypes = expenseTypesQuery.data?.items ?? [];
|
||||
|
||||
const computeEditorDefaults = (item?: ReimbursementItem): ItemEditorInitialValues => {
|
||||
@@ -1730,9 +1741,12 @@ export function ReimbursementDetailPage() {
|
||||
className="hidden"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
accept={PDF_FILE_ACCEPT}
|
||||
multiple
|
||||
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
|
||||
onChange={(event) => {
|
||||
handleInvoicePdfSelection(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FetchMyDelegationsResponse } from "../../api/delegations";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { PDF_FILE_ACCEPT, splitPdfFiles } from "../../lib/pdfFiles";
|
||||
|
||||
export type InvoiceUploadCardProps = {
|
||||
selectedFiles: File[];
|
||||
@@ -62,10 +63,13 @@ export function InvoiceUploadCard({
|
||||
<input
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
accept="application/pdf"
|
||||
accept={PDF_FILE_ACCEPT}
|
||||
data-testid="invoice-upload-file"
|
||||
multiple
|
||||
onChange={(event) => setSelectedFiles(Array.from(event.target.files ?? []))}
|
||||
onChange={(event) => {
|
||||
setSelectedFiles(splitPdfFiles(event.target.files).accepted);
|
||||
event.target.value = "";
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户