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
这个提交包含在:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
>
|
||||
丢弃草稿
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户