feat(P25-2/P25-3/P26-2/P28-1/P24-2/P29-3): 落地内部OA够用版前端入口与运营页面

关联 Issue: P25-2, P25-3, P25-4, P26-2, P26-4, P28-1, P28-2, P28-3, P28-4, P24-2, P29-3
这个提交包含在:
2026-03-21 18:03:48 +08:00
父节点 e39e41287e
当前提交 9905c1c79d
共修改 44 个文件,包含 2029 行新增和 169 行删除
+74
查看文件
@@ -0,0 +1,74 @@
import { useQuery } from "@tanstack/react-query";
import { fetchApOperations } from "../api/apOperations";
import { PageHeader } from "../components/common/PageHeader";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
export function ApOperationsPage() {
const summaryQuery = useQuery({
queryKey: ["ap-operations"],
queryFn: () => fetchApOperations(),
});
if (summaryQuery.isLoading) {
return <div className="card-std p-6 text-sm text-zinc-500">加载 AP 运营视图...</div>;
}
if (summaryQuery.isError || !summaryQuery.data) {
return <div className="card-std p-6 text-sm text-red-600">AP 运营视图加载失败。</div>;
}
const data = summaryQuery.data;
return (
<div className="space-y-6">
<PageHeader title="AP 运营" subtitle="待支付、失败批次、风险项与差旅对账异常。" />
<div className="grid gap-4 md:grid-cols-4">
<div className="card-std p-5"><div className="text-sm text-zinc-500">开放批次</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.openPaymentBatchCount}</div></div>
<div className="card-std p-5"><div className="text-sm text-zinc-500">失败批次项</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.failedPaymentBatchItemCount}</div></div>
<div className="card-std p-5"><div className="text-sm text-zinc-500">开放风险</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.openRiskFlagCount}</div></div>
<div className="card-std p-5"><div className="text-sm text-zinc-500">对账异常</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.reconciliationAnomalyCount}</div></div>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">开放批次</h3>
<div className="space-y-2 text-sm">
{data.openPaymentBatches.length ? data.openPaymentBatches.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3">
<div className="font-medium text-zinc-900">{item.batchNo}</div>
<div className="text-zinc-600">{item.name}</div>
<div className="text-xs text-zinc-500">{item.entityType} · {item.status}</div>
</div>
)) : <div className="text-zinc-500">暂无开放批次</div>}
</div>
</div>
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">失败批次项</h3>
<div className="space-y-2 text-sm">
{data.failedPaymentBatchItems.length ? data.failedPaymentBatchItems.map((item) => (
<div key={item.id} className="rounded-lg border border-red-200 bg-red-50 px-3 py-3">
<div className="font-medium text-zinc-900">{item.entityCode}</div>
<div className="text-zinc-600">{item.failureMessage ?? item.failureCode ?? "执行失败"}</div>
</div>
)) : <div className="text-zinc-500">暂无失败项</div>}
</div>
</div>
</div>
<RiskFlagsCard items={data.openRiskFlags} title="开放风险项" />
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">差旅对账异常</h3>
<div className="space-y-2 text-sm">
{data.reconciliationAnomalies.length ? data.reconciliationAnomalies.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3">
<div className="font-medium text-zinc-900">{item.anomalyCode ?? item.matchStatus}</div>
<div className="text-zinc-600">订单 {item.orderAmount ?? "-"} / 交易 {item.transactionAmount ?? "-"} {item.currency}</div>
</div>
)) : <div className="text-zinc-500">暂无异常</div>}
</div>
</div>
</div>
);
}
+6
查看文件
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
@@ -147,6 +148,11 @@ export function ApprovalRulesPage() {
<PageHeader
title="审批规则"
subtitle="维护金额段与审批层级(仅影响新提交的报销单,历史流程不回写)。"
actions={
<Button asChild variant="secondary">
<Link to="/system/workflow-definitions">查看流程定义</Link>
</Button>
}
/>
{error ? (
+115
查看文件
@@ -0,0 +1,115 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createCutoverScope, dryRunMigration, fetchCutoverScopes, fetchMigrationRuns, updateCutoverScope } from "../api/cutover";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { toast } from "sonner";
export function CutoverConsolePage() {
const queryClient = useQueryClient();
const [scopeType, setScopeType] = useState<"department" | "legal_entity" | "domain">("department");
const [scopeKey, setScopeKey] = useState("");
const [notes, setNotes] = useState("");
const [dryRunType, setDryRunType] = useState<"employees" | "departments" | "budgets" | "masterdata_validation">("employees");
const [dryRunFile, setDryRunFile] = useState<File | null>(null);
const scopesQuery = useQuery({ queryKey: ["cutover-scopes"], queryFn: () => fetchCutoverScopes() });
const runsQuery = useQuery({ queryKey: ["migration-runs"], queryFn: () => fetchMigrationRuns() });
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ["cutover-scopes"] });
await queryClient.invalidateQueries({ queryKey: ["migration-runs"] });
};
const createScopeMutation = useMutation({
mutationFn: () => createCutoverScope({ scopeType, scopeKey, notes, enabled: false }),
onSuccess: async () => {
setScopeKey("");
setNotes("");
await refresh();
toast.success("切换范围已创建");
},
});
const toggleScopeMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => updateCutoverScope(id, { enabled }),
onSuccess: async () => {
await refresh();
toast.success("切换范围已更新");
},
});
const dryRunMutation = useMutation({
mutationFn: async () => {
if (!dryRunFile) throw new Error("请先选择 CSV 文件");
return dryRunMigration({ runType: dryRunType, file: dryRunFile });
},
onSuccess: async () => {
setDryRunFile(null);
await refresh();
toast.success("dry-run 已完成");
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "dry-run 失败");
},
});
return (
<div className="space-y-6">
<PageHeader title="切换控制台" subtitle="试点范围、dry-run 差异和回退前置准备。" />
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
<div className="space-y-6">
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">新增切换范围</h3>
<div className="space-y-2"><Label>范围类型</Label><select className="select-std" value={scopeType} onChange={(event) => setScopeType(event.target.value as "department" | "legal_entity" | "domain")}><option value="department">department</option><option value="legal_entity">legal_entity</option><option value="domain">domain</option></select></div>
<div className="space-y-2"><Label>范围 Key</Label><Input value={scopeKey} onChange={(event) => setScopeKey(event.target.value)} placeholder="部门 ID / 法体 ID / expense" /></div>
<div className="space-y-2"><Label>备注</Label><Input value={notes} onChange={(event) => setNotes(event.target.value)} /></div>
<Button type="button" onClick={() => createScopeMutation.mutate()}>创建范围</Button>
</div>
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">上传 dry-run CSV</h3>
<div className="space-y-2"><Label>runType</Label><select className="select-std" value={dryRunType} onChange={(event) => setDryRunType(event.target.value as typeof dryRunType)}><option value="employees">employees</option><option value="departments">departments</option><option value="budgets">budgets</option><option value="masterdata_validation">masterdata_validation</option></select></div>
<Input type="file" accept=".csv,text/csv" onChange={(event) => setDryRunFile(event.target.files?.[0] ?? null)} />
<Button type="button" onClick={() => dryRunMutation.mutate()} disabled={dryRunMutation.isPending}>{dryRunMutation.isPending ? "执行中..." : "执行 dry-run"}</Button>
</div>
</div>
<div className="space-y-6">
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">切换范围</h3>
<div className="space-y-3">
{(scopesQuery.data?.items ?? []).map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium text-zinc-900">{item.scopeType} · {item.scopeKey}</div>
<div className="text-sm text-zinc-600">{item.notes ?? "-"}</div>
<div className="text-xs text-zinc-500">{item.enabled ? "enabled" : "disabled"} · startsAt={item.startsAt ?? "-"}</div>
</div>
<Button type="button" variant="secondary" onClick={() => toggleScopeMutation.mutate({ id: item.id, enabled: !item.enabled })}>{item.enabled ? "关闭" : "启用"}</Button>
</div>
</div>
))}
</div>
</div>
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">最近 dry-run</h3>
<div className="space-y-3">
{(runsQuery.data?.items ?? []).map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
<div className="font-medium text-zinc-900">{item.runType} · {item.status}</div>
<div className="text-sm text-zinc-600">{item.sourceFilename}</div>
<div className="mt-1 text-xs text-zinc-500">{item.createdAt ?? "-"}</div>
</div>
))}
{!runsQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无 dry-run 记录</div> : null}
</div>
</div>
</div>
</div>
</div>
);
}
+9 -1
查看文件
@@ -5,6 +5,7 @@ import {
fetchFeatureFlags,
updateFeatureFlag,
type FeatureFlagItem,
type FeatureFlagKey,
type FeatureFlagOverrideMode,
type FeatureFlagRolloutType,
} from "../api/featureFlags";
@@ -32,6 +33,13 @@ function toDisplayName(flagKey: string) {
if (flagKey === "erp_sync") return "ERP 同步";
if (flagKey === "invoice_verification") return "发票验真";
if (flagKey === "leave_holiday_calendar") return "请假日历增强";
if (flagKey === "budget_precheck_v1") return "预算预校验";
if (flagKey === "payment_batch_v1") return "支付批次";
if (flagKey === "portal_home_v1") return "Portal 首页";
if (flagKey === "global_search_v1") return "全局搜索";
if (flagKey === "announcements_v1") return "公告中心";
if (flagKey === "workflow_definition_admin_v1") return "流程定义治理";
if (flagKey === "cutover_console_v1") return "切换控制台";
return flagKey;
}
@@ -79,7 +87,7 @@ export function FeatureFlagsPage() {
throw new Error("未找到待提交草稿");
}
return updateFeatureFlag(flagKey as "erp_sync" | "invoice_verification" | "leave_holiday_calendar", {
return updateFeatureFlag(flagKey as FeatureFlagKey, {
overrideMode: draft.overrideMode,
rolloutType: draft.rolloutType,
departmentIds:
+26 -2
查看文件
@@ -1,5 +1,7 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom";
import { fetchSsoProviders } from "../api/auth";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
@@ -10,11 +12,11 @@ import { Label } from "../components/ui/label";
function resolveRedirect(searchParams: URLSearchParams) {
const redirect = searchParams.get("redirect");
if (!redirect) {
return "/dashboard";
return "/portal";
}
if (!redirect.startsWith("/")) {
return "/dashboard";
return "/portal";
}
return redirect;
@@ -31,6 +33,12 @@ export function LoginPage() {
const [error, setError] = useState("");
const redirectTo = resolveRedirect(searchParams);
const ssoProvidersQuery = useQuery({
queryKey: ["auth", "sso-providers"],
queryFn: () => fetchSsoProviders(),
staleTime: 60_000,
});
const oidcProvider = (ssoProvidersQuery.data ?? []).find((item) => item.id === "oidc" && item.enabled);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -105,6 +113,22 @@ export function LoginPage() {
{submitting ? "登录中..." : "登录"}
</Button>
</form>
{oidcProvider ? (
<div className="space-y-3 border-t border-zinc-200 pt-4">
<p className="text-xs uppercase tracking-wide text-zinc-500">企业统一入口</p>
<Button
className="w-full"
type="button"
variant="secondary"
onClick={() => {
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
}}
>
使用{oidcProvider.label}
</Button>
</div>
) : null}
</div>
);
}
+78 -40
查看文件
@@ -28,6 +28,7 @@ import {
removeWorkflowActionDraft,
resolveWorkflowTaskReferenceSnapshot,
updateWorkflowActionDraftStatus,
type WorkflowDraftAction,
type WorkflowDraftQueueItem,
} from "../mobile/workflowActionQueue";
@@ -107,6 +108,7 @@ export function MobileApprovalsPage() {
const [comment, setComment] = useState("");
const [transferToApproverId, setTransferToApproverId] = useState("");
const [transferReason, setTransferReason] = useState("");
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
const [isOnline, setIsOnline] = useState<boolean>(typeof navigator === "undefined" ? true : navigator.onLine);
const autoSyncRequestedRef = useRef(false);
@@ -192,14 +194,63 @@ export function MobileApprovalsPage() {
queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 100 }),
});
async function refreshDraftQueueCache() {
if (!ownerEmployeeId) {
return [];
}
const drafts = await listWorkflowActionDrafts(ownerEmployeeId);
queryClient.setQueryData(["mobile", "workflow", "draft-queue", ownerEmployeeId], drafts);
return drafts;
}
const currentQueueItems = queueQuery.data ?? [];
const retryableQueueItems = useMemo(
() => currentQueueItems.filter((item) => item.status !== "conflicted"),
[currentQueueItems],
);
async function queueOfflineDraft(action: WorkflowDraftAction) {
try {
if (!selectedTaskId) {
throw new Error("请选择任务");
}
if (!selectedTask) {
throw new Error("当前待办尚未缓存,请联网加载后再操作");
}
if (!selectedTask.allowedActions.includes(action)) {
throw new Error("当前待办不允许该动作");
}
if (action === "reject" && !comment.trim()) {
throw new Error("驳回必须填写意见");
}
setOfflineDraftSubmitting(true);
await enqueueWorkflowActionDraft({
taskId: selectedTaskId,
action,
comment: action === "reject" ? comment.trim() : null,
ownerEmployeeId,
detail: selectedTask,
});
await refreshDraftQueueCache();
setComment("");
setError("");
setMessage(action === "approve" ? "当前离线,通过草稿已加入待同步队列。" : "当前离线,驳回草稿已加入待同步队列。");
toast.success(action === "approve" ? "已加入通过草稿队列" : "已加入驳回草稿队列");
} catch (actionError) {
const nextError = resolveErrorMessage(actionError);
setError(nextError);
setMessage("");
toast.error(nextError);
} finally {
setOfflineDraftSubmitting(false);
}
}
const actionMutation = useMutation({
mutationFn: async (action: MobileAction) => {
if (!canUseRemote) {
throw new Error("当前离线,请使用离线草稿动作");
}
if (!selectedTaskId) {
throw new Error("请选择任务");
}
@@ -210,28 +261,9 @@ export function MobileApprovalsPage() {
throw new Error("当前待办不允许该动作");
}
if (!canUseRemote) {
if (action === "return" || action === "transfer") {
throw new Error("离线仅支持通过 / 驳回草稿,退回与转审需联网执行");
}
if (action === "reject" && !comment.trim()) {
throw new Error("驳回必须填写意见");
}
await enqueueWorkflowActionDraft({
taskId: selectedTaskId,
action,
comment: action === "reject" ? comment.trim() : null,
ownerEmployeeId,
detail: selectedTask,
});
return { mode: "queued" as const, action };
}
if (action === "approve") {
await approveWorkflowTask(selectedTaskId);
return { mode: "submitted" as const, action };
return { action };
}
if (action === "reject") {
@@ -239,7 +271,7 @@ export function MobileApprovalsPage() {
throw new Error("驳回必须填写意见");
}
await rejectWorkflowTask(selectedTaskId, comment.trim());
return { mode: "submitted" as const, action };
return { action };
}
if (action === "return") {
@@ -247,7 +279,7 @@ export function MobileApprovalsPage() {
throw new Error("退回必须填写意见");
}
await returnWorkflowTask(selectedTaskId, comment.trim());
return { mode: "submitted" as const, action };
return { action };
}
if (!transferToApproverId) {
@@ -258,17 +290,9 @@ export function MobileApprovalsPage() {
}
await transferWorkflowTask(selectedTaskId, transferToApproverId, transferReason.trim());
return { mode: "submitted" as const, action };
return { action };
},
onSuccess: async (result) => {
if (result.mode === "queued") {
await queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId] });
setError("");
setMessage(result.action === "approve" ? "当前离线,通过草稿已加入待同步队列。" : "当前离线,驳回草稿已加入待同步队列。");
toast.success(result.action === "approve" ? "已加入通过草稿队列" : "已加入驳回草稿队列");
return;
}
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-detail", selectedTaskId] }),
@@ -289,6 +313,8 @@ export function MobileApprovalsPage() {
},
});
const isActionSubmitting = actionMutation.isPending || offlineDraftSubmitting;
const syncMutation = useMutation({
mutationFn: async (items: WorkflowDraftQueueItem[]) => {
let succeeded = 0;
@@ -524,16 +550,28 @@ export function MobileApprovalsPage() {
<div className="flex flex-wrap gap-2">
<Button
data-testid="mobile-approvals-approve"
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("approve")}
onClick={() => actionMutation.mutate("approve")}
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
onClick={() => {
if (canUseRemote) {
actionMutation.mutate("approve");
return;
}
void queueOfflineDraft("approve");
}}
type="button"
>
{canUseRemote ? "通过" : "离线加入通过草稿"}
</Button>
<Button
data-testid="mobile-approvals-reject"
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("reject")}
onClick={() => actionMutation.mutate("reject")}
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
onClick={() => {
if (canUseRemote) {
actionMutation.mutate("reject");
return;
}
void queueOfflineDraft("reject");
}}
type="button"
variant="secondary"
>
@@ -541,7 +579,7 @@ export function MobileApprovalsPage() {
</Button>
<Button
data-testid="mobile-approvals-return"
disabled={actionMutation.isPending || !canUseRemote || !selectedTask.allowedActions.includes("return")}
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
onClick={() => actionMutation.mutate("return")}
type="button"
variant="secondary"
@@ -577,7 +615,7 @@ export function MobileApprovalsPage() {
<Button
data-testid="mobile-approvals-transfer"
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("transfer")}
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")}
onClick={() => actionMutation.mutate("transfer")}
type="button"
variant="secondary"
@@ -643,7 +681,7 @@ export function MobileApprovalsPage() {
status: "queued",
lastError: null,
});
await queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId] });
await refreshDraftQueueCache();
if (canUseRemote) {
syncMutation.mutate([{ ...item, status: "queued", lastError: null }]);
}
@@ -658,7 +696,7 @@ export function MobileApprovalsPage() {
variant="secondary"
onClick={async () => {
await removeWorkflowActionDraft(item.taskId, ownerEmployeeId);
await queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId] });
await refreshDraftQueueCache();
}}
>
丢弃草稿
+275
查看文件
@@ -0,0 +1,275 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
cancelPaymentBatch,
createPaymentBatch,
executePaymentBatch,
fetchPaymentBatches,
reviewPaymentBatch,
updatePaymentBatchItem,
type PaymentBatch,
type PaymentBatchEntityType,
type PaymentChannel,
} from "../api/paymentBatches";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { toast } from "sonner";
const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"];
function resolveError(error: unknown) {
return isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "操作失败";
}
export function PaymentBatchesPage() {
const queryClient = useQueryClient();
const [selectedBatchId, setSelectedBatchId] = useState("");
const [entityType, setEntityType] = useState<PaymentBatchEntityType>("reimbursement");
const [name, setName] = useState("");
const [entityIds, setEntityIds] = useState("");
const [defaultPaymentDate, setDefaultPaymentDate] = useState("");
const [defaultPaymentChannel, setDefaultPaymentChannel] = useState<PaymentChannel | "">("");
const [voucherPrefix, setVoucherPrefix] = useState("");
const [error, setError] = useState("");
const listQuery = useQuery({
queryKey: ["payment-batches"],
queryFn: () => fetchPaymentBatches(),
});
const selectedBatch = useMemo<PaymentBatch | null>(() => {
const items = listQuery.data?.items ?? [];
return items.find((item) => item.id === selectedBatchId) ?? items[0] ?? null;
}, [listQuery.data?.items, selectedBatchId]);
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ["payment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["ap-operations"] });
};
const createMutation = useMutation({
mutationFn: async () =>
createPaymentBatch({
entityType,
name: name.trim() || `${entityType}-batch`,
entityIds: entityIds.split(/[\s,]+/).map((item) => item.trim()).filter(Boolean),
defaultPaymentDate: defaultPaymentDate || undefined,
defaultPaymentChannel: defaultPaymentChannel || undefined,
voucherPrefix: voucherPrefix || undefined,
}),
onSuccess: async ({ item }) => {
setError("");
setSelectedBatchId(item.id);
await refresh();
toast.success("付款批次已创建");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const reviewMutation = useMutation({
mutationFn: async (batchId: string) => {
const batch = listQuery.data?.items.find((item) => item.id === batchId);
const resolveRiskFlagIds = (batch?.items ?? []).flatMap((item) => item.openRiskFlags.map((flag) => flag.id));
return reviewPaymentBatch(batchId, { resolveRiskFlagIds });
},
onSuccess: async () => {
setError("");
await refresh();
toast.success("付款批次已复核");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const executeMutation = useMutation({
mutationFn: executePaymentBatch,
onSuccess: async () => {
setError("");
await refresh();
toast.success("付款批次已执行");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const cancelMutation = useMutation({
mutationFn: cancelPaymentBatch,
onSuccess: async () => {
setError("");
await refresh();
toast.success("付款批次已取消");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const updateItemMutation = useMutation({
mutationFn: async (payload: { batchId: string; itemId: string; paymentVoucherNoOverride?: string | null; paymentDateOverride?: string | null; paymentChannelOverride?: PaymentChannel | null }) =>
updatePaymentBatchItem(payload.batchId, payload.itemId, {
paymentVoucherNoOverride: payload.paymentVoucherNoOverride,
paymentDateOverride: payload.paymentDateOverride,
paymentChannelOverride: payload.paymentChannelOverride,
}),
onSuccess: async () => {
setError("");
await refresh();
toast.success("批次项已更新");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
return (
<div className="space-y-6">
<PageHeader title="支付批次" subtitle="财务批量付款、复核与执行。" />
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3>
<div className="space-y-2">
<Label>实体类型</Label>
<select className="select-std" value={entityType} onChange={(event) => setEntityType(event.target.value as PaymentBatchEntityType)}>
<option value="reimbursement">报销付款</option>
<option value="travel_order">差旅订单付款</option>
</select>
</div>
<div className="space-y-2">
<Label>批次名称</Label>
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:2026Q2-财务付款批次-01" />
</div>
<div className="space-y-2">
<Label>实体 ID</Label>
<textarea className="min-h-28 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm" value={entityIds} onChange={(event) => setEntityIds(event.target.value)} placeholder="用逗号或换行分隔 reimbursement / travel_order ID" />
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>默认付款日期</Label>
<Input type="date" value={defaultPaymentDate} onChange={(event) => setDefaultPaymentDate(event.target.value)} />
</div>
<div className="space-y-2">
<Label>默认渠道</Label>
<select className="select-std" value={defaultPaymentChannel} onChange={(event) => setDefaultPaymentChannel((event.target.value as PaymentChannel | "") || "") }>
<option value="">不设置</option>
{PAYMENT_CHANNEL_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</div>
</div>
<div className="space-y-2">
<Label>凭证前缀</Label>
<Input value={voucherPrefix} onChange={(event) => setVoucherPrefix(event.target.value)} placeholder="例如 PBX" />
</div>
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "创建中..." : "创建批次"}
</Button>
</div>
<div className="space-y-6">
<div className="card-std p-6">
<h3 className="mb-3 text-base font-semibold text-zinc-900">批次列表</h3>
<div className="space-y-2">
{(listQuery.data?.items ?? []).map((item) => (
<button
key={item.id}
type="button"
className={selectedBatch?.id === item.id ? "w-full rounded-lg border border-primary bg-primary/5 px-3 py-3 text-left" : "w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"}
onClick={() => setSelectedBatchId(item.id)}
>
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.batchNo}</div>
<div className="text-xs uppercase text-zinc-500">{item.status}</div>
</div>
<div className="mt-1 text-sm text-zinc-600">{item.name}</div>
</button>
))}
{!listQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无支付批次</div> : null}
</div>
</div>
{selectedBatch ? (
<div className="space-y-6">
<div className="card-std space-y-3 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs text-zinc-500">{selectedBatch.batchNo}</div>
<div className="text-lg font-semibold text-zinc-900">{selectedBatch.name}</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" disabled={reviewMutation.isPending} onClick={() => reviewMutation.mutate(selectedBatch.id)}>
复核
</Button>
<Button type="button" disabled={executeMutation.isPending} onClick={() => executeMutation.mutate(selectedBatch.id)}>
执行
</Button>
<Button type="button" variant="secondary" disabled={cancelMutation.isPending} onClick={() => cancelMutation.mutate(selectedBatch.id)}>
取消
</Button>
</div>
</div>
<div className="grid gap-3 md:grid-cols-4 text-sm">
<div>状态:{selectedBatch.status}</div>
<div>实体:{selectedBatch.entityType}</div>
<div>默认日期:{selectedBatch.defaultPaymentDate ?? "-"}</div>
<div>默认渠道:{selectedBatch.defaultPaymentChannel ?? "-"}</div>
</div>
</div>
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">批次项</h3>
<div className="space-y-4">
{selectedBatch.items.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-medium text-zinc-900">{item.entityCode}</div>
<div className="text-sm text-zinc-600">{item.entityTitle}</div>
</div>
<div className="text-sm text-zinc-600">{item.amount} {item.currencyCode} · {item.status}</div>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-4">
<Input defaultValue={item.paymentVoucherNoOverride ?? ""} placeholder="凭证号" onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })} />
<Input type="date" defaultValue={item.paymentDateOverride ?? ""} onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })} />
<select className="select-std" defaultValue={item.paymentChannelOverride ?? ""} onChange={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentChannelOverride: (event.target.value as PaymentChannel | "") || null })}>
<option value="">默认渠道</option>
{PAYMENT_CHANNEL_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">失败:{item.failureMessage ?? "-"}</div>
</div>
<RiskFlagsCard items={item.openRiskFlags} title="关联开放风险" />
</div>
))}
</div>
</div>
</div>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,84 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { archiveAnnouncement, createAnnouncement, fetchAnnouncements, updateAnnouncement } from "../api/announcements";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { toast } from "sonner";
export function PortalAnnouncementsPage() {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const [summary, setSummary] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [priority, setPriority] = useState("100");
const listQuery = useQuery({ queryKey: ["announcements", "manage"], queryFn: () => fetchAnnouncements(true) });
const refresh = () => queryClient.invalidateQueries({ queryKey: ["announcements"] });
const createMutation = useMutation({
mutationFn: () => createAnnouncement({ title, summary, linkUrl: linkUrl || null, priority: Number(priority || 100) }),
onSuccess: async () => {
setTitle("");
setSummary("");
setLinkUrl("");
setPriority("100");
await refresh();
toast.success("公告已创建");
},
});
const archiveMutation = useMutation({
mutationFn: archiveAnnouncement,
onSuccess: async () => {
await refresh();
toast.success("公告已归档");
},
});
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => updateAnnouncement(id, { enabled }),
onSuccess: async () => {
await refresh();
toast.success("公告状态已更新");
},
});
return (
<div className="space-y-6">
<PageHeader title="公告管理" subtitle="轻量发布公告、制度链接和 FAQ 入口。" />
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">新建公告</h3>
<div className="space-y-2"><Label>标题</Label><Input value={title} onChange={(event) => setTitle(event.target.value)} /></div>
<div className="space-y-2"><Label>摘要</Label><textarea className="min-h-28 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm" value={summary} onChange={(event) => setSummary(event.target.value)} /></div>
<div className="space-y-2"><Label>链接</Label><Input value={linkUrl} onChange={(event) => setLinkUrl(event.target.value)} /></div>
<div className="space-y-2"><Label>优先级</Label><Input value={priority} onChange={(event) => setPriority(event.target.value)} /></div>
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>{createMutation.isPending ? "创建中..." : "创建公告"}</Button>
</div>
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">公告列表</h3>
<div className="space-y-3">
{(listQuery.data?.items ?? []).map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="mt-1 text-sm text-zinc-600">{item.summary}</div>
<div className="mt-1 text-xs text-zinc-500">priority={item.priority} · {item.enabled ? "enabled" : "disabled"}</div>
</div>
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={() => toggleMutation.mutate({ id: item.id, enabled: !item.enabled })}>{item.enabled ? "停用" : "启用"}</Button>
<Button type="button" variant="secondary" onClick={() => archiveMutation.mutate(item.id)}>归档</Button>
</div>
</div>
</div>
))}
{!listQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无公告</div> : null}
</div>
</div>
</div>
</div>
);
}
+133
查看文件
@@ -0,0 +1,133 @@
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchPortalHome } from "../api/portal";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { PageHeader } from "../components/common/PageHeader";
import { readRecentVisits } from "../lib/recentVisits";
function domainLabel(domain: string) {
if (domain === "reimbursement") return "报销";
if (domain === "leave") return "请假";
if (domain === "purchase_request") return "采购";
if (domain === "travel_request") return "差旅";
return domain;
}
export function PortalHomePage() {
const portalQuery = useQuery({
queryKey: ["portal-home"],
queryFn: () => fetchPortalHome(),
});
const recentVisits = readRecentVisits();
if (portalQuery.isLoading) {
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
}
if (portalQuery.isError || !portalQuery.data) {
const message = isApiError(portalQuery.error)
? `${portalQuery.error.code}: ${mapApiErrorMessage(portalQuery.error.code, portalQuery.error.details, portalQuery.error.message)}`
: "Portal 加载失败";
return (
<div className="space-y-6">
<PageHeader title="Portal" subtitle="统一入口与待办概览。" />
<div className="card-std p-6 text-sm text-red-600">{message}</div>
<div className="card-std p-6 text-sm text-zinc-600">如 `portal_home_v1` 尚未开启,可先使用 `仪表盘`。</div>
</div>
);
}
const payload = portalQuery.data;
return (
<div className="space-y-6">
<PageHeader title="Portal" subtitle={`你好,${payload.greeting.name}。`} />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{payload.cards.map((card) => (
<Link
key={card.key}
to={card.href}
className={
card.severity === "high"
? "card-std border-red-200 bg-red-50 p-5"
: card.severity === "warning"
? "card-std border-amber-200 bg-amber-50 p-5"
: "card-std p-5"
}
>
<div className="text-sm text-zinc-500">{card.title}</div>
<div className="mt-2 text-2xl font-bold text-zinc-900">{card.value}</div>
</Link>
))}
</div>
<div className="grid gap-6 xl:grid-cols-3">
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">快捷入口</h3>
<div className="grid gap-2">
{payload.quickLinks.map((item) => (
<Link key={item.key} to={item.href} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-50">
{item.title}
</Link>
))}
</div>
</div>
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">我的申请</h3>
<div className="space-y-2 text-sm">
{payload.myApplications.map((item) => (
<Link key={item.domain} to={item.href} className="flex items-center justify-between rounded-lg border border-zinc-200 px-3 py-2 hover:bg-zinc-50">
<span>{domainLabel(item.domain)}</span>
<span className="font-medium text-zinc-900">{item.count}</span>
</Link>
))}
</div>
</div>
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">最近访问</h3>
<div className="space-y-2 text-sm">
{recentVisits.length ? recentVisits.map((item) => (
<Link key={item.path} to={item.path} className="block rounded-lg border border-zinc-200 px-3 py-2 hover:bg-zinc-50">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.path}</div>
</Link>
)) : <div className="text-zinc-500">暂无最近访问</div>}
</div>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">公告</h3>
<div className="space-y-3">
{payload.announcements.length ? payload.announcements.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-3">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="mt-1 text-sm text-zinc-600">{item.summary}</div>
{item.href ? <a href={item.href} className="mt-2 inline-block text-sm text-primary" target="_blank" rel="noreferrer">查看链接</a> : null}
</div>
)) : <div className="text-sm text-zinc-500">暂无公告</div>}
</div>
</div>
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">待办与风险</h3>
<Link to={payload.pendingTasks.href} className="block rounded-lg border border-zinc-200 px-3 py-3 text-sm hover:bg-zinc-50">
我的待办:<span className="font-medium text-zinc-900">{payload.pendingTasks.count}</span>
</Link>
{payload.riskSummary ? (
<Link to={payload.riskSummary.href} className="block rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm hover:bg-amber-100">
待复核风险:<span className="font-medium text-zinc-900">{payload.riskSummary.openCount}</span>
</Link>
) : (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">当前没有开放风险项</div>
)}
</div>
</div>
</div>
);
}
+51 -9
查看文件
@@ -12,6 +12,7 @@ import {
fetchPurchaseRequestErpTrack,
fetchPurchaseRequests,
issuePurchaseAttachmentSignedUrl,
precheckPurchaseRequest,
requeuePurchaseRequestErpSync,
submitPurchaseRequest,
updatePurchaseRequest,
@@ -32,6 +33,7 @@ import {
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
@@ -40,6 +42,7 @@ import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Textarea } from "../components/ui/textarea";
import { buildPrecheckDialogMessage } from "../lib/precheck";
const PAGE_SIZE = 20;
@@ -211,6 +214,7 @@ export function PurchaseRequestsPage() {
const [costCenterFilter, setCostCenterFilter] = useState("");
const [page, setPage] = useState(1);
const [selectedId, setSelectedId] = useState("");
const [hydratedDetailId, setHydratedDetailId] = useState("");
const [formError, setFormError] = useState("");
const [title, setTitle] = useState("");
@@ -296,7 +300,7 @@ export function PurchaseRequestsPage() {
}, [detailQuery.isError, detailQuery.isLoading, listQuery.data?.items, selectedId]);
useEffect(() => {
if (!detailQuery.data) {
if (!detailQuery.data || detailQuery.data.id !== selectedId || hydratedDetailId === detailQuery.data.id) {
return;
}
setTitle(detailQuery.data.title || "");
@@ -320,7 +324,8 @@ export function PurchaseRequestsPage() {
setArchivePoNo(detailQuery.data.archiveOrder?.poNo || "");
setArchiveContractNo(detailQuery.data.archiveOrder?.contractNo || "");
setFormError("");
}, [detailQuery.data]);
setHydratedDetailId(detailQuery.data.id);
}, [detailQuery.data, hydratedDetailId, selectedId]);
useEffect(() => {
if (!archiveOrderQuery.data) {
@@ -355,6 +360,7 @@ export function PurchaseRequestsPage() {
mutationFn: () => createPurchaseRequest(),
onSuccess: async (data) => {
setSelectedId(data.purchaseRequest.id);
setHydratedDetailId("");
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "list"] });
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail"] });
toast.success("采购草稿已创建");
@@ -444,21 +450,22 @@ export function PurchaseRequestsPage() {
if (!selectedId) {
throw new Error("请先选择采购单");
}
validateBeforeSubmit();
await updatePurchaseRequest(selectedId, buildPayload());
return submitPurchaseRequest(selectedId);
},
onSuccess: async (data) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
setFormError("");
toast.success(
data.warnings.includes("missing_budget_pool")
? "采购申请已提交(未匹配预算池)"
: "采购申请已提交",
data.precheckDecision === "manual_review"
? `采购申请已提交,已生成 ${data.openedRiskFlagCount} 条待复核风险`
: data.warnings.includes("missing_budget_pool")
? "采购申请已提交(未匹配预算池)"
: "采购申请已提交",
);
},
onError: (error) => {
@@ -606,6 +613,36 @@ export function PurchaseRequestsPage() {
const detail = detailQuery.data;
const archiveOrder = archiveOrderQuery.data?.archiveOrder ?? detail?.archiveOrder ?? null;
const handleSubmitAction = async () => {
if (!selectedId) {
return;
}
try {
setFormError("");
validateBeforeSubmit();
await updatePurchaseRequest(selectedId, buildPayload());
const precheck = await precheckPurchaseRequest(selectedId);
const message = buildPrecheckDialogMessage(precheck);
if (precheck.decision === "block") {
setFormError(message);
toast.error("提交前校验未通过");
return;
}
if ((precheck.decision === "warn" || precheck.decision === "manual_review") && !window.confirm(message)) {
return;
}
submitMutation.mutate();
} catch (error) {
const message = resolveError(error);
setFormError(message);
toast.error(message);
}
};
const erpTrack = erpTrackQuery.data ?? null;
const canRetryErp = Boolean(canIntegrationManage && erpTrack?.permissions.canRetry);
const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder));
@@ -767,7 +804,10 @@ export function PurchaseRequestsPage() {
<TableRow
key={item.id}
className={item.id === selectedId ? "bg-zinc-50" : undefined}
onClick={() => setSelectedId(item.id)}
onClick={() => {
setSelectedId(item.id);
setHydratedDetailId("");
}}
>
<TableCell className="cursor-pointer">
<div className="font-medium text-zinc-900">{item.requestNo || item.title || item.id}</div>
@@ -835,7 +875,7 @@ export function PurchaseRequestsPage() {
data-testid="purchase-submit"
type="button"
disabled={!canSubmit || submitMutation.isPending}
onClick={() => submitMutation.mutate()}
onClick={() => void handleSubmitAction()}
>
{submitMutation.isPending ? "提交中..." : "提交审批"}
</Button>
@@ -851,6 +891,8 @@ export function PurchaseRequestsPage() {
</div>
</div>
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">标题</label>
+46 -1
查看文件
@@ -10,6 +10,7 @@ import {
deleteReimbursementItem,
fetchReimbursementDetail,
payReimbursement,
precheckReimbursement,
submitReimbursement,
updateReimbursementItem,
withdrawReimbursement,
@@ -21,6 +22,7 @@ import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { Button } from "../components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
@@ -30,6 +32,7 @@ import {
type ItemEditorInitialValues,
type ItemEditorSaveData,
} from "./reimbursement-detail/ReimbursementItemEditor";
import { buildPrecheckDialogMessage } from "../lib/precheck";
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
@@ -319,6 +322,46 @@ export function ReimbursementDetailPage() {
setItemError("");
};
const handleSubmitAction = async () => {
if (!reimbursementId) {
return;
}
try {
setActionError("");
const precheck = await precheckReimbursement(reimbursementId);
const message = buildPrecheckDialogMessage(precheck);
if (precheck.decision === "block") {
setActionError(message);
toast.error("提交前校验未通过");
return;
}
if ((precheck.decision === "warn" || precheck.decision === "manual_review") && !window.confirm(message)) {
return;
}
mutation.mutate({ action: "submit" });
} catch (error) {
if (isApiError(error)) {
const message = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
setActionError(message);
toast.error(message);
return;
}
if (error instanceof Error) {
setActionError(error.message);
toast.error(error.message);
return;
}
setActionError("提交前校验失败,请稍后重试");
toast.error("提交前校验失败,请稍后重试");
}
};
return (
<div className="space-y-6">
<PageHeader
@@ -414,6 +457,8 @@ export function ReimbursementDetailPage() {
</div>
) : null}
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
<div className="card-std space-y-4 p-6" data-testid="reimbursement-items">
@@ -528,7 +573,7 @@ export function ReimbursementDetailPage() {
canPay={canPay}
actionError={actionError}
isPending={mutation.isPending}
onSubmit={() => mutation.mutate({ action: "submit" })}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
onPay={(payload) => mutation.mutate({ action: "pay", payload })}
/>
+57 -3
查看文件
@@ -8,6 +8,7 @@ import {
createTravelRequest,
fetchTravelRequestDetail,
fetchTravelRequests,
precheckTravelRequest,
submitTravelRequest,
updateTravelRequest,
withdrawTravelRequest,
@@ -24,6 +25,7 @@ import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { EmptyStateCard } from "../components/common/EmptyStateCard";
import { PageHeader } from "../components/common/PageHeader";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
@@ -33,6 +35,7 @@ import { TravelReconciliationTab } from "../components/travel/TravelReconciliati
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
import { buildPrecheckDialogMessage } from "../lib/precheck";
const PAGE_SIZE = 12;
@@ -308,6 +311,50 @@ export function TravelRequestsPage() {
const detail = detailQuery.data ?? null;
const isDraftEditing = Boolean(detail && detail.status === "draft" && detail.permissions.canEditDraft);
const handleSubmitAction = async () => {
if (!selectedId) {
return;
}
try {
setFormError("");
validateForm();
if (isDraftEditing) {
await updateTravelRequest(
selectedId,
buildPatchPayload({
purpose,
tripType: formTripType,
startDate,
endDate,
legalEntityId,
costCenterId,
estimatedAmount,
segments,
}),
);
}
const precheck = await precheckTravelRequest(selectedId);
const message = buildPrecheckDialogMessage(precheck);
if (precheck.decision === "block") {
setFormError(message);
toast.error("提交前校验未通过");
return;
}
if ((precheck.decision === "warn" || precheck.decision === "manual_review") && !window.confirm(message)) {
return;
}
submitMutation.mutate();
} catch (error) {
const message = resolveError(error);
setFormError(message);
toast.error(message);
}
};
function switchTab(nextTab: "requests" | "reconciliation") {
const next = new URLSearchParams(searchParams);
next.set("tab", nextTab);
@@ -398,12 +445,17 @@ export function TravelRequestsPage() {
}
return submitTravelRequest(selectedId);
},
onSuccess: async () => {
onSuccess: async (data) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["travel-requests"] }),
queryClient.invalidateQueries({ queryKey: ["travel-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
]);
toast.success("差旅申请已提交");
toast.success(
data.precheckDecision === "manual_review"
? `差旅申请已提交,已生成 ${data.openedRiskFlagCount} 条待复核风险`
: "差旅申请已提交",
);
},
onError: (error) => {
const message = resolveError(error);
@@ -676,7 +728,7 @@ export function TravelRequestsPage() {
<Button
type="button"
variant="secondary"
onClick={() => submitMutation.mutate()}
onClick={() => void handleSubmitAction()}
disabled={submitMutation.isPending}
data-testid="travel-request-submit"
>
@@ -702,6 +754,8 @@ export function TravelRequestsPage() {
</div>
</div>
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4 text-sm">
<div>
<p className="text-zinc-500">申请人</p>
@@ -0,0 +1,168 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
archiveWorkflowDefinition,
createWorkflowDefinition,
fetchWorkflowDefinitions,
publishWorkflowDefinition,
updateWorkflowDefinition,
type WorkflowDefinitionEditorModel,
} from "../api/workflowDefinitions";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { toast } from "sonner";
const ENTITY_TYPES: WorkflowDefinitionEditorModel["entityType"][] = ["reimbursement", "leave", "purchase_request", "travel_request"];
type DraftStep = WorkflowDefinitionEditorModel["steps"][number];
function emptyStep(): DraftStep {
return { id: `step_${Date.now()}`, name: "审批节点", assigneeRule: { type: "department_manager" } };
}
export function WorkflowDefinitionsPage() {
const queryClient = useQueryClient();
const [entityType, setEntityType] = useState<WorkflowDefinitionEditorModel["entityType"]>("leave");
const [selectedId, setSelectedId] = useState("");
const [name, setName] = useState("");
const [notes, setNotes] = useState("");
const [steps, setSteps] = useState<DraftStep[]>([emptyStep()]);
const listQuery = useQuery({ queryKey: ["workflow-definitions"], queryFn: () => fetchWorkflowDefinitions() });
const items = listQuery.data?.items ?? [];
const scopedItems = useMemo(() => items.filter((item) => item.entityType === entityType), [items, entityType]);
const selected = useMemo(() => scopedItems.find((item) => item.id === selectedId) ?? scopedItems[0] ?? null, [scopedItems, selectedId]);
useEffect(() => {
if (!selected) return;
setSelectedId(selected.id);
setName(selected.name);
setNotes(selected.notes ?? "");
setSteps(selected.steps.length ? selected.steps : [emptyStep()]);
}, [selected?.id]);
const refresh = () => queryClient.invalidateQueries({ queryKey: ["workflow-definitions"] });
const createMutation = useMutation({
mutationFn: () => createWorkflowDefinition({ entityType, name: name.trim() || `${entityType}-workflow`, notes, steps }),
onSuccess: async ({ item }) => {
setSelectedId(item.id);
await refresh();
toast.success("流程定义草稿已创建");
},
});
const saveMutation = useMutation({
mutationFn: (id: string) => updateWorkflowDefinition(id, { name, notes, steps }),
onSuccess: async () => {
await refresh();
toast.success("流程定义已保存");
},
});
const publishMutation = useMutation({
mutationFn: publishWorkflowDefinition,
onSuccess: async () => {
await refresh();
toast.success("流程定义已发布");
},
});
const archiveMutation = useMutation({
mutationFn: archiveWorkflowDefinition,
onSuccess: async () => {
await refresh();
toast.success("流程定义已归档");
},
});
return (
<div className="space-y-6">
<PageHeader title="流程定义" subtitle="线性流程的版本化治理;报销继续由审批规则投影。" />
<div className="grid gap-6 xl:grid-cols-[220px,280px,1fr]">
<div className="card-std space-y-3 p-6">
<Label>业务域</Label>
<select className="select-std" value={entityType} onChange={(event) => setEntityType(event.target.value as WorkflowDefinitionEditorModel["entityType"])}>
{ENTITY_TYPES.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
{entityType !== "reimbursement" ? <Button type="button" onClick={() => createMutation.mutate()}>创建草稿</Button> : null}
</div>
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">版本列表</h3>
<div className="space-y-2">
{scopedItems.map((item) => (
<button key={item.id} type="button" className={selected?.id === item.id ? "w-full rounded-lg border border-primary bg-primary/5 px-3 py-3 text-left" : "w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"} onClick={() => setSelectedId(item.id)}>
<div className="font-medium text-zinc-900">v{item.version} · {item.name}</div>
<div className="text-xs text-zinc-500">{item.status} · {item.source}</div>
</button>
))}
{!scopedItems.length ? <div className="text-sm text-zinc-500">当前实体暂无流程定义</div> : null}
</div>
</div>
<div className="card-std space-y-4 p-6">
{selected ? (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs text-zinc-500">{selected.source}</div>
<div className="text-lg font-semibold text-zinc-900">{selected.name}</div>
</div>
{selected.status !== "derived_readonly" ? (
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={() => saveMutation.mutate(selected.id)} disabled={selected.status !== "draft"}>保存</Button>
<Button type="button" onClick={() => publishMutation.mutate(selected.id)} disabled={selected.status === "archived"}>发布</Button>
<Button type="button" variant="secondary" onClick={() => archiveMutation.mutate(selected.id)} disabled={selected.status === "archived"}>归档</Button>
</div>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>名称</Label>
<Input value={name} onChange={(event) => setName(event.target.value)} disabled={selected.status !== "draft"} />
</div>
<div className="space-y-2">
<Label>备注</Label>
<Input value={notes} onChange={(event) => setNotes(event.target.value)} disabled={selected.status !== "draft"} />
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">步骤</h3>
{selected.status === "draft" ? <Button type="button" variant="secondary" onClick={() => setSteps((prev) => [...prev, emptyStep()])}>新增步骤</Button> : null}
</div>
<div className="space-y-3">
{steps.map((step, index) => (
<div key={step.id} className="rounded-lg border border-zinc-200 p-4">
<div className="grid gap-3 md:grid-cols-3">
<Input value={step.id} disabled={selected.status !== "draft"} onChange={(event) => setSteps((prev) => prev.map((item, itemIndex) => itemIndex === index ? { ...item, id: event.target.value } : item))} />
<Input value={step.name} disabled={selected.status !== "draft"} onChange={(event) => setSteps((prev) => prev.map((item, itemIndex) => itemIndex === index ? { ...item, name: event.target.value } : item))} />
<select className="select-std" value={step.assigneeRule.type} disabled={selected.status !== "draft"} onChange={(event) => setSteps((prev) => prev.map((item, itemIndex) => itemIndex === index ? { ...item, assigneeRule: event.target.value === "role" ? { type: "role", value: "manager" } : event.target.value === "specific_user" ? { type: "specific_user", value: "" } : { type: "department_manager" } } : item))}>
<option value="department_manager">department_manager</option>
<option value="role">role</option>
<option value="specific_user">specific_user</option>
</select>
</div>
{step.assigneeRule.type !== "department_manager" ? (
<div className="mt-3">
<Input value={step.assigneeRule.value} disabled={selected.status !== "draft"} onChange={(event) => setSteps((prev) => prev.map((item, itemIndex) => itemIndex === index ? { ...item, assigneeRule: { ...item.assigneeRule, value: event.target.value } as DraftStep["assigneeRule"] } : item))} placeholder={step.assigneeRule.type === "role" ? "角色编码" : "用户 ID"} />
</div>
) : null}
</div>
))}
</div>
</div>
</>
) : (
<div className="text-sm text-zinc-500">暂无可编辑流程定义</div>
)}
</div>
</div>
</div>
);
}