feat(G1,P16A,P16B): 收口主数据核心与节假日日历增强
这个提交包含在:
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编码</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>法体</TableHead>
|
||||
<TableHead>日期数</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{holidayCalendars.length ? (
|
||||
holidayCalendars.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={selectedCalendarId === item.id ? "bg-zinc-50" : undefined}
|
||||
onClick={() => setSelectedCalendarId(item.id)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div>{item.code}</div>
|
||||
<div className="text-xs text-zinc-500">{item.timezone}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>{item.name}</div>
|
||||
{item.isDefaultForLegalEntity ? (
|
||||
<div className="text-xs text-emerald-600">默认法体日历</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>{item.legalEntity ? `${item.legalEntity.code} / ${item.legalEntity.name}` : "全局"}</TableCell>
|
||||
<TableCell>{item.daysCount}</TableCell>
|
||||
<TableCell>{item.archivedAt ? <StatusBadge status="archived" /> : renderEnabledBadge(item)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => setSelectedCalendarId(item.id)}>
|
||||
维护日期
|
||||
</Button>
|
||||
{canManage && !item.archivedAt ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setConfirmArchiveId(item.id)}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-sm text-zinc-500">
|
||||
暂无日历数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">日期明细维护</h3>
|
||||
<p className="text-sm text-zinc-500">
|
||||
{selectedCalendar
|
||||
? `当前:${selectedCalendar.code} / ${selectedCalendar.name}`
|
||||
: "请选择左侧日历后维护日期明细"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedCalendarId && daysQuery.isLoading ? <TableSkeleton columns={4} rows={4} /> : null}
|
||||
|
||||
{selectedCalendar ? (
|
||||
<>
|
||||
{canManage ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="holidayDayDate">日期</Label>
|
||||
<Input
|
||||
id="holidayDayDate"
|
||||
type="date"
|
||||
value={dayDate}
|
||||
onChange={(event) => setDayDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="holidayDayType">类型</Label>
|
||||
<select
|
||||
id="holidayDayType"
|
||||
className="select-std"
|
||||
value={dayType}
|
||||
onChange={(event) => setDayType(event.target.value as HolidayCalendarDayType)}
|
||||
>
|
||||
{DAY_TYPE_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label htmlFor="holidayDayName">名称</Label>
|
||||
<Input
|
||||
id="holidayDayName"
|
||||
value={dayName}
|
||||
onChange={(event) => setDayName(event.target.value)}
|
||||
placeholder="如:元旦 / 调休补班"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label htmlFor="holidayDayNote">备注(可选)</Label>
|
||||
<Textarea
|
||||
id="holidayDayNote"
|
||||
value={dayNote}
|
||||
onChange={(event) => setDayNote(event.target.value)}
|
||||
placeholder="补充说明"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 md:col-span-2">
|
||||
<Button type="button" onClick={addOrReplaceDraftDay} disabled={!dayDate || !dayName.trim()}>
|
||||
添加 / 覆盖日期
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setDraftDays(
|
||||
sortDraftDays(
|
||||
(daysQuery.data?.items ?? []).map((item: HolidayCalendarDayItem) => ({
|
||||
date: item.date,
|
||||
dayType: item.dayType,
|
||||
name: item.name,
|
||||
note: item.note ?? "",
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
重置草稿
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={replaceDaysMutation.isPending}
|
||||
onClick={() => replaceDaysMutation.mutate()}
|
||||
>
|
||||
保存当前草稿
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>日期</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{draftDays.length ? (
|
||||
draftDays.map((item) => (
|
||||
<TableRow key={item.date}>
|
||||
<TableCell className="font-medium">{item.date}</TableCell>
|
||||
<TableCell>{renderDayTypeLabel(item.dayType)}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.note || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{canManage ? (
|
||||
<Button type="button" variant="secondary" onClick={() => removeDraftDay(item.date)}>
|
||||
删除
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-zinc-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-sm text-zinc-500">
|
||||
当前没有日期明细
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-6 text-sm text-zinc-500">
|
||||
左侧选择一个日历后可查看与维护日期明细。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmArchiveId)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConfirmArchiveId(null);
|
||||
}
|
||||
}}
|
||||
title="确认归档日历"
|
||||
description={
|
||||
confirmArchiveId
|
||||
? `确认归档 ${holidayCalendars.find((item) => item.id === confirmArchiveId)?.code ?? "当前日历"} 吗?`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="确认归档"
|
||||
cancelLabel="取消"
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
if (!confirmArchiveId) {
|
||||
return;
|
||||
}
|
||||
await archiveMutation.mutateAsync(confirmArchiveId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderEnabledBadge(item: { archivedAt: string | null; enabled: boolean }) {
|
||||
if (item.archivedAt) {
|
||||
return <StatusBadge status="archived" />;
|
||||
}
|
||||
if (item.enabled) {
|
||||
return <StatusBadge status="active" />;
|
||||
}
|
||||
return <StatusBadge status="archived" label="已停用" className="badge-neutral" />;
|
||||
}
|
||||
@@ -78,6 +78,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
icon: "account_tree",
|
||||
anyOf: ["system.users"],
|
||||
},
|
||||
{
|
||||
to: "/system/master-data",
|
||||
label: "主数据",
|
||||
icon: "dataset",
|
||||
anyOf: ["masterdata.view", "masterdata.manage"],
|
||||
},
|
||||
{
|
||||
to: "/system/approval-rules",
|
||||
label: "审批规则",
|
||||
|
||||
@@ -31,6 +31,7 @@ const ROLLOUT_TYPE_OPTIONS: Array<{ value: FeatureFlagRolloutType; label: string
|
||||
function toDisplayName(flagKey: string) {
|
||||
if (flagKey === "erp_sync") return "ERP 同步";
|
||||
if (flagKey === "invoice_verification") return "发票验真";
|
||||
if (flagKey === "leave_holiday_calendar") return "请假日历增强";
|
||||
return flagKey;
|
||||
}
|
||||
|
||||
@@ -78,7 +79,7 @@ export function FeatureFlagsPage() {
|
||||
throw new Error("未找到待提交草稿");
|
||||
}
|
||||
|
||||
return updateFeatureFlag(flagKey as "erp_sync" | "invoice_verification", {
|
||||
return updateFeatureFlag(flagKey as "erp_sync" | "invoice_verification" | "leave_holiday_calendar", {
|
||||
overrideMode: draft.overrideMode,
|
||||
rolloutType: draft.rolloutType,
|
||||
departmentIds:
|
||||
@@ -131,7 +132,7 @@ export function FeatureFlagsPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="功能开关"
|
||||
subtitle="统一治理 ERP 与验真灰度。Env 仍是硬闸,在线开关用于部门级灰度。"
|
||||
subtitle="统一治理 ERP、验真与请假日历灰度。Env 仍是硬闸,在线开关用于部门级灰度。"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
|
||||
@@ -0,0 +1,786 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import {
|
||||
archiveCostCenter,
|
||||
archiveDocumentCategory,
|
||||
archiveLegalEntity,
|
||||
createCostCenter,
|
||||
createDocumentCategory,
|
||||
createLegalEntity,
|
||||
fetchCostCenters,
|
||||
fetchDocumentCategories,
|
||||
fetchLegalEntities,
|
||||
type CostCenterItem,
|
||||
type DocumentCategoryEntityType,
|
||||
type DocumentResetPeriod,
|
||||
} from "../api/masterData";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { HolidayCalendarTab } from "../components/master-data/HolidayCalendarTab";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
|
||||
const DOCUMENT_ENTITY_TYPE_OPTIONS: Array<{ value: DocumentCategoryEntityType; label: string }> = [
|
||||
{ value: "reimbursement", label: "报销" },
|
||||
{ value: "leave", label: "请假" },
|
||||
{ value: "purchase_request", label: "采购申请" },
|
||||
{ value: "asset_card", label: "资产卡片" },
|
||||
{ value: "general", label: "通用" },
|
||||
];
|
||||
|
||||
const RESET_PERIOD_OPTIONS: Array<{ value: DocumentResetPeriod; label: string }> = [
|
||||
{ value: "none", label: "不重置" },
|
||||
{ value: "daily", label: "按日重置" },
|
||||
{ value: "monthly", label: "按月重置" },
|
||||
{ value: "yearly", label: "按年重置" },
|
||||
];
|
||||
|
||||
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 renderEnabledBadge(item: { archivedAt: string | null; enabled: boolean }) {
|
||||
if (item.archivedAt) {
|
||||
return <StatusBadge status="archived" />;
|
||||
}
|
||||
if (item.enabled) {
|
||||
return <StatusBadge status="active" />;
|
||||
}
|
||||
return <StatusBadge status="archived" label="已停用" className="badge-neutral" />;
|
||||
}
|
||||
|
||||
export function MasterDataPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const canView = hasAnyPermission(permissions, ["masterdata.view", "masterdata.manage"]);
|
||||
const canManage = hasPermission(permissions, "masterdata.manage");
|
||||
|
||||
const [tab, setTab] = useState<"legal-entities" | "cost-centers" | "document-categories" | "holiday-calendars">(
|
||||
"legal-entities",
|
||||
);
|
||||
const [includeArchived, setIncludeArchived] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [confirmArchive, setConfirmArchive] = useState<
|
||||
| null
|
||||
| {
|
||||
kind: "legal-entity" | "cost-center" | "document-category";
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
>(null);
|
||||
|
||||
const [legalEntityEnabledFilter, setLegalEntityEnabledFilter] = useState<"" | "true" | "false">("");
|
||||
const [costCenterEnabledFilter, setCostCenterEnabledFilter] = useState<"" | "true" | "false">("");
|
||||
const [costCenterLegalEntityFilter, setCostCenterLegalEntityFilter] = useState("");
|
||||
const [documentCategoryEnabledFilter, setDocumentCategoryEnabledFilter] = useState<"" | "true" | "false">("");
|
||||
const [documentCategoryEntityTypeFilter, setDocumentCategoryEntityTypeFilter] = useState<
|
||||
"" | DocumentCategoryEntityType
|
||||
>("");
|
||||
|
||||
const [legalEntityCode, setLegalEntityCode] = useState("");
|
||||
const [legalEntityName, setLegalEntityName] = useState("");
|
||||
const [legalEntityShortName, setLegalEntityShortName] = useState("");
|
||||
const [legalEntityCurrency, setLegalEntityCurrency] = useState("CNY");
|
||||
const [legalEntityTaxNo, setLegalEntityTaxNo] = useState("");
|
||||
|
||||
const [costCenterLegalEntityId, setCostCenterLegalEntityId] = useState("");
|
||||
const [costCenterCode, setCostCenterCode] = useState("");
|
||||
const [costCenterName, setCostCenterName] = useState("");
|
||||
const [costCenterParentId, setCostCenterParentId] = useState("");
|
||||
|
||||
const [documentCategoryCode, setDocumentCategoryCode] = useState("");
|
||||
const [documentCategoryName, setDocumentCategoryName] = useState("");
|
||||
const [documentCategoryEntityType, setDocumentCategoryEntityType] =
|
||||
useState<DocumentCategoryEntityType>("reimbursement");
|
||||
const [documentCategorySequencePrefix, setDocumentCategorySequencePrefix] = useState("BX");
|
||||
const [documentCategoryNumberLength, setDocumentCategoryNumberLength] = useState("4");
|
||||
const [documentCategoryResetPeriod, setDocumentCategoryResetPeriod] =
|
||||
useState<DocumentResetPeriod>("yearly");
|
||||
const [documentCategoryWorkflowKey, setDocumentCategoryWorkflowKey] = useState("");
|
||||
|
||||
const legalEntitiesQuery = useQuery({
|
||||
queryKey: ["master-data", "legal-entities", includeArchived, legalEntityEnabledFilter],
|
||||
enabled: canView,
|
||||
queryFn: () =>
|
||||
fetchLegalEntities({
|
||||
includeArchived,
|
||||
enabled: toEnabledFilter(legalEntityEnabledFilter),
|
||||
}),
|
||||
});
|
||||
|
||||
const costCentersQuery = useQuery({
|
||||
queryKey: [
|
||||
"master-data",
|
||||
"cost-centers",
|
||||
includeArchived,
|
||||
costCenterEnabledFilter,
|
||||
costCenterLegalEntityFilter,
|
||||
],
|
||||
enabled: canView,
|
||||
queryFn: () =>
|
||||
fetchCostCenters({
|
||||
includeArchived,
|
||||
enabled: toEnabledFilter(costCenterEnabledFilter),
|
||||
legalEntityId: costCenterLegalEntityFilter || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const documentCategoriesQuery = useQuery({
|
||||
queryKey: [
|
||||
"master-data",
|
||||
"document-categories",
|
||||
includeArchived,
|
||||
documentCategoryEnabledFilter,
|
||||
documentCategoryEntityTypeFilter,
|
||||
],
|
||||
enabled: canView,
|
||||
queryFn: () =>
|
||||
fetchDocumentCategories({
|
||||
includeArchived,
|
||||
enabled: toEnabledFilter(documentCategoryEnabledFilter),
|
||||
entityType: documentCategoryEntityTypeFilter || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const legalEntities = legalEntitiesQuery.data?.items ?? [];
|
||||
const costCenters = costCentersQuery.data?.items ?? [];
|
||||
const documentCategories = documentCategoriesQuery.data?.items ?? [];
|
||||
|
||||
const availableParentCostCenters = useMemo(() => {
|
||||
return costCenters.filter(
|
||||
(item) => !item.archivedAt && item.legalEntityId === costCenterLegalEntityId,
|
||||
);
|
||||
}, [costCenterLegalEntityId, costCenters]);
|
||||
|
||||
const invalidateMasterData = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["master-data", "legal-entities"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["master-data", "cost-centers"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["master-data", "document-categories"] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const legalEntityCreateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return createLegalEntity({
|
||||
code: legalEntityCode,
|
||||
name: legalEntityName,
|
||||
shortName: legalEntityShortName || null,
|
||||
currencyCode: legalEntityCurrency,
|
||||
taxNo: legalEntityTaxNo || null,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateMasterData();
|
||||
setLegalEntityCode("");
|
||||
setLegalEntityName("");
|
||||
setLegalEntityShortName("");
|
||||
setLegalEntityCurrency("CNY");
|
||||
setLegalEntityTaxNo("");
|
||||
setError("");
|
||||
toast.success("法体已创建");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const costCenterCreateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return createCostCenter({
|
||||
legalEntityId: costCenterLegalEntityId,
|
||||
code: costCenterCode,
|
||||
name: costCenterName,
|
||||
parentId: costCenterParentId || null,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateMasterData();
|
||||
setCostCenterLegalEntityId("");
|
||||
setCostCenterCode("");
|
||||
setCostCenterName("");
|
||||
setCostCenterParentId("");
|
||||
setError("");
|
||||
toast.success("成本中心已创建");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const documentCategoryCreateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return createDocumentCategory({
|
||||
code: documentCategoryCode,
|
||||
name: documentCategoryName,
|
||||
entityType: documentCategoryEntityType,
|
||||
sequencePrefix: documentCategorySequencePrefix,
|
||||
numberLength: Number(documentCategoryNumberLength || 4),
|
||||
resetPeriod: documentCategoryResetPeriod,
|
||||
defaultWorkflowKey: documentCategoryWorkflowKey || null,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateMasterData();
|
||||
setDocumentCategoryCode("");
|
||||
setDocumentCategoryName("");
|
||||
setDocumentCategoryEntityType("reimbursement");
|
||||
setDocumentCategorySequencePrefix("BX");
|
||||
setDocumentCategoryNumberLength("4");
|
||||
setDocumentCategoryResetPeriod("yearly");
|
||||
setDocumentCategoryWorkflowKey("");
|
||||
setError("");
|
||||
toast.success("单据分类已创建");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: async (target: NonNullable<typeof confirmArchive>) => {
|
||||
if (target.kind === "legal-entity") {
|
||||
return archiveLegalEntity(target.id);
|
||||
}
|
||||
if (target.kind === "cost-center") {
|
||||
return archiveCostCenter(target.id);
|
||||
}
|
||||
return archiveDocumentCategory(target.id);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateMasterData();
|
||||
setError("");
|
||||
toast.success("已归档");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
setConfirmArchive(null);
|
||||
},
|
||||
});
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="主数据" subtitle="你当前没有主数据权限,请联系管理员开通。" />
|
||||
<div className="card-std p-6 text-sm text-zinc-600">所需权限:masterdata.view 或 masterdata.manage</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="主数据"
|
||||
subtitle="集中维护法体、成本中心、单据分类与日历,为预算、采购、请假与资产扩展提供统一骨架。"
|
||||
actions={
|
||||
<Button variant="secondary" type="button" onClick={() => setIncludeArchived((value) => !value)}>
|
||||
{includeArchived ? "隐藏归档" : "显示归档"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||
) : null}
|
||||
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as typeof tab)}>
|
||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||||
<TabsTrigger value="legal-entities">法体</TabsTrigger>
|
||||
<TabsTrigger value="cost-centers">成本中心</TabsTrigger>
|
||||
<TabsTrigger value="document-categories">单据分类</TabsTrigger>
|
||||
<TabsTrigger value="holiday-calendars">日历</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="legal-entities" className="space-y-6">
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">法体筛选</h3>
|
||||
<select
|
||||
className="select-std max-w-xs"
|
||||
value={legalEntityEnabledFilter}
|
||||
onChange={(event) => setLegalEntityEnabledFilter(event.target.value as "" | "true" | "false")}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="true">仅启用</option>
|
||||
<option value="false">仅停用</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-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="legalEntityCode">编码</Label>
|
||||
<Input id="legalEntityCode" value={legalEntityCode} onChange={(event) => setLegalEntityCode(event.target.value)} placeholder="HYB_CN" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="legalEntityName">名称</Label>
|
||||
<Input id="legalEntityName" value={legalEntityName} onChange={(event) => setLegalEntityName(event.target.value)} placeholder="海音达科技有限公司" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="legalEntityShortName">简称</Label>
|
||||
<Input id="legalEntityShortName" value={legalEntityShortName} onChange={(event) => setLegalEntityShortName(event.target.value)} placeholder="海音达" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="legalEntityCurrency">币种</Label>
|
||||
<Input id="legalEntityCurrency" value={legalEntityCurrency} onChange={(event) => setLegalEntityCurrency(event.target.value)} placeholder="CNY" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="legalEntityTaxNo">税号</Label>
|
||||
<Input id="legalEntityTaxNo" value={legalEntityTaxNo} onChange={(event) => setLegalEntityTaxNo(event.target.value)} placeholder="可选" />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={legalEntityCreateMutation.isPending || !legalEntityCode.trim() || !legalEntityName.trim() || !legalEntityCurrency.trim()}
|
||||
onClick={() => legalEntityCreateMutation.mutate()}
|
||||
>
|
||||
{legalEntityCreateMutation.isPending ? "创建中..." : "创建法体"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card-std p-6 space-y-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">法体列表</h3>
|
||||
{legalEntitiesQuery.isLoading ? <TableSkeleton columns={6} rows={4} /> : null}
|
||||
{!legalEntitiesQuery.isLoading ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编码</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>简称</TableHead>
|
||||
<TableHead>币种</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{legalEntities.length ? (
|
||||
legalEntities.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{item.code}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.shortName ?? "-"}</TableCell>
|
||||
<TableCell>{item.currencyCode}</TableCell>
|
||||
<TableCell>{renderEnabledBadge(item)}</TableCell>
|
||||
<TableCell>
|
||||
{canManage && !item.archivedAt ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setConfirmArchive({
|
||||
kind: "legal-entity",
|
||||
id: item.id,
|
||||
label: `${item.code} / ${item.name}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-zinc-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-sm text-zinc-500">
|
||||
暂无法体数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="cost-centers" className="space-y-6">
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<select
|
||||
className="select-std"
|
||||
value={costCenterEnabledFilter}
|
||||
onChange={(event) => setCostCenterEnabledFilter(event.target.value as "" | "true" | "false")}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="true">仅启用</option>
|
||||
<option value="false">仅停用</option>
|
||||
</select>
|
||||
<select
|
||||
className="select-std"
|
||||
value={costCenterLegalEntityFilter}
|
||||
onChange={(event) => setCostCenterLegalEntityFilter(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 md:col-span-2">
|
||||
<Label htmlFor="costCenterLegalEntity">所属法体</Label>
|
||||
<select
|
||||
id="costCenterLegalEntity"
|
||||
className="select-std"
|
||||
value={costCenterLegalEntityId}
|
||||
onChange={(event) => {
|
||||
setCostCenterLegalEntityId(event.target.value);
|
||||
setCostCenterParentId("");
|
||||
}}
|
||||
>
|
||||
<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="space-y-1">
|
||||
<Label htmlFor="costCenterCode">编码</Label>
|
||||
<Input id="costCenterCode" value={costCenterCode} onChange={(event) => setCostCenterCode(event.target.value)} placeholder="RD" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="costCenterName">名称</Label>
|
||||
<Input id="costCenterName" value={costCenterName} onChange={(event) => setCostCenterName(event.target.value)} placeholder="研发中心" />
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-3">
|
||||
<Label htmlFor="costCenterParent">上级成本中心(可选)</Label>
|
||||
<select
|
||||
id="costCenterParent"
|
||||
className="select-std"
|
||||
value={costCenterParentId}
|
||||
onChange={(event) => setCostCenterParentId(event.target.value)}
|
||||
disabled={!costCenterLegalEntityId}
|
||||
>
|
||||
<option value="">无</option>
|
||||
{availableParentCostCenters.map((item: CostCenterItem) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.code} / {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
costCenterCreateMutation.isPending ||
|
||||
!costCenterLegalEntityId ||
|
||||
!costCenterCode.trim() ||
|
||||
!costCenterName.trim()
|
||||
}
|
||||
onClick={() => costCenterCreateMutation.mutate()}
|
||||
>
|
||||
{costCenterCreateMutation.isPending ? "创建中..." : "创建成本中心"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card-std p-6 space-y-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">成本中心列表</h3>
|
||||
{costCentersQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
|
||||
{!costCentersQuery.isLoading ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编码</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>所属法体</TableHead>
|
||||
<TableHead>上级</TableHead>
|
||||
<TableHead>负责人</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{costCenters.length ? (
|
||||
costCenters.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{item.code}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.legalEntity ? `${item.legalEntity.code} / ${item.legalEntity.name}` : "-"}</TableCell>
|
||||
<TableCell>{item.parent ? `${item.parent.name}` : "-"}</TableCell>
|
||||
<TableCell>{item.owner ? `${item.owner.name} (${item.owner.employeeNo})` : "-"}</TableCell>
|
||||
<TableCell>{renderEnabledBadge(item)}</TableCell>
|
||||
<TableCell>
|
||||
{canManage && !item.archivedAt ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setConfirmArchive({
|
||||
kind: "cost-center",
|
||||
id: item.id,
|
||||
label: `${item.code} / ${item.name}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-zinc-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-sm text-zinc-500">
|
||||
暂无成本中心数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="document-categories" className="space-y-6">
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<select
|
||||
className="select-std"
|
||||
value={documentCategoryEnabledFilter}
|
||||
onChange={(event) =>
|
||||
setDocumentCategoryEnabledFilter(event.target.value as "" | "true" | "false")
|
||||
}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="true">仅启用</option>
|
||||
<option value="false">仅停用</option>
|
||||
</select>
|
||||
<select
|
||||
className="select-std"
|
||||
value={documentCategoryEntityTypeFilter}
|
||||
onChange={(event) => setDocumentCategoryEntityTypeFilter(event.target.value as "" | DocumentCategoryEntityType)}
|
||||
>
|
||||
<option value="">全部实体</option>
|
||||
{DOCUMENT_ENTITY_TYPE_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</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="documentCategoryCode">编码</Label>
|
||||
<Input id="documentCategoryCode" value={documentCategoryCode} onChange={(event) => setDocumentCategoryCode(event.target.value)} placeholder="purchase_general" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="documentCategoryName">名称</Label>
|
||||
<Input id="documentCategoryName" value={documentCategoryName} onChange={(event) => setDocumentCategoryName(event.target.value)} placeholder="日常采购申请" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="documentCategoryEntityType">实体类型</Label>
|
||||
<select
|
||||
id="documentCategoryEntityType"
|
||||
className="select-std"
|
||||
value={documentCategoryEntityType}
|
||||
onChange={(event) => setDocumentCategoryEntityType(event.target.value as DocumentCategoryEntityType)}
|
||||
>
|
||||
{DOCUMENT_ENTITY_TYPE_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="documentCategoryPrefix">流水号前缀</Label>
|
||||
<Input id="documentCategoryPrefix" value={documentCategorySequencePrefix} onChange={(event) => setDocumentCategorySequencePrefix(event.target.value)} placeholder="CG" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="documentCategoryNumberLength">流水号位数</Label>
|
||||
<Input id="documentCategoryNumberLength" type="number" min={2} max={8} value={documentCategoryNumberLength} onChange={(event) => setDocumentCategoryNumberLength(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="documentCategoryResetPeriod">重置周期</Label>
|
||||
<select
|
||||
id="documentCategoryResetPeriod"
|
||||
className="select-std"
|
||||
value={documentCategoryResetPeriod}
|
||||
onChange={(event) => setDocumentCategoryResetPeriod(event.target.value as DocumentResetPeriod)}
|
||||
>
|
||||
{RESET_PERIOD_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label htmlFor="documentCategoryWorkflowKey">默认工作流键(可选)</Label>
|
||||
<Input id="documentCategoryWorkflowKey" value={documentCategoryWorkflowKey} onChange={(event) => setDocumentCategoryWorkflowKey(event.target.value)} placeholder="预留字段,不在本轮启用" />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
documentCategoryCreateMutation.isPending ||
|
||||
!documentCategoryCode.trim() ||
|
||||
!documentCategoryName.trim() ||
|
||||
!documentCategorySequencePrefix.trim()
|
||||
}
|
||||
onClick={() => documentCategoryCreateMutation.mutate()}
|
||||
>
|
||||
{documentCategoryCreateMutation.isPending ? "创建中..." : "创建单据分类"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card-std p-6 space-y-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">单据分类列表</h3>
|
||||
{documentCategoriesQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
|
||||
{!documentCategoriesQuery.isLoading ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编码</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>实体类型</TableHead>
|
||||
<TableHead>前缀</TableHead>
|
||||
<TableHead>重置周期</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{documentCategories.length ? (
|
||||
documentCategories.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{item.code}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>
|
||||
{DOCUMENT_ENTITY_TYPE_OPTIONS.find((option) => option.value === item.entityType)?.label ?? item.entityType}
|
||||
</TableCell>
|
||||
<TableCell>{item.sequencePrefix}</TableCell>
|
||||
<TableCell>
|
||||
{RESET_PERIOD_OPTIONS.find((option) => option.value === item.resetPeriod)?.label ?? item.resetPeriod}
|
||||
</TableCell>
|
||||
<TableCell>{renderEnabledBadge(item)}</TableCell>
|
||||
<TableCell>
|
||||
{canManage && !item.archivedAt ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setConfirmArchive({
|
||||
kind: "document-category",
|
||||
id: item.id,
|
||||
label: `${item.code} / ${item.name}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-zinc-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-sm text-zinc-500">
|
||||
暂无单据分类数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="holiday-calendars" className="space-y-6">
|
||||
<HolidayCalendarTab
|
||||
canManage={canManage}
|
||||
includeArchived={includeArchived}
|
||||
legalEntities={legalEntities}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmArchive)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConfirmArchive(null);
|
||||
}
|
||||
}}
|
||||
title="确认归档"
|
||||
description={confirmArchive ? `归档后将无法继续在新业务中使用:${confirmArchive.label}` : undefined}
|
||||
confirmLabel="确认归档"
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
if (confirmArchive) {
|
||||
await archiveMutation.mutateAsync(confirmArchive);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { ChangePasswordPage } from "../pages/ChangePasswordPage";
|
||||
import { BudgetsPage } from "../pages/BudgetsPage";
|
||||
import { DashboardPage } from "../pages/DashboardPage";
|
||||
import { DepartmentsPage } from "../pages/DepartmentsPage";
|
||||
import { MasterDataPage } from "../pages/MasterDataPage";
|
||||
import { DelegationsPage } from "../pages/DelegationsPage";
|
||||
import { ForbiddenPage } from "../pages/ForbiddenPage";
|
||||
import { MobileApprovalsPage } from "../pages/MobileApprovalsPage";
|
||||
@@ -180,6 +181,15 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/master-data"
|
||||
element={
|
||||
<RequirePermission anyOf={["masterdata.view", "masterdata.manage"]}>
|
||||
<MasterDataPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/approval-rules"
|
||||
element={
|
||||
|
||||
在新工单中引用
屏蔽一个用户