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