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

这个提交包含在:
2026-03-07 17:29:03 +08:00
父节点 32a3d3724d
当前提交 195f46a2e3
共修改 35 个文件,包含 4556 行新增和 27 行删除
@@ -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: "审批规则",