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({}) });
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户