修复 R7E 真机反馈回流与移动入口 WF8-11A R7E-16A R7E-16B BIZ8-12A
这个提交包含在:
@@ -83,6 +83,7 @@ export type PortalHomePayload = {
|
||||
pendingBatchCount: number;
|
||||
failedItemCount: number;
|
||||
riskCount: number;
|
||||
paymentBatchEnabled?: boolean;
|
||||
href: string;
|
||||
} | null;
|
||||
adoptionSummary:
|
||||
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
UserCog,
|
||||
UserRound,
|
||||
Users,
|
||||
WalletCards,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const MOBILE_ICON_MAP: Record<string, LucideIcon> = {
|
||||
account_tree: Network,
|
||||
account_balance_wallet: WalletCards,
|
||||
badge: IdCard,
|
||||
cloud_upload: CloudUpload,
|
||||
event_available: CalendarCheck,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const PDF_FILE_ACCEPT = ".pdf,application/pdf";
|
||||
|
||||
export function isPdfFile(file: File) {
|
||||
return file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
export function splitPdfFiles(files: File[] | FileList | null | undefined) {
|
||||
const source = Array.from(files ?? []);
|
||||
return {
|
||||
accepted: source.filter(isPdfFile),
|
||||
rejected: source.filter((file) => !isPdfFile(file)),
|
||||
};
|
||||
}
|
||||
|
||||
export function rejectedPdfMessage(rejected: File[]) {
|
||||
if (!rejected.length) {
|
||||
return "";
|
||||
}
|
||||
const names = rejected.slice(0, 3).map((file) => file.name || "未命名文件").join("、");
|
||||
const suffix = rejected.length > 3 ? `等 ${rejected.length} 个文件` : "";
|
||||
return `发票上传仅支持 PDF,已忽略 ${names}${suffix}。`;
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -253,8 +253,8 @@
|
||||
|
||||
.mobile-list-card__header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
gap: 0.5rem;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -281,10 +281,17 @@
|
||||
}
|
||||
|
||||
.mobile-status-slot {
|
||||
flex: none;
|
||||
max-width: 42%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-flex;
|
||||
min-width: max-content;
|
||||
max-width: min(46vw, 8rem);
|
||||
justify-self: end;
|
||||
overflow: visible;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-status-slot > * {
|
||||
min-width: max-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户