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 行删除
+56
查看文件
@@ -0,0 +1,56 @@
import { apiRequestData } from "./http";
export type PortalAnnouncement = {
id: string;
title: string;
summary: string;
linkUrl: string | null;
startsAt: string | null;
endsAt: string | null;
enabled: boolean;
priority: number;
createdAt: string | null;
updatedAt: string | null;
};
export async function fetchAnnouncements(manage = false) {
const suffix = manage ? "?manage=1" : "";
return apiRequestData<{ items: PortalAnnouncement[] }>(`/api/announcements${suffix}`);
}
export async function createAnnouncement(payload: {
title: string;
summary: string;
linkUrl?: string | null;
startsAt?: string | null;
endsAt?: string | null;
enabled?: boolean;
priority?: number;
}) {
return apiRequestData<{ item: PortalAnnouncement }>("/api/announcements", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function updateAnnouncement(id: string, payload: Partial<{
title: string;
summary: string;
linkUrl: string | null;
startsAt: string | null;
endsAt: string | null;
enabled: boolean;
priority: number;
}>) {
return apiRequestData<{ item: PortalAnnouncement }>(`/api/announcements/${id}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function archiveAnnouncement(id: string) {
return apiRequestData<{ item: PortalAnnouncement }>(`/api/announcements/${id}/archive`, {
method: "POST",
body: JSON.stringify({}),
});
}
+19
查看文件
@@ -0,0 +1,19 @@
import { apiRequestData } from "./http";
import type { RiskFlag } from "./feeControl";
export type ApOperationsSummary = {
summary: {
openPaymentBatchCount: number;
failedPaymentBatchItemCount: number;
openRiskFlagCount: number;
reconciliationAnomalyCount: number;
};
openPaymentBatches: Array<{ id: string; batchNo: string; entityType: string; name: string; status: string; createdAt: string | null }>;
failedPaymentBatchItems: Array<{ id: string; paymentBatchId: string; entityId: string; entityCode: string; entityTitle: string; amount: string; currencyCode: string; failureCode: string | null; failureMessage: string | null; createdAt: string | null }>;
openRiskFlags: RiskFlag[];
reconciliationAnomalies: Array<{ id: string; travelReconciliationBatchId: string; travelOrderId: string | null; travelRequestId: string | null; matchStatus: string; resolutionStatus: string; anomalyCode: string | null; orderAmount: string | null; transactionAmount: string | null; currency: string; createdAt: string | null }>;
};
export async function fetchApOperations() {
return apiRequestData<ApOperationsSummary>("/api/ap-operations");
}
+6
查看文件
@@ -17,3 +17,9 @@ export async function changePassword(payload: { oldPassword: string; newPassword
});
}
export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string };
export async function fetchSsoProviders() {
return apiRequestData<SsoProviderInfo[]>("/api/auth/sso/providers", { auth: false });
}
+72
查看文件
@@ -0,0 +1,72 @@
import { apiRequestData } from "./http";
export type MigrationRun = {
id: string;
runType: "employees" | "departments" | "budgets" | "masterdata_validation";
status: string;
sourceFilename: string;
summaryJson: Record<string, unknown> | null;
errorJson: Record<string, unknown> | null;
createdAt: string | null;
createdBy: string | null;
};
export type CutoverScope = {
id: string;
scopeType: "department" | "legal_entity" | "domain";
scopeKey: string;
enabled: boolean;
startsAt: string | null;
notes: string | null;
createdAt: string | null;
updatedAt: string | null;
updatedBy: string | null;
};
export async function fetchMigrationRuns() {
return apiRequestData<{ items: MigrationRun[] }>("/api/migration-runs");
}
export async function fetchMigrationRun(id: string) {
return apiRequestData<{ item: MigrationRun }>(`/api/migration-runs/${id}`);
}
export async function dryRunMigration(payload: { runType: MigrationRun["runType"]; file: File }) {
const formData = new FormData();
formData.append("runType", payload.runType);
formData.append("file", payload.file);
return apiRequestData<{ item: MigrationRun }>("/api/migration-runs/dry-run", {
method: "POST",
body: formData,
});
}
export async function fetchCutoverScopes() {
return apiRequestData<{ items: CutoverScope[] }>("/api/cutover/scopes");
}
export async function createCutoverScope(payload: {
scopeType: CutoverScope["scopeType"];
scopeKey: string;
enabled?: boolean;
startsAt?: string | null;
notes?: string | null;
}) {
return apiRequestData<{ item: CutoverScope }>("/api/cutover/scopes", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function updateCutoverScope(id: string, payload: Partial<{
scopeType: CutoverScope["scopeType"];
scopeKey: string;
enabled: boolean;
startsAt: string | null;
notes: string | null;
}>) {
return apiRequestData<{ item: CutoverScope }>(`/api/cutover/scopes/${id}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
+1 -1
查看文件
@@ -1,6 +1,6 @@
import { apiRequestData } from "./http";
export type FeatureFlagKey = "erp_sync" | "invoice_verification" | "leave_holiday_calendar";
export type FeatureFlagKey = "erp_sync" | "invoice_verification" | "leave_holiday_calendar" | "budget_precheck_v1" | "payment_batch_v1" | "portal_home_v1" | "global_search_v1" | "announcements_v1" | "workflow_definition_admin_v1" | "cutover_console_v1";
export type FeatureFlagOverrideMode = "inherit" | "force_on" | "force_off";
export type FeatureFlagRolloutType = "global" | "department_allowlist";
+34
查看文件
@@ -0,0 +1,34 @@
export type RiskFlag = {
id: string;
entityType: string;
entityId: string;
riskCode: string;
level: "low" | "medium" | "high";
title: string;
detail: string | null;
source: string;
status: "open" | "resolved" | "dismissed";
payloadJson: Record<string, unknown> | null;
createdAt: string | null;
createdBy: string | null;
resolvedAt: string | null;
resolvedBy: string | null;
};
export type PrecheckDecision = "pass" | "warn" | "manual_review" | "block";
export type PrecheckResponse = {
entityType: "reimbursement" | "purchase_request" | "travel_request";
entityId: string;
decision: PrecheckDecision;
matchedBudgetControlRule: {
id: string;
name: string;
controlMode: "warn" | "block" | "manual_review";
} | null;
budgetHint: Record<string, unknown> | null;
warnings: Array<{ code: string; title: string; detail: string }>;
blockingReasons: Array<{ code: string; title: string; detail: string }>;
riskFlags: Array<{ code: string; level: "low" | "medium" | "high"; title: string; detail: string; willOpenOnSubmit: boolean }>;
nextActions: string[];
};
+109
查看文件
@@ -0,0 +1,109 @@
import { apiRequestData } from "./http";
import type { RiskFlag } from "./feeControl";
export type PaymentBatchEntityType = "reimbursement" | "travel_order";
export type PaymentBatchStatus = "draft" | "reviewed" | "executed" | "failed" | "cancelled";
export type PaymentChannel = "bank" | "alipay" | "wechat" | "cash" | "other";
export type PaymentBatchItem = {
id: string;
entityId: string;
entityCode: string;
entityTitle: string;
amount: string;
currencyCode: string;
paymentVoucherNoOverride: string | null;
paymentDateOverride: string | null;
paymentChannelOverride: PaymentChannel | null;
status: string;
failureCode: string | null;
failureMessage: string | null;
snapshotJson: Record<string, unknown>;
createdAt: string | null;
openRiskFlags: RiskFlag[];
};
export type PaymentBatch = {
id: string;
batchNo: string;
entityType: PaymentBatchEntityType;
name: string;
status: PaymentBatchStatus;
defaultPaymentDate: string | null;
defaultPaymentChannel: PaymentChannel | null;
voucherPrefix: string | null;
reviewComment: string | null;
executionSummaryJson: Record<string, unknown> | null;
createdAt: string | null;
createdBy: string | null;
reviewedAt: string | null;
reviewedBy: string | null;
executedAt: string | null;
executedBy: string | null;
cancelledAt: string | null;
cancelledBy: string | null;
items: PaymentBatchItem[];
};
export type PaymentBatchListResponse = { items: PaymentBatch[] };
export async function fetchPaymentBatches(query: { entityType?: PaymentBatchEntityType; status?: PaymentBatchStatus } = {}) {
const params = new URLSearchParams();
if (query.entityType) params.set("entityType", query.entityType);
if (query.status) params.set("status", query.status);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiRequestData<PaymentBatchListResponse>(`/api/payment-batches${suffix}`);
}
export async function fetchPaymentBatchDetail(batchId: string) {
return apiRequestData<{ item: PaymentBatch }>(`/api/payment-batches/${batchId}`);
}
export async function createPaymentBatch(payload: {
entityType: PaymentBatchEntityType;
name: string;
entityIds: string[];
defaultPaymentDate?: string;
defaultPaymentChannel?: PaymentChannel;
voucherPrefix?: string;
}) {
return apiRequestData<{ item: PaymentBatch }>("/api/payment-batches", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function updatePaymentBatchItem(batchId: string, itemId: string, payload: {
paymentVoucherNoOverride?: string | null;
paymentDateOverride?: string | null;
paymentChannelOverride?: PaymentChannel | null;
}) {
return apiRequestData<{ item: Pick<PaymentBatchItem, "id" | "paymentVoucherNoOverride" | "paymentDateOverride" | "paymentChannelOverride"> }>(
`/api/payment-batches/${batchId}/items/${itemId}`,
{
method: "PATCH",
body: JSON.stringify(payload),
},
);
}
export async function reviewPaymentBatch(batchId: string, payload: { comment?: string; resolveRiskFlagIds?: string[] } = {}) {
return apiRequestData<{ item: PaymentBatch }>(`/api/payment-batches/${batchId}/review`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function executePaymentBatch(batchId: string) {
return apiRequestData<{ item: PaymentBatch }>(`/api/payment-batches/${batchId}/execute`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function cancelPaymentBatch(batchId: string) {
return apiRequestData<{ item: PaymentBatch }>(`/api/payment-batches/${batchId}/cancel`, {
method: "POST",
body: JSON.stringify({}),
});
}
+15
查看文件
@@ -0,0 +1,15 @@
import { apiRequestData } from "./http";
export type PortalHomePayload = {
greeting: { name: string };
cards: Array<{ key: string; title: string; value: string | number; href: string; severity?: "normal" | "warning" | "high" }>;
pendingTasks: { count: number; href: string };
myApplications: Array<{ domain: string; count: number; href: string }>;
riskSummary: { openCount: number; href: string } | null;
announcements: Array<{ id: string; title: string; summary: string; href: string | null }>;
quickLinks: Array<{ key: string; title: string; href: string }>;
};
export async function fetchPortalHome() {
return apiRequestData<PortalHomePayload>("/api/portal/home");
}
+11
查看文件
@@ -1,4 +1,5 @@
import { apiRequestData } from "./http";
import type { PrecheckResponse, RiskFlag } from "./feeControl";
export type PurchaseRequestStatus = "draft" | "reviewing" | "approved" | "rejected" | "cancelled";
@@ -205,6 +206,7 @@ export type PurchaseRequestDetail = PurchaseRequestSummary & {
canWithdraw: boolean;
canArchiveOrder: boolean;
};
riskFlags: RiskFlag[];
};
export type PurchaseRequestListResponse = {
@@ -283,6 +285,13 @@ export async function updatePurchaseRequest(
});
}
export async function precheckPurchaseRequest(purchaseRequestId: string) {
return apiRequestData<PrecheckResponse>(`/api/purchase-requests/${purchaseRequestId}/precheck`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function submitPurchaseRequest(purchaseRequestId: string) {
return apiRequestData<{
purchaseRequestId: string;
@@ -296,6 +305,8 @@ export async function submitPurchaseRequest(purchaseRequestId: string) {
applied: boolean;
reservedAmount: string;
};
precheckDecision: "pass" | "warn" | "manual_review";
openedRiskFlagCount: number;
}>(`/api/purchase-requests/${purchaseRequestId}/submit`, {
method: "POST",
body: JSON.stringify({}),
+12 -1
查看文件
@@ -1,4 +1,5 @@
import { apiRequestData } from "./http";
import type { PrecheckResponse, RiskFlag } from "./feeControl";
export type ReimbursementStatus =
| "draft"
@@ -113,6 +114,7 @@ export type ReimbursementDetail = {
assignee_role: string | null;
} | null;
} | null;
riskFlags: RiskFlag[];
items: ReimbursementItem[];
};
@@ -161,11 +163,13 @@ export type ReimbursementSubmitResponse = {
budget: {
budgetId: string;
budgetCode: string;
controlMode: "warn" | "block";
controlMode: "warn" | "block" | "manual_review";
usagePercent: number;
exceededThreshold: boolean;
exceededBudget: boolean;
} | null;
precheckDecision: "pass" | "warn" | "manual_review";
openedRiskFlagCount: number;
};
function toQueryString(query: Record<string, string | number | undefined>) {
@@ -203,6 +207,13 @@ export async function createReimbursement(payload: {
});
}
export async function precheckReimbursement(reimbursementId: string) {
return apiRequestData<PrecheckResponse>(`/api/reimbursements/${reimbursementId}/precheck`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function submitReimbursement(reimbursementId: string) {
return apiRequestData<ReimbursementSubmitResponse>(`/api/reimbursements/${reimbursementId}/submit`, {
method: "POST",
+17
查看文件
@@ -0,0 +1,17 @@
import { apiRequestData } from "./http";
export type GlobalSearchHit = {
entityType: "reimbursement" | "leave" | "purchase_request" | "travel_request" | "asset";
entityId: string;
title: string;
subtitle: string | null;
status: string | null;
href: string;
matchedBy: "exact" | "prefix" | "contains";
updatedAt: string | null;
};
export async function searchGlobal(q: string, limit = 10) {
const params = new URLSearchParams({ q, limit: String(limit) });
return apiRequestData<{ items: GlobalSearchHit[] }>(`/api/search?${params.toString()}`);
}
+11
查看文件
@@ -1,4 +1,5 @@
import { apiRequestData } from "./http";
import type { PrecheckResponse, RiskFlag } from "./feeControl";
export type TravelRequestStatus = "draft" | "reviewing" | "approved" | "rejected";
export type TravelTripType = "domestic" | "international";
@@ -108,6 +109,7 @@ export type TravelRequestDetail = TravelRequestSummary & {
canWithdraw: boolean;
canManage: boolean;
};
riskFlags: RiskFlag[];
};
export type TravelRequestListResponse = {
@@ -160,6 +162,8 @@ export type TravelRequestSubmitResponse = {
reservedAmount?: string;
items?: Array<{ budgetPoolId: string; amount: string }>;
};
precheckDecision: "pass" | "warn" | "manual_review";
openedRiskFlagCount: number;
};
export type TravelRequestWithdrawResponse = {
@@ -209,6 +213,13 @@ export async function updateTravelRequest(travelRequestId: string, payload: Part
});
}
export async function precheckTravelRequest(travelRequestId: string) {
return apiRequestData<PrecheckResponse>(`/api/travel-requests/${travelRequestId}/precheck`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function submitTravelRequest(travelRequestId: string) {
return apiRequestData<TravelRequestSubmitResponse>(`/api/travel-requests/${travelRequestId}/submit`, {
method: "POST",
+45
查看文件
@@ -0,0 +1,45 @@
import { apiRequestData } from "./http";
export type WorkflowDefinitionEditorModel = {
id: string;
entityType: "reimbursement" | "leave" | "purchase_request" | "travel_request";
name: string;
version: number;
status: "draft" | "published" | "archived" | "derived_readonly";
steps: Array<{
id: string;
name: string;
assigneeRule:
| { type: "department_manager" }
| { type: "role"; value: string }
| { type: "specific_user"; value: string };
}>;
notes?: string;
source: "manual" | "approval_rules_projection";
createdAt: string | null;
updatedAt: string | null;
publishedAt: string | null;
archivedAt: string | null;
};
export async function fetchWorkflowDefinitions() {
return apiRequestData<{ items: WorkflowDefinitionEditorModel[] }>("/api/workflow-definitions");
}
export async function fetchWorkflowDefinition(id: string) {
return apiRequestData<{ item: WorkflowDefinitionEditorModel }>(`/api/workflow-definitions/${id}`);
}
export async function fetchActiveWorkflowDefinition(entityType: WorkflowDefinitionEditorModel["entityType"]) {
return apiRequestData<{ item: WorkflowDefinitionEditorModel | null }>(`/api/workflow-definitions/entity/${entityType}/active`);
}
export async function createWorkflowDefinition(payload: { entityType: WorkflowDefinitionEditorModel["entityType"]; name: string; steps: WorkflowDefinitionEditorModel["steps"]; notes?: string }) {
return apiRequestData<{ item: WorkflowDefinitionEditorModel }>("/api/workflow-definitions", { method: "POST", body: JSON.stringify(payload) });
}
export async function updateWorkflowDefinition(id: string, payload: Partial<{ name: string; steps: WorkflowDefinitionEditorModel["steps"]; notes?: string }>) {
return apiRequestData<{ item: WorkflowDefinitionEditorModel }>(`/api/workflow-definitions/${id}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function publishWorkflowDefinition(id: string) {
return apiRequestData<{ item: WorkflowDefinitionEditorModel }>(`/api/workflow-definitions/${id}/publish`, { method: "POST", body: JSON.stringify({}) });
}
export async function archiveWorkflowDefinition(id: string) {
return apiRequestData<{ item: WorkflowDefinitionEditorModel }>(`/api/workflow-definitions/${id}/archive`, { method: "POST", body: JSON.stringify({}) });
}
+4
查看文件
@@ -87,6 +87,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"budget.view",
"expense.approve",
"expense.finance.pay",
"payment.batch.manage",
"expense.view_all",
"procurement.view_all",
"travel.view_all",
@@ -106,6 +107,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"ai.assist",
"expense.approve",
"expense.finance.pay",
"payment.batch.manage",
"expense.view_all",
"expense.finance.override_duplicate",
"procurement.view_all",
@@ -118,6 +120,8 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"asset.inventory",
"system.settings",
"system.audit.view",
"payment.batch.manage",
"risk.review.manage",
],
administration: [
"expense.proxy_submit",
@@ -0,0 +1,78 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { searchGlobal } from "../api/search";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const navigate = useNavigate();
const [keyword, setKeyword] = useState("");
const trimmed = keyword.trim();
const searchQuery = useQuery({
enabled: open && trimmed.length >= 2,
queryKey: ["global-search", trimmed],
queryFn: () => searchGlobal(trimmed, 10),
});
const message = useMemo(() => {
if (!trimmed) return "输入编号、标题或关键字开始搜索。";
if (trimmed.length < 2) return "至少输入 2 个字符。";
if (searchQuery.isError) {
return isApiError(searchQuery.error)
? `${searchQuery.error.code}: ${mapApiErrorMessage(searchQuery.error.code, searchQuery.error.details, searchQuery.error.message)}`
: "搜索失败,请稍后重试";
}
return null;
}, [trimmed, searchQuery.error, searchQuery.isError]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>全局搜索</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Input
autoFocus
placeholder="搜索报销、请假、采购、差旅、资产"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
{message ? <div className="text-sm text-zinc-500">{message}</div> : null}
<div className="space-y-2">
{(searchQuery.data?.items ?? []).map((item) => (
<button
key={`${item.entityType}-${item.entityId}`}
type="button"
className="w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"
onClick={() => {
onOpenChange(false);
navigate(item.href);
}}
>
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs uppercase text-zinc-500">{item.matchedBy}</div>
</div>
{item.subtitle ? <div className="mt-1 text-sm text-zinc-600">{item.subtitle}</div> : null}
<div className="mt-1 text-xs text-zinc-500">{item.entityType} · {item.status ?? "-"}</div>
</button>
))}
</div>
<div className="flex justify-end">
<Button variant="secondary" type="button" onClick={() => onOpenChange(false)}>
关闭
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,34 @@
import type { RiskFlag } from "../../api/feeControl";
export function RiskFlagsCard({ items, title = "风险项" }: { items: RiskFlag[]; title?: string }) {
if (!items.length) {
return null;
}
return (
<div className="card-std space-y-3 p-6" data-testid="risk-flags-card">
<h3 className="text-base font-semibold text-zinc-900">{title}</h3>
<div className="space-y-3">
{items.map((item) => (
<div
key={item.id}
className={
item.level === "high"
? "rounded-lg border border-red-200 bg-red-50 p-3"
: item.level === "medium"
? "rounded-lg border border-amber-200 bg-amber-50 p-3"
: "rounded-lg border border-zinc-200 bg-zinc-50 p-3"
}
>
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.title}</div>
<span className="text-xs uppercase text-zinc-500">{item.level}</span>
</div>
{item.detail ? <div className="mt-1 text-sm text-zinc-600">{item.detail}</div> : null}
<div className="mt-2 text-xs text-zinc-500">来源:{item.source} · 状态:{item.status}</div>
</div>
))}
</div>
</div>
);
}
@@ -11,6 +11,7 @@ export type NavItem = {
};
export const NAV_ITEMS: NavItem[] = [
{ to: "/portal", label: "Portal", icon: "home_app_logo" },
{ to: "/dashboard", label: "仪表盘", icon: "dashboard" },
{
to: "/reimbursements",
@@ -42,6 +43,18 @@ export const NAV_ITEMS: NavItem[] = [
icon: "fact_check",
anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"],
},
{
to: "/payment-batches",
label: "支付批次",
icon: "payments",
anyOf: ["payment.batch.manage", "risk.review.manage"],
},
{
to: "/ap-operations",
label: "AP 运营",
icon: "account_balance_wallet",
anyOf: ["payment.batch.manage", "risk.review.manage"],
},
{
to: "/assets",
label: "资产台账",
@@ -132,6 +145,24 @@ export const NAV_ITEMS: NavItem[] = [
icon: "rule",
anyOf: ["system.settings"],
},
{
to: "/system/workflow-definitions",
label: "流程定义",
icon: "schema",
anyOf: ["system.settings"],
},
{
to: "/system/announcements",
label: "公告管理",
icon: "campaign",
anyOf: ["system.settings"],
},
{
to: "/system/cutover",
label: "切换控制台",
icon: "swap_calls",
anyOf: ["system.settings"],
},
{
to: "/system/feature-flags",
label: "功能开关",
+34 -20
查看文件
@@ -3,13 +3,9 @@ import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchNotifications } from "../../api/notifications";
import { useAuth } from "../../auth/AuthContext";
import { GlobalSearchDialog } from "../GlobalSearchDialog";
import { Button } from "../ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogTrigger,
} from "../ui/dialog";
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "../ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -29,12 +25,7 @@ function MenuIcon() {
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 6h16M4 12h16M4 18h16"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
/>
<path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" strokeLinecap="round" strokeWidth="2" />
</svg>
);
}
@@ -64,6 +55,7 @@ export function Topbar() {
const { me, logout } = useAuth();
const roleCodes = me?.roleCodes ?? [];
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const unreadQuery = useQuery({
enabled: Boolean(me),
@@ -76,18 +68,13 @@ export function Topbar() {
const unreadBadge = unreadCount > 99 ? "99+" : String(unreadCount);
return (
<>
<header className="sticky top-0 z-10 h-16 flex flex-col justify-center border-b border-zinc-200 bg-white px-4 sm:px-8">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Dialog open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
<DialogTrigger asChild>
<Button
className="lg:hidden"
size="icon"
variant="secondary"
aria-label="打开菜单"
type="button"
>
<Button className="lg:hidden" size="icon" variant="secondary" aria-label="打开菜单" type="button">
<MenuIcon />
</Button>
</DialogTrigger>
@@ -97,7 +84,7 @@ export function Topbar() {
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
<span className="text-secondary capitalize">{me?.roleCodes?.[0] || 'User'}</span>
<span className="text-secondary capitalize">{me?.roleCodes?.[0] || "User"}</span>
</p>
<p className="mt-1 text-sm font-bold text-zinc-900">海音达工作台</p>
</div>
@@ -122,6 +109,30 @@ export function Topbar() {
</div>
<div className="flex items-center gap-3">
<Button
size="sm"
variant="secondary"
type="button"
onClick={() => setSearchOpen(true)}
data-testid="global-search-trigger"
className="hidden md:inline-flex"
>
<span className="material-symbols-outlined mr-1 text-[18px]">search</span>
搜索
</Button>
<Button
size="icon"
variant="secondary"
type="button"
onClick={() => setSearchOpen(true)}
data-testid="global-search-trigger-mobile"
className="md:hidden"
aria-label="打开搜索"
>
<span className="material-symbols-outlined text-[18px]">search</span>
</Button>
<Button
asChild
size="icon"
@@ -171,5 +182,8 @@ export function Topbar() {
</div>
</div>
</header>
<GlobalSearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
</>
);
}
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { mapApiErrorMessage } from '../../api/errorMessages';
import { useAuth } from '../../auth/AuthContext';
import { hasPermission } from '../../auth/permissions';
import { fetchCostCenters, fetchLegalEntities } from '../../api/masterData';
import {
confirmTravelReconciliationItem,
@@ -58,6 +60,9 @@ type TravelReconciliationTabProps = {
export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: TravelReconciliationTabProps) {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const canCreateBatch =
hasPermission(permissions, 'travel.payment.manage') || hasPermission(permissions, 'integration.manage');
const [status, setStatus] = useState<TravelReconciliationBatchStatus | ''>('');
const [legalEntityFilter, setLegalEntityFilter] = useState('');
const [costCenterFilter, setCostCenterFilter] = useState('');
@@ -257,7 +262,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
return (
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<section className="space-y-6">
{detail?.permissions.canManagePayments ? (
{canCreateBatch ? (
<div className="card-std space-y-4 p-5">
<div>
<h3 className="text-base font-semibold text-zinc-900">新建对账批次</h3>
+34 -1
查看文件
@@ -1,9 +1,42 @@
import { Outlet } from "react-router-dom";
import { useEffect } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { NAV_ITEMS } from "../components/navigation/NavLinks";
import { ErrorBoundary } from "../components/common/ErrorBoundary";
import { Sidebar } from "../components/navigation/Sidebar";
import { Topbar } from "../components/navigation/Topbar";
import { writeRecentVisit } from "../lib/recentVisits";
const EXTRA_TITLE_ENTRIES = [
{ path: "/notifications", title: "通知中心" },
{ path: "/reimbursements/", title: "报销详情" },
{ path: "/workflow/tasks/", title: "待办详情" },
];
function resolveVisitTitle(pathname: string) {
const candidates = [
...NAV_ITEMS.map((item) => ({ path: item.to, title: item.label })),
...EXTRA_TITLE_ENTRIES,
].sort((left, right) => right.path.length - left.path.length);
const matched = candidates.find((item) => pathname === item.path || pathname.startsWith(item.path));
if (matched) {
return matched.title;
}
return pathname === "/" ? "Portal" : pathname;
}
export function AppShellLayout() {
const location = useLocation();
useEffect(() => {
const pathname = location.pathname;
if (!pathname || pathname === "/login" || pathname.startsWith("/mobile/")) {
return;
}
writeRecentVisit(pathname, resolveVisitTitle(pathname));
}, [location.pathname]);
return (
<div className="flex min-h-screen bg-zinc-50">
<Sidebar />
+46
查看文件
@@ -0,0 +1,46 @@
import type { PrecheckDecision, PrecheckResponse } from "../api/feeControl";
function decisionLabel(decision: PrecheckDecision) {
if (decision === "pass") return "通过";
if (decision === "warn") return "预警";
if (decision === "manual_review") return "需人工复核";
return "阻断";
}
export function buildPrecheckDialogMessage(precheck: PrecheckResponse) {
const lines = [`提交前校验结果:${decisionLabel(precheck.decision)}`];
if (precheck.matchedBudgetControlRule) {
lines.push(`命中规则:${precheck.matchedBudgetControlRule.name}(${precheck.matchedBudgetControlRule.controlMode})`);
}
if (precheck.blockingReasons.length) {
lines.push("", "阻断原因:");
precheck.blockingReasons.forEach((item, index) => {
lines.push(`${index + 1}. ${item.title}${item.detail ? `:${item.detail}` : ""}`);
});
}
if (precheck.warnings.length) {
lines.push("", "预警信息:");
precheck.warnings.forEach((item, index) => {
lines.push(`${index + 1}. ${item.title}${item.detail ? `:${item.detail}` : ""}`);
});
}
if (precheck.riskFlags.length) {
lines.push("", "风险项:");
precheck.riskFlags.forEach((item, index) => {
lines.push(`${index + 1}. [${item.level}] ${item.title}${item.detail ? `:${item.detail}` : ""}`);
});
}
if (precheck.nextActions.length) {
lines.push("", "下一步建议:");
precheck.nextActions.forEach((item, index) => {
lines.push(`${index + 1}. ${item}`);
});
}
return lines.join("\n");
}
+30
查看文件
@@ -0,0 +1,30 @@
export const RECENT_VISITS_STORAGE_KEY = "hyb_oa_recent_visits";
export type RecentVisit = {
path: string;
title: string;
visitedAt: string;
};
function isWindowAvailable() {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
export function readRecentVisits(): RecentVisit[] {
if (!isWindowAvailable()) return [];
try {
const raw = window.localStorage.getItem(RECENT_VISITS_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item.path === "string") : [];
} catch {
return [];
}
}
export function writeRecentVisit(path: string, title: string) {
if (!isWindowAvailable()) return;
const current = readRecentVisits().filter((item) => item.path !== path);
current.unshift({ path, title, visitedAt: new Date().toISOString() });
window.localStorage.setItem(RECENT_VISITS_STORAGE_KEY, JSON.stringify(current.slice(0, 10)));
}
+38 -4
查看文件
@@ -42,13 +42,47 @@ export function createIndexedDbStore(options: IndexedDbStoreOptions) {
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
let executorSettled = false;
let transactionCompleted = false;
let settled = false;
let executorResult!: T;
const resolveIfReady = () => {
if (!settled && executorSettled && transactionCompleted) {
settled = true;
resolve(executorResult);
}
};
const rejectOnce = (error: unknown) => {
if (settled) {
return;
}
settled = true;
reject(error instanceof Error ? error : new Error(String(error)));
};
tx.oncomplete = () => {
transactionCompleted = true;
resolveIfReady();
};
tx.onerror = () => rejectOnce(tx.error ?? new Error("indexeddb_tx_failed"));
tx.onabort = () => rejectOnce(tx.error ?? new Error("indexeddb_tx_aborted"));
Promise.resolve(executor(store))
.then((result) => {
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error ?? new Error("indexeddb_tx_failed"));
tx.onabort = () => reject(tx.error ?? new Error("indexeddb_tx_aborted"));
executorResult = result;
executorSettled = true;
resolveIfReady();
})
.catch((error) => reject(error));
.catch((error) => {
try {
tx.abort();
} catch {
// noop
}
rejectOnce(error);
});
});
}
+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>
);
}
+49 -7
查看文件
@@ -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,19 +450,20 @@ 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")
? "采购申请已提交(未匹配预算池)"
: "采购申请已提交",
);
@@ -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>
);
}
+73 -6
查看文件
@@ -1,5 +1,6 @@
import { Suspense, type ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { RoutePageFallback } from "../components/common/RoutePageFallback";
import { AppShellLayout } from "../layouts/AppShellLayout";
import { AuthLayout } from "../layouts/AuthLayout";
import { BudgetsPage } from "../pages/BudgetsPage";
@@ -9,11 +10,12 @@ import { ForbiddenPage } from "../pages/ForbiddenPage";
import { LoginPage } from "../pages/LoginPage";
import { NotFoundPage } from "../pages/NotFoundPage";
import { ProfilePage } from "../pages/ProfilePage";
import { RoutePageFallback } from "../components/common/RoutePageFallback";
import {
ApOperationsPage,
ApprovalRulesPage,
AssetRegistryPage,
AuditLogsPage,
CutoverConsolePage,
DelegationsPage,
DepartmentsPage,
ErpIntegrationPage,
@@ -27,11 +29,15 @@ import {
MobileCapturePage,
NotificationChannelsPage,
NotificationsPage,
PaymentBatchesPage,
PortalAnnouncementsPage,
PortalHomePage,
PurchaseRequestsPage,
ReimbursementDetailPage,
ReimbursementsPage,
TravelRequestsPage,
UserManagementPage,
WorkflowDefinitionsPage,
WorkflowTaskDetailPage,
WorkflowTasksPage,
} from "./lazy";
@@ -74,7 +80,8 @@ export function AppRouter() {
</RequireAuth>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route index element={<Navigate to="/portal" replace />} />
<Route path="portal" element={withRouteFallback(<PortalHomePage />)} />
<Route path="dashboard" element={<DashboardPage />} />
<Route
@@ -116,7 +123,9 @@ export function AppRouter() {
<Route
path="purchase-requests"
element={
<RequirePermission anyOf={["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"]}>
<RequirePermission
anyOf={["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"]}
>
{withRouteFallback(<PurchaseRequestsPage />)}
</RequirePermission>
}
@@ -125,16 +134,45 @@ export function AppRouter() {
<Route
path="travel-requests"
element={
<RequirePermission anyOf={["travel.create", "travel.view_own", "travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"]}>
<RequirePermission
anyOf={[
"travel.create",
"travel.view_own",
"travel.view_all",
"travel.manage",
"travel.payment.manage",
"integration.manage",
]}
>
{withRouteFallback(<TravelRequestsPage />)}
</RequirePermission>
}
/>
<Route
path="payment-batches"
element={
<RequirePermission anyOf={["payment.batch.manage", "risk.review.manage"]}>
{withRouteFallback(<PaymentBatchesPage />)}
</RequirePermission>
}
/>
<Route
path="ap-operations"
element={
<RequirePermission anyOf={["payment.batch.manage", "risk.review.manage"]}>
{withRouteFallback(<ApOperationsPage />)}
</RequirePermission>
}
/>
<Route
path="assets"
element={
<RequirePermission anyOf={["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]}>
<RequirePermission
anyOf={["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]}
>
{withRouteFallback(<AssetRegistryPage />)}
</RequirePermission>
}
@@ -188,7 +226,9 @@ export function AppRouter() {
<Route
path="mobile/assets/scan"
element={
<RequirePermission anyOf={["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]}>
<RequirePermission
anyOf={["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]}
>
{withRouteFallback(<MobileAssetScanPage />)}
</RequirePermission>
}
@@ -286,6 +326,33 @@ export function AppRouter() {
}
/>
<Route
path="system/announcements"
element={
<RequirePermission anyOf={["system.settings"]}>
{withRouteFallback(<PortalAnnouncementsPage />)}
</RequirePermission>
}
/>
<Route
path="system/workflow-definitions"
element={
<RequirePermission anyOf={["system.settings"]}>
{withRouteFallback(<WorkflowDefinitionsPage />)}
</RequirePermission>
}
/>
<Route
path="system/cutover"
element={
<RequirePermission anyOf={["system.settings"]}>
{withRouteFallback(<CutoverConsolePage />)}
</RequirePermission>
}
/>
<Route path="settings/profile" element={<ProfilePage />} />
</Route>
@@ -0,0 +1 @@
export { ApOperationsPage as default } from "../../pages/ApOperationsPage";
@@ -0,0 +1 @@
export { CutoverConsolePage as default } from "../../pages/CutoverConsolePage";
@@ -0,0 +1 @@
export { PaymentBatchesPage as default } from "../../pages/PaymentBatchesPage";
@@ -0,0 +1 @@
export { PortalAnnouncementsPage as default } from "../../pages/PortalAnnouncementsPage";
+1
查看文件
@@ -0,0 +1 @@
export { PortalHomePage as default } from "../../pages/PortalHomePage";
@@ -0,0 +1 @@
export { WorkflowDefinitionsPage as default } from "../../pages/WorkflowDefinitionsPage";
+6
查看文件
@@ -1,8 +1,10 @@
import { lazy } from "react";
export const ApOperationsPage = lazy(() => import("./ApOperationsPage"));
export const AuditLogsPage = lazy(() => import("./AuditLogsPage"));
export const ApprovalRulesPage = lazy(() => import("./ApprovalRulesPage"));
export const AssetRegistryPage = lazy(() => import("./AssetRegistryPage"));
export const CutoverConsolePage = lazy(() => import("./CutoverConsolePage"));
export const DelegationsPage = lazy(() => import("./DelegationsPage"));
export const DepartmentsPage = lazy(() => import("./DepartmentsPage"));
export const ErpIntegrationPage = lazy(() => import("./ErpIntegrationPage"));
@@ -16,10 +18,14 @@ export const MobileAssetScanPage = lazy(() => import("./MobileAssetScanPage"));
export const MobileCapturePage = lazy(() => import("./MobileCapturePage"));
export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage"));
export const NotificationsPage = lazy(() => import("./NotificationsPage"));
export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
export const PortalAnnouncementsPage = lazy(() => import("./PortalAnnouncementsPage"));
export const PortalHomePage = lazy(() => import("./PortalHomePage"));
export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"));
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
export const UserManagementPage = lazy(() => import("./UserManagementPage"));
export const WorkflowDefinitionsPage = lazy(() => import("./WorkflowDefinitionsPage"));
export const WorkflowTaskDetailPage = lazy(() => import("./WorkflowTaskDetailPage"));
export const WorkflowTasksPage = lazy(() => import("./WorkflowTasksPage"));