feat(G1,P16A,P16B): 收口主数据核心与节假日日历增强

这个提交包含在:
2026-03-07 17:29:03 +08:00
父节点 32a3d3724d
当前提交 195f46a2e3
共修改 35 个文件,包含 4556 行新增和 27 行删除
+56
查看文件
@@ -119,6 +119,58 @@ export function mapApiErrorMessage(
if (details?.verification === "disabled") {
return "当前环境未开启发票验真。";
}
if (details?.code === "duplicate") {
return "编码已存在,请更换后重试。";
}
if (details?.legalEntityId === "not_found") {
return "所选法体不存在。";
}
if (details?.legalEntityId === "archived") {
return "所选法体已归档,无法继续使用。";
}
if (details?.ownerEmployeeId === "not_found") {
return "负责人不存在或已停用。";
}
if (details?.parentId === "not_found") {
return "上级成本中心不存在。";
}
if (details?.parentId === "archived") {
return "上级成本中心已归档,无法继续使用。";
}
if (details?.parentId === "self_not_allowed") {
return "上级成本中心不能选择自身。";
}
if (details?.parentId === "cycle_not_allowed") {
return "上级成本中心不能形成循环层级。";
}
if (details?.parentId === "cross_legal_entity_not_allowed") {
return "上级成本中心必须与当前法体一致。";
}
if (details?.legalEntity === "has_cost_centers") {
return "当前法体下仍存在未归档的成本中心,无法归档。";
}
if (details?.costCenter === "has_children") {
return "当前成本中心存在下级节点,无法归档。";
}
if (details?.holidayCalendar === "bound_as_default") {
return `当前日历仍被法体 ${details?.legalEntityCode ?? ""} 作为默认日历引用,无法归档。`;
}
if (details?.date === "duplicate") {
return "同一日历内不允许存在重复日期。";
}
if (details?.verification === "already_pending") {
return "已有验真任务正在处理中,请稍后查看结果。";
@@ -148,6 +200,10 @@ export function mapApiErrorMessage(
return "请假开始/结束日期与半天时段不合法。";
}
if (details?.leave === "non_working_range") {
return "所选时间段均为非工作日,无需请假。";
}
if (details?.leave === "missing_reserve_ledger") {
return "请假冻结账本状态异常,请联系管理员处理。";
}
+1 -1
查看文件
@@ -1,6 +1,6 @@
import { apiRequestData } from "./http";
export type FeatureFlagKey = "erp_sync" | "invoice_verification";
export type FeatureFlagKey = "erp_sync" | "invoice_verification" | "leave_holiday_calendar";
export type FeatureFlagOverrideMode = "inherit" | "force_on" | "force_off";
export type FeatureFlagRolloutType = "global" | "department_allowlist";
+347
查看文件
@@ -0,0 +1,347 @@
import { apiRequestData } from "./http";
export type DocumentCategoryEntityType =
| "reimbursement"
| "leave"
| "purchase_request"
| "asset_card"
| "general";
export type DocumentResetPeriod = "none" | "daily" | "monthly" | "yearly";
export type HolidayCalendarDayType = "holiday" | "workday" | "adjusted_workday";
export type LegalEntityItem = {
id: string;
code: string;
name: string;
shortName: string | null;
currencyCode: string;
taxNo: string | null;
enabled: boolean;
archivedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
};
export type CostCenterItem = {
id: string;
legalEntityId: string;
code: string;
name: string;
parentId: string | null;
ownerEmployeeId: string | null;
enabled: boolean;
archivedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
legalEntity: {
id: string;
code: string;
name: string;
} | null;
parent: {
id: string;
name: string;
} | null;
owner: {
id: string;
employeeNo: string;
name: string;
} | null;
};
export type DocumentCategoryItem = {
id: string;
code: string;
name: string;
entityType: DocumentCategoryEntityType;
sequencePrefix: string;
numberLength: number;
resetPeriod: DocumentResetPeriod;
defaultWorkflowKey: string | null;
enabled: boolean;
archivedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
};
export type HolidayCalendarItem = {
id: string;
code: string;
name: string;
timezone: string;
legalEntityId: string | null;
enabled: boolean;
archivedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
daysCount: number;
isDefaultForLegalEntity: boolean;
legalEntity: {
id: string;
code: string;
name: string;
} | null;
};
export type HolidayCalendarDayItem = {
id: string;
date: string;
dayType: HolidayCalendarDayType;
name: string;
note: string | null;
createdAt: string | null;
updatedAt: string | null;
};
export type MasterDataListResponse<T> = {
includeArchived: boolean;
items: T[];
};
function toQueryString(query: Record<string, string | boolean | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value == null || value === "") {
continue;
}
params.set(key, typeof value === "boolean" ? String(value) : value);
}
const encoded = params.toString();
return encoded ? `?${encoded}` : "";
}
export async function fetchLegalEntities(params: { includeArchived?: boolean; enabled?: boolean } = {}) {
return apiRequestData<MasterDataListResponse<LegalEntityItem>>(
`/api/master-data/legal-entities${toQueryString(params as Record<string, string | boolean | undefined>)}`,
);
}
export async function createLegalEntity(payload: {
code: string;
name: string;
shortName?: string | null;
currencyCode: string;
taxNo?: string | null;
enabled?: boolean;
}) {
return apiRequestData<{ legalEntity: LegalEntityItem }>("/api/master-data/legal-entities", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function patchLegalEntity(
legalEntityId: string,
payload: Partial<{
code: string;
name: string;
shortName: string | null;
currencyCode: string;
taxNo: string | null;
enabled: boolean;
}>,
) {
return apiRequestData<{ legalEntity: LegalEntityItem }>(`/api/master-data/legal-entities/${legalEntityId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function archiveLegalEntity(legalEntityId: string) {
return apiRequestData<{ legalEntity: LegalEntityItem; outcome: string }>(
`/api/master-data/legal-entities/${legalEntityId}/archive`,
{
method: "POST",
body: JSON.stringify({}),
},
);
}
export async function fetchCostCenters(params: {
includeArchived?: boolean;
enabled?: boolean;
legalEntityId?: string;
} = {}) {
return apiRequestData<MasterDataListResponse<CostCenterItem>>(
`/api/master-data/cost-centers${toQueryString(params as Record<string, string | boolean | undefined>)}`,
);
}
export async function createCostCenter(payload: {
legalEntityId: string;
code: string;
name: string;
parentId?: string | null;
ownerEmployeeId?: string | null;
enabled?: boolean;
}) {
return apiRequestData<{ costCenter: CostCenterItem }>("/api/master-data/cost-centers", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function patchCostCenter(
costCenterId: string,
payload: Partial<{
legalEntityId: string;
code: string;
name: string;
parentId: string | null;
ownerEmployeeId: string | null;
enabled: boolean;
}>,
) {
return apiRequestData<{ costCenter: CostCenterItem }>(`/api/master-data/cost-centers/${costCenterId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function archiveCostCenter(costCenterId: string) {
return apiRequestData<{ costCenter: CostCenterItem; outcome: string }>(
`/api/master-data/cost-centers/${costCenterId}/archive`,
{
method: "POST",
body: JSON.stringify({}),
},
);
}
export async function fetchDocumentCategories(params: {
includeArchived?: boolean;
enabled?: boolean;
entityType?: DocumentCategoryEntityType;
} = {}) {
return apiRequestData<MasterDataListResponse<DocumentCategoryItem>>(
`/api/master-data/document-categories${toQueryString(params as Record<string, string | boolean | undefined>)}`,
);
}
export async function createDocumentCategory(payload: {
code: string;
name: string;
entityType: DocumentCategoryEntityType;
sequencePrefix: string;
numberLength?: number;
resetPeriod: DocumentResetPeriod;
defaultWorkflowKey?: string | null;
enabled?: boolean;
}) {
return apiRequestData<{ documentCategory: DocumentCategoryItem }>(
"/api/master-data/document-categories",
{
method: "POST",
body: JSON.stringify(payload),
},
);
}
export async function patchDocumentCategory(
documentCategoryId: string,
payload: Partial<{
code: string;
name: string;
entityType: DocumentCategoryEntityType;
sequencePrefix: string;
numberLength: number;
resetPeriod: DocumentResetPeriod;
defaultWorkflowKey: string | null;
enabled: boolean;
}>,
) {
return apiRequestData<{ documentCategory: DocumentCategoryItem }>(
`/api/master-data/document-categories/${documentCategoryId}`,
{
method: "PATCH",
body: JSON.stringify(payload),
},
);
}
export async function archiveDocumentCategory(documentCategoryId: string) {
return apiRequestData<{ documentCategory: DocumentCategoryItem; outcome: string }>(
`/api/master-data/document-categories/${documentCategoryId}/archive`,
{
method: "POST",
body: JSON.stringify({}),
},
);
}
export async function fetchHolidayCalendars(params: {
includeArchived?: boolean;
enabled?: boolean;
legalEntityId?: string;
} = {}) {
return apiRequestData<MasterDataListResponse<HolidayCalendarItem>>(
`/api/master-data/holiday-calendars${toQueryString(params as Record<string, string | boolean | undefined>)}`,
);
}
export async function createHolidayCalendar(payload: {
code: string;
name: string;
timezone?: string;
legalEntityId?: string | null;
enabled?: boolean;
}) {
return apiRequestData<{ holidayCalendar: HolidayCalendarItem }>("/api/master-data/holiday-calendars", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function patchHolidayCalendar(
holidayCalendarId: string,
payload: Partial<{
code: string;
name: string;
timezone: string;
legalEntityId: string | null;
enabled: boolean;
}>,
) {
return apiRequestData<{ holidayCalendar: HolidayCalendarItem }>(
`/api/master-data/holiday-calendars/${holidayCalendarId}`,
{
method: "PATCH",
body: JSON.stringify(payload),
},
);
}
export async function archiveHolidayCalendar(holidayCalendarId: string) {
return apiRequestData<{ holidayCalendar: HolidayCalendarItem; outcome: string }>(
`/api/master-data/holiday-calendars/${holidayCalendarId}/archive`,
{
method: "POST",
body: JSON.stringify({}),
},
);
}
export async function fetchHolidayCalendarDays(holidayCalendarId: string) {
return apiRequestData<{ items: HolidayCalendarDayItem[] }>(
`/api/master-data/holiday-calendars/${holidayCalendarId}/days`,
);
}
export async function replaceHolidayCalendarDays(
holidayCalendarId: string,
items: Array<{
date: string;
dayType: HolidayCalendarDayType;
name: string;
note?: string | null;
}>,
) {
return apiRequestData<{ holidayCalendarId: string; replacedCount: number }>(
`/api/master-data/holiday-calendars/${holidayCalendarId}/days`,
{
method: "PUT",
body: JSON.stringify({ items }),
},
);
}
+6
查看文件
@@ -16,6 +16,9 @@ const LEAVE_SELF_SERVICE_PERMISSIONS = [
"leave.submit",
] as const;
const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"] as const;
const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"] as const;
const EMPLOYEE_BASE_PERMISSIONS = [
...EXPENSE_SELF_SERVICE_PERMISSIONS,
...LEAVE_SELF_SERVICE_PERMISSIONS,
@@ -35,6 +38,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
],
ceo: [
...EMPLOYEE_BASE_PERMISSIONS,
...MASTERDATA_VIEW_PERMISSIONS,
"expense.approve",
"expense.transfer",
"expense.view_all",
@@ -45,6 +49,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
],
finance: [
...EMPLOYEE_BASE_PERMISSIONS,
...MASTERDATA_VIEW_PERMISSIONS,
"invoice.manage",
"invoice.verify",
"report.dashboard.view",
@@ -55,6 +60,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
],
finance_mgr: [
...EMPLOYEE_BASE_PERMISSIONS,
...MASTERDATA_MANAGE_PERMISSIONS,
"invoice.manage",
"invoice.verify",
"report.dashboard.view",
@@ -0,0 +1,593 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../../api/errorMessages";
import {
archiveHolidayCalendar,
createHolidayCalendar,
fetchHolidayCalendarDays,
fetchHolidayCalendars,
replaceHolidayCalendarDays,
type HolidayCalendarDayItem,
type HolidayCalendarDayType,
type LegalEntityItem,
} from "../../api/masterData";
import { isApiError } from "../../api/types";
import { ConfirmDialog } from "../common/ConfirmDialog";
import { StatusBadge } from "../common/StatusBadge";
import { TableSkeleton } from "../common/TableSkeleton";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
import { Textarea } from "../ui/textarea";
const DAY_TYPE_OPTIONS: Array<{ value: HolidayCalendarDayType; label: string }> = [
{ value: "holiday", label: "假日" },
{ value: "workday", label: "工作日" },
{ value: "adjusted_workday", label: "调休补班" },
];
type HolidayCalendarTabProps = {
canManage: boolean;
includeArchived: boolean;
legalEntities: LegalEntityItem[];
};
type DraftDay = {
date: string;
dayType: HolidayCalendarDayType;
name: string;
note: string;
};
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "操作失败,请稍后重试。";
}
function toEnabledFilter(value: "" | "true" | "false") {
if (value === "") {
return undefined;
}
return value === "true";
}
function renderDayTypeLabel(value: HolidayCalendarDayType) {
return DAY_TYPE_OPTIONS.find((item) => item.value === value)?.label ?? value;
}
function sortDraftDays(items: DraftDay[]) {
return [...items].sort((left, right) => left.date.localeCompare(right.date));
}
export function HolidayCalendarTab({ canManage, includeArchived, legalEntities }: HolidayCalendarTabProps) {
const queryClient = useQueryClient();
const [error, setError] = useState("");
const [enabledFilter, setEnabledFilter] = useState<"" | "true" | "false">("");
const [legalEntityFilter, setLegalEntityFilter] = useState("");
const [calendarCode, setCalendarCode] = useState("");
const [calendarName, setCalendarName] = useState("");
const [calendarTimezone, setCalendarTimezone] = useState("Asia/Shanghai");
const [calendarLegalEntityId, setCalendarLegalEntityId] = useState("");
const [selectedCalendarId, setSelectedCalendarId] = useState("");
const [dayDate, setDayDate] = useState("");
const [dayType, setDayType] = useState<HolidayCalendarDayType>("holiday");
const [dayName, setDayName] = useState("");
const [dayNote, setDayNote] = useState("");
const [draftDays, setDraftDays] = useState<DraftDay[]>([]);
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null);
const calendarsQuery = useQuery({
queryKey: ["master-data", "holiday-calendars", includeArchived, enabledFilter, legalEntityFilter],
queryFn: () =>
fetchHolidayCalendars({
includeArchived,
enabled: toEnabledFilter(enabledFilter),
legalEntityId: legalEntityFilter || undefined,
}),
});
const holidayCalendars = calendarsQuery.data?.items ?? [];
useEffect(() => {
if (!holidayCalendars.length) {
setSelectedCalendarId("");
return;
}
if (!selectedCalendarId || !holidayCalendars.some((item) => item.id === selectedCalendarId)) {
setSelectedCalendarId(holidayCalendars[0].id);
}
}, [holidayCalendars, selectedCalendarId]);
const selectedCalendar = useMemo(
() => holidayCalendars.find((item) => item.id === selectedCalendarId) ?? null,
[holidayCalendars, selectedCalendarId],
);
const daysQuery = useQuery({
queryKey: ["master-data", "holiday-calendar-days", selectedCalendarId],
enabled: Boolean(selectedCalendarId),
queryFn: () => fetchHolidayCalendarDays(selectedCalendarId),
});
useEffect(() => {
if (!selectedCalendarId) {
setDraftDays([]);
return;
}
if (daysQuery.data?.items) {
setDraftDays(
sortDraftDays(
daysQuery.data.items.map((item: HolidayCalendarDayItem) => ({
date: item.date,
dayType: item.dayType,
name: item.name,
note: item.note ?? "",
})),
),
);
}
}, [daysQuery.data?.items, selectedCalendarId]);
const invalidateHolidayData = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["master-data", "holiday-calendars"] }),
queryClient.invalidateQueries({ queryKey: ["master-data", "holiday-calendar-days"] }),
queryClient.invalidateQueries({ queryKey: ["master-data", "legal-entities"] }),
]);
};
const createMutation = useMutation({
mutationFn: async () =>
createHolidayCalendar({
code: calendarCode,
name: calendarName,
timezone: calendarTimezone,
legalEntityId: calendarLegalEntityId || null,
}),
onSuccess: async (response) => {
await invalidateHolidayData();
setCalendarCode("");
setCalendarName("");
setCalendarTimezone("Asia/Shanghai");
setCalendarLegalEntityId("");
setSelectedCalendarId(response.holidayCalendar.id);
setError("");
toast.success("日历已创建");
},
onError: (mutationError) => {
const message = resolveError(mutationError);
setError(message);
toast.error(message);
},
});
const replaceDaysMutation = useMutation({
mutationFn: async () => {
if (!selectedCalendarId) {
throw new Error("请先选择日历");
}
return replaceHolidayCalendarDays(
selectedCalendarId,
draftDays.map((item) => ({
date: item.date,
dayType: item.dayType,
name: item.name,
note: item.note || null,
})),
);
},
onSuccess: async () => {
await invalidateHolidayData();
setError("");
toast.success("日期明细已覆盖保存");
},
onError: (mutationError) => {
const message = resolveError(mutationError);
setError(message);
toast.error(message);
},
});
const archiveMutation = useMutation({
mutationFn: async (holidayCalendarId: string) => archiveHolidayCalendar(holidayCalendarId),
onSuccess: async () => {
await invalidateHolidayData();
setConfirmArchiveId(null);
setError("");
toast.success("日历已归档");
},
onError: (mutationError) => {
const message = resolveError(mutationError);
setError(message);
toast.error(message);
setConfirmArchiveId(null);
},
});
const addOrReplaceDraftDay = () => {
if (!dayDate || !dayName.trim()) {
return;
}
setDraftDays((prev) => {
const next = prev.filter((item) => item.date !== dayDate);
next.push({
date: dayDate,
dayType,
name: dayName.trim(),
note: dayNote.trim(),
});
return sortDraftDays(next);
});
setDayDate("");
setDayType("holiday");
setDayName("");
setDayNote("");
};
const removeDraftDay = (date: string) => {
setDraftDays((prev) => prev.filter((item) => item.date !== date));
};
return (
<div className="space-y-6">
{error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
) : null}
<div className="card-std p-6 space-y-4">
<div className="grid gap-3 md:grid-cols-3">
<select
className="select-std"
value={enabledFilter}
onChange={(event) => setEnabledFilter(event.target.value as "" | "true" | "false")}
>
<option value="">全部状态</option>
<option value="true">仅启用</option>
<option value="false">仅停用</option>
</select>
<select
className="select-std"
value={legalEntityFilter}
onChange={(event) => setLegalEntityFilter(event.target.value)}
>
<option value="">全部法体</option>
{legalEntities
.filter((item) => !item.archivedAt)
.map((item) => (
<option key={item.id} value={item.id}>
{item.code} / {item.name}
</option>
))}
</select>
</div>
</div>
{canManage ? (
<div className="card-std p-6 space-y-4">
<h3 className="text-base font-semibold text-zinc-900">新建日历</h3>
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-1">
<Label htmlFor="holidayCalendarCode">编码</Label>
<Input
id="holidayCalendarCode"
value={calendarCode}
onChange={(event) => setCalendarCode(event.target.value)}
placeholder="HYB_CN_STD"
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label htmlFor="holidayCalendarName">名称</Label>
<Input
id="holidayCalendarName"
value={calendarName}
onChange={(event) => setCalendarName(event.target.value)}
placeholder="海音达默认日历"
/>
</div>
<div className="space-y-1">
<Label htmlFor="holidayCalendarTimezone">时区</Label>
<Input
id="holidayCalendarTimezone"
value={calendarTimezone}
onChange={(event) => setCalendarTimezone(event.target.value)}
placeholder="Asia/Shanghai"
/>
</div>
<div className="space-y-1 md:col-span-3">
<Label htmlFor="holidayCalendarLegalEntity">所属法体(可选)</Label>
<select
id="holidayCalendarLegalEntity"
className="select-std"
value={calendarLegalEntityId}
onChange={(event) => setCalendarLegalEntityId(event.target.value)}
>
<option value="">全局日历</option>
{legalEntities
.filter((item) => !item.archivedAt)
.map((item) => (
<option key={item.id} value={item.id}>
{item.code} / {item.name}
</option>
))}
</select>
</div>
<div className="flex items-end">
<Button
type="button"
disabled={
createMutation.isPending ||
!calendarCode.trim() ||
!calendarName.trim() ||
!calendarTimezone.trim()
}
onClick={() => createMutation.mutate()}
>
创建日历
</Button>
</div>
</div>
<p className="text-xs text-zinc-500">
首个绑定到法体的日历会自动成为该法体默认日历;后续切换默认日历将放在后续阶段补齐。
</p>
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-[1.2fr,1fr]">
<div className="card-std p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">日历列表</h3>
<span className="text-sm text-zinc-500">共 {holidayCalendars.length} 项</span>
</div>
{calendarsQuery.isLoading ? <TableSkeleton columns={6} rows={4} /> : null}
{!calendarsQuery.isLoading ? (
<div className="rounded-lg border border-zinc-200">
<Table>