diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index d8691c2f..5f07740b 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -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 "请假冻结账本状态异常,请联系管理员处理。"; } diff --git a/client/src/api/featureFlags.ts b/client/src/api/featureFlags.ts index e4f420e2..cb91f7ce 100644 --- a/client/src/api/featureFlags.ts +++ b/client/src/api/featureFlags.ts @@ -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"; diff --git a/client/src/api/masterData.ts b/client/src/api/masterData.ts new file mode 100644 index 00000000..fc8a6491 --- /dev/null +++ b/client/src/api/masterData.ts @@ -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 = { + includeArchived: boolean; + items: T[]; +}; + +function toQueryString(query: Record) { + 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>( + `/api/master-data/legal-entities${toQueryString(params as Record)}`, + ); +} + +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>( + `/api/master-data/cost-centers${toQueryString(params as Record)}`, + ); +} + +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>( + `/api/master-data/document-categories${toQueryString(params as Record)}`, + ); +} + +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>( + `/api/master-data/holiday-calendars${toQueryString(params as Record)}`, + ); +} + +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 }), + }, + ); +} diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index 640f0bfa..beb297de 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -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 = { ], ceo: [ ...EMPLOYEE_BASE_PERMISSIONS, + ...MASTERDATA_VIEW_PERMISSIONS, "expense.approve", "expense.transfer", "expense.view_all", @@ -45,6 +49,7 @@ const ROLE_PERMISSIONS: Record = { ], finance: [ ...EMPLOYEE_BASE_PERMISSIONS, + ...MASTERDATA_VIEW_PERMISSIONS, "invoice.manage", "invoice.verify", "report.dashboard.view", @@ -55,6 +60,7 @@ const ROLE_PERMISSIONS: Record = { ], finance_mgr: [ ...EMPLOYEE_BASE_PERMISSIONS, + ...MASTERDATA_MANAGE_PERMISSIONS, "invoice.manage", "invoice.verify", "report.dashboard.view", diff --git a/client/src/components/master-data/HolidayCalendarTab.tsx b/client/src/components/master-data/HolidayCalendarTab.tsx new file mode 100644 index 00000000..138e312b --- /dev/null +++ b/client/src/components/master-data/HolidayCalendarTab.tsx @@ -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("holiday"); + const [dayName, setDayName] = useState(""); + const [dayNote, setDayNote] = useState(""); + const [draftDays, setDraftDays] = useState([]); + const [confirmArchiveId, setConfirmArchiveId] = useState(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 ( +
+ {error ? ( +

{error}

+ ) : null} + +
+
+ + +
+
+ + {canManage ? ( +
+

新建日历

+
+
+ + setCalendarCode(event.target.value)} + placeholder="HYB_CN_STD" + /> +
+
+ + setCalendarName(event.target.value)} + placeholder="海音达默认日历" + /> +
+
+ + setCalendarTimezone(event.target.value)} + placeholder="Asia/Shanghai" + /> +
+
+ + +
+
+ +
+
+

+ 首个绑定到法体的日历会自动成为该法体默认日历;后续切换默认日历将放在后续阶段补齐。 +

+
+ ) : null} + +
+
+
+

日历列表

+ 共 {holidayCalendars.length} 项 +
+ + {calendarsQuery.isLoading ? : null} + + {!calendarsQuery.isLoading ? ( +
+ + + + 编码 + 名称 + 法体 + 日期数 + 状态 + 操作 + + + + {holidayCalendars.length ? ( + holidayCalendars.map((item) => ( + setSelectedCalendarId(item.id)} + > + +
{item.code}
+
{item.timezone}
+
+ +
{item.name}
+ {item.isDefaultForLegalEntity ? ( +
默认法体日历
+ ) : null} +
+ {item.legalEntity ? `${item.legalEntity.code} / ${item.legalEntity.name}` : "全局"} + {item.daysCount} + {item.archivedAt ? : renderEnabledBadge(item)} + +
+ + {canManage && !item.archivedAt ? ( + + ) : null} +
+
+
+ )) + ) : ( + + + 暂无日历数据 + + + )} +
+
+
+ ) : null} +
+ +
+
+

日期明细维护

+

+ {selectedCalendar + ? `当前:${selectedCalendar.code} / ${selectedCalendar.name}` + : "请选择左侧日历后维护日期明细"} +

+
+ + {selectedCalendarId && daysQuery.isLoading ? : null} + + {selectedCalendar ? ( + <> + {canManage ? ( +
+
+ + setDayDate(event.target.value)} + /> +
+
+ + +
+
+ + setDayName(event.target.value)} + placeholder="如:元旦 / 调休补班" + /> +
+
+ +