824 lines
25 KiB
TypeScript
824 lines
25 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import {
|
|
closestCenter,
|
|
DndContext,
|
|
KeyboardSensor,
|
|
MouseSensor,
|
|
TouchSensor,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
type UniqueIdentifier,
|
|
} from '@dnd-kit/core';
|
|
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
|
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import {
|
|
IconChevronDown,
|
|
IconChevronLeft,
|
|
IconChevronRight,
|
|
IconChevronsLeft,
|
|
IconChevronsRight,
|
|
IconDotsVertical,
|
|
IconLayoutColumns,
|
|
IconPlus,
|
|
IconStarFilled,
|
|
IconBuilding,
|
|
IconId,
|
|
IconMars,
|
|
IconVenus,
|
|
IconEye,
|
|
IconTrash,
|
|
} from '@tabler/icons-react';
|
|
import {
|
|
ColumnDef,
|
|
ColumnFiltersState,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
getFacetedRowModel,
|
|
getFacetedUniqueValues,
|
|
getFilteredRowModel,
|
|
getSortedRowModel,
|
|
Row,
|
|
SortingState,
|
|
useReactTable,
|
|
VisibilityState,
|
|
} from '@tanstack/react-table';
|
|
import dayjs from 'dayjs';
|
|
import 'dayjs/locale/zh-cn';
|
|
import { Badge } from '@nice/ui/components/badge';
|
|
import { Button } from '@nice/ui/components/button';
|
|
import { Checkbox } from '@nice/ui/components/checkbox';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@nice/ui/components/dropdown-menu';
|
|
import { Label } from '@nice/ui/components/label';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@nice/ui/components/select';
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@nice/ui/components/table';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@nice/ui/components/avatar';
|
|
import { Input } from '@nice/ui/components/input';
|
|
import { ProfileDetail, ProfileMetadata } from '@fenghuo/common/profile';
|
|
import { TaxonomySlug } from '@fenghuo/common/term';
|
|
import { MultipleDeptSelector } from '../selector/dept-select';
|
|
import { useTranslation } from '@nice/i18n'
|
|
|
|
// 职务等级星星显示组件
|
|
function DutyLevelStars({ level }: { level: number | string }) {
|
|
// 转换为数字并检查是否为有效的正整数
|
|
const numLevel = Number(level);
|
|
if (!numLevel || numLevel <= 0) return null;
|
|
|
|
const stars = Array.from({ length: 3 }, (_, index) => {
|
|
const filled = index < numLevel;
|
|
return filled ? (
|
|
<IconStarFilled key={index} className="size-3 text-yellow-500" />
|
|
) : (
|
|
null
|
|
);
|
|
});
|
|
return <div className="flex items-center gap-0.5">{stars}</div>;
|
|
}
|
|
|
|
// 日期格式化辅助函数
|
|
function formatDate(date: Date | string | null | undefined): string {
|
|
if (!date) return '-';
|
|
try {
|
|
return dayjs(date).format('YYYY-MM-DD');
|
|
} catch {
|
|
return '-';
|
|
}
|
|
}
|
|
|
|
// 政治面貌和党派职务显示组件
|
|
function PoliticalInfo({ metadata }: { metadata?: ProfileMetadata }) {
|
|
if (!metadata?.politicalStatus && !metadata?.partyPosition) {
|
|
return <span className="text-muted-foreground">-</span>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
{metadata.politicalStatus && (
|
|
<Badge variant="outline" className="text-xs">
|
|
{metadata.politicalStatus}
|
|
</Badge>
|
|
)}
|
|
{metadata.partyPosition && (
|
|
<div className="text-xs text-muted-foreground break-words">{metadata.partyPosition}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 籍贯显示组件
|
|
function NativePlace({ native }: { native?: ProfileMetadata['native'] }) {
|
|
if (!native) return <span className="text-muted-foreground">-</span>;
|
|
|
|
const parts = [native.province, native.city, native.county].filter(Boolean);
|
|
return <span className="break-words">{parts.join(' ')}</span>;
|
|
}
|
|
|
|
// 学历信息显示组件
|
|
function EducationInfo({ metadata }: { metadata?: ProfileMetadata }) {
|
|
if (!metadata?.education && !metadata?.educationForm && !metadata?.schoolMajor) {
|
|
return <span className="text-muted-foreground">-</span>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="flex items-start gap-1 flex-wrap">
|
|
{metadata.education && (
|
|
<Badge variant="outline" className="text-xs flex-shrink-0">
|
|
{metadata.education}
|
|
</Badge>
|
|
)}
|
|
{metadata.educationForm && (
|
|
<span className="text-xs text-muted-foreground break-words">({metadata.educationForm})</span>
|
|
)}
|
|
</div>
|
|
{metadata.schoolMajor && (
|
|
<div className="text-xs text-muted-foreground break-words">{metadata.schoolMajor}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 组织信息展示组件
|
|
function OrganizationInfo({ organization }: { organization?: ProfileDetail['organization'] }) {
|
|
const { t } = useTranslation();
|
|
|
|
if (!organization) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<IconBuilding className="size-4 text-muted-foreground flex-shrink-0" />
|
|
<div className="text-sm">{t('profile.profile_table.table.no_organization')}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 提取组织类型术语(作为标签展示)
|
|
const organizationTypeTerms = organization.terms?.filter(
|
|
(term) => term.taxonomy?.slug === TaxonomySlug.ORGANIZATION_TYPE
|
|
) || [];
|
|
|
|
// 提取专业术语(作为小字展示)
|
|
const professionTerms = organization.terms?.filter(
|
|
(term) => term.taxonomy?.slug === TaxonomySlug.PROFESSION
|
|
) || [];
|
|
|
|
return (
|
|
<div className="flex items-start gap-2">
|
|
<IconBuilding className="size-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
|
<div className="space-y-1 min-w-0 flex-1">
|
|
<div className="flex items-start gap-2 flex-wrap">
|
|
{
|
|
organization.parentId ? (
|
|
<span className="text-sm break-words min-w-0">{organization?.parent?.name}/{organization.name}</span>
|
|
) : (
|
|
<span className="text-sm break-words min-w-0">{organization.name}</span>
|
|
)
|
|
}
|
|
{/* 组织类型标签 */}
|
|
{organizationTypeTerms.map((term) => (
|
|
<Badge key={term.id} variant="secondary" className="text-xs flex-shrink-0">
|
|
{term.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
{/* 专业信息 */}
|
|
{professionTerms.length > 0 && (
|
|
<div className="text-xs text-muted-foreground break-words">
|
|
{professionTerms.map((term) => term.name).join(' · ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 可拖拽行组件
|
|
function DraggableRow({ row }: { row: Row<ProfileDetail> }) {
|
|
const { transform, transition, setNodeRef, isDragging } = useSortable({
|
|
id: row.original.id,
|
|
});
|
|
|
|
return (
|
|
<TableRow
|
|
data-state={row.getIsSelected() && 'selected'}
|
|
data-dragging={isDragging}
|
|
ref={setNodeRef}
|
|
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
|
|
style={{
|
|
transform: CSS.Transform.toString(transform),
|
|
transition: transition,
|
|
}}
|
|
>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<TableCell
|
|
key={cell.id}
|
|
className="whitespace-normal break-words"
|
|
style={{
|
|
width: `${cell.column.getSize()}px`,
|
|
minWidth: `${cell.column.getSize()}px`,
|
|
maxWidth: `${cell.column.getSize()}px`,
|
|
}}
|
|
>
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
);
|
|
}
|
|
|
|
// 分页信息接口
|
|
interface PaginationInfo {
|
|
page: number;
|
|
pageSize: number;
|
|
totalPages: number;
|
|
totalCount: number;
|
|
hasNextPage: boolean;
|
|
hasPreviousPage: boolean;
|
|
}
|
|
|
|
// 主数据表组件
|
|
export function ProfileDataTable({
|
|
data: initialData,
|
|
isLoading = false,
|
|
pagination,
|
|
onPageChange,
|
|
onPageSizeChange,
|
|
onAddEmployee,
|
|
onViewDetail,
|
|
onDeleteEmployee,
|
|
searchValue = '',
|
|
onSearchChange,
|
|
// 添加部门筛选相关属性
|
|
selectedOrganizationIds = [],
|
|
onOrganizationChange,
|
|
sortBy = 'default',
|
|
onSortChange,
|
|
}: {
|
|
data: ProfileDetail[];
|
|
isLoading?: boolean;
|
|
pagination?: PaginationInfo;
|
|
onPageChange?: (page: number) => void;
|
|
onPageSizeChange?: (size: number) => void;
|
|
onAddEmployee?: () => void;
|
|
onViewDetail?: (profileId: string) => void;
|
|
onDeleteEmployee?: (profileId: string) => void;
|
|
searchValue?: string;
|
|
onSearchChange?: (value: string) => void;
|
|
// 添加部门筛选相关类型
|
|
selectedOrganizationIds?: string[];
|
|
onOrganizationChange?: (organizationIds: string[]) => void;
|
|
// 添加排序相关类型
|
|
sortBy?: string;
|
|
onSortChange?: (sortBy: string) => void;
|
|
}) {
|
|
const [data, setData] = React.useState(() => initialData);
|
|
const [rowSelection, setRowSelection] = React.useState({});
|
|
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({
|
|
// 默认隐藏的列
|
|
hireInfo: false,
|
|
politicalInfo: false,
|
|
nativePlace: false,
|
|
trainAppraisal: false,
|
|
educationInfo: false,
|
|
otherInfo: false,
|
|
birthDate: false,
|
|
});
|
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
|
|
|
// 移除本地分页状态,使用服务端分页
|
|
const sortableId = React.useId();
|
|
const sensors = useSensors(useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), useSensor(KeyboardSensor, {}));
|
|
const { t } = useTranslation();
|
|
// 定义列配置 - 移到组件内部以访问回调函数
|
|
const columns: ColumnDef<ProfileDetail>[] = [
|
|
{
|
|
id: 'select',
|
|
header: ({ table }) => (
|
|
<div className="flex items-center justify-center">
|
|
<Checkbox
|
|
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')}
|
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
|
aria-label={t('common.select_all')}
|
|
/>
|
|
</div>
|
|
),
|
|
cell: ({ row }) => (
|
|
<div className="flex items-center justify-center">
|
|
<Checkbox
|
|
checked={row.getIsSelected()}
|
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
aria-label={t('common.select_row')}
|
|
/>
|
|
</div>
|
|
),
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
size: 20,
|
|
},
|
|
{
|
|
accessorKey: 'name',
|
|
header: t('profile.profile_table.column.name'),
|
|
cell: ({ row }) => {
|
|
const profile = row.original;
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<Avatar className="size-12 flex-shrink-0">
|
|
{profile.avatar ? (
|
|
<AvatarImage
|
|
src={profile.avatar}
|
|
alt={profile.name}
|
|
className="object-contain" // 添加这个类
|
|
/>
|
|
) : (
|
|
<AvatarFallback className="text-xs">
|
|
{profile.name.slice(0, 1)}
|
|
</AvatarFallback>
|
|
)}
|
|
</Avatar>
|
|
<div className="flex flex-row items-center justify-center gap-1">
|
|
<div className="font-medium">{profile.name}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{profile.gender === 1 ? (
|
|
<IconMars className="size-4 text-blue-500" />
|
|
) : (
|
|
<IconVenus className="size-4 text-pink-500" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
enableHiding: false,
|
|
size: 120,
|
|
},
|
|
{
|
|
accessorKey: 'certificates',
|
|
header: t('profile.profile_table.column.id_card'),
|
|
cell: ({ row }) => {
|
|
const profile = row.original;
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="text-sm font-mono flex items-center gap-1 break-all">
|
|
<IconId className="size-4 text-muted-foreground flex-shrink-0" />
|
|
<span className="break-all">{profile.paperId}</span>
|
|
</div>
|
|
{profile.paperId && (
|
|
<div className="text-xs text-muted-foreground font-mono break-all">{t('profile.profile_table.table.id_card_label')}{profile.idNum}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
enableHiding: true,
|
|
size: 140,
|
|
},
|
|
{
|
|
accessorKey: 'command',
|
|
header: t('profile.profile_table.column.formation_command'),
|
|
cell: ({ row }) => {
|
|
const profile = row.original;
|
|
return (
|
|
<div className="text-xs break-words whitespace-normal text-muted-foreground">
|
|
{profile.command || <span className="text-muted-foreground"></span>}
|
|
</div>
|
|
);
|
|
},
|
|
enableHiding: true,
|
|
size: 120,
|
|
},
|
|
{
|
|
accessorKey: 'dutyInfo',
|
|
header: t('profile.profile_table.column.duty'),
|
|
cell: ({ row }) => {
|
|
const profile = row.original;
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="text-sm font-medium flex items-center gap-2 break-words">
|
|
<span className="break-words">{profile.dutyName}</span>
|
|
{profile.dutyLevel > 0 && <DutyLevelStars level={profile.dutyLevel} />}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground break-words">{profile.dutyCode}</div>
|
|
</div>
|
|
);
|
|
},
|
|
enableHiding: true,
|
|
size: 100,
|
|
},
|
|
{
|
|
accessorKey: 'organization',
|
|
header: t('profile.profile_table.column.organization'),
|
|
cell: ({ row }) => {
|
|
const profile = row.original;
|
|
return <OrganizationInfo organization={profile.organization} />;
|
|
},
|
|
enableHiding: true,
|
|
size: 180,
|
|
},
|
|
{
|
|
accessorKey: 'identityInfo',
|
|
header: t('profile.profile_table.column.identity'),
|
|
cell: ({ row }) => {
|
|
const profile = row.original;
|
|
return (
|
|
<div className="space-y-1">
|
|
{profile.identity && (
|
|
<div className="flex items-start gap-2 flex-wrap">
|
|
<Badge variant="outline" className="text-xs flex-shrink-0">
|
|
{profile.identity}
|
|
</Badge>
|
|
{
|
|
profile.level && (
|
|
<span className="font-medium break-words min-w-0">{profile.level}</span>
|
|
)
|
|
}
|
|
</div>
|
|
)}
|
|
|
|
{profile.levelDate && (
|
|
<div className="text-muted-foreground text-xs break-words">
|
|
{formatDate(profile.levelDate)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
enableHiding: true,
|
|
size: 120,
|
|
},
|
|
{
|
|
accessorKey: 'hireInfo',
|
|
header: t('profile.profile_table.column.hire_date'),
|
|
cell: ({ row }) => {
|
|
const profile = row.original;
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="text-sm break-words">{formatDate(profile.hireDate)}</div>
|
|
{profile.relativeHireDate && profile.hireDate !== profile.relativeHireDate && (
|
|
<div className="text-xs text-muted-foreground break-words">
|
|
{t('profile.profile_sheet.relative_hire_time')}: {formatDate(profile.relativeHireDate)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
enableHiding: true,
|
|
size: 120,
|
|
},
|
|
|
|
// 以下列默认隐藏
|
|
{
|
|
accessorKey: 'politicalInfo',
|
|
header: t('profile.profile_table.column.political'),
|
|
cell: ({ row }) => {
|
|
const metadata = row.original.metadata as ProfileMetadata;
|
|
return <PoliticalInfo metadata={metadata} />;
|
|
},
|
|
enableHiding: true,
|
|
size: 100,
|
|
},
|
|
{
|
|
accessorKey: 'nativePlace',
|
|
header: t('profile.profile_table.column.native'),
|
|
cell: ({ row }) => {
|
|
const metadata = row.original.metadata as ProfileMetadata;
|
|
return <NativePlace native={metadata?.native} />;
|
|
},
|
|
enableHiding: true,
|
|
size: 130,
|
|
},
|
|
{
|
|
accessorKey: 'educationInfo',
|
|
header: t('profile.profile_table.column.education'),
|
|
cell: ({ row }) => {
|
|
const metadata = row.original.metadata as ProfileMetadata;
|
|
return <EducationInfo metadata={metadata} />;
|
|
},
|
|
enableHiding: true,
|
|
size: 100,
|
|
},
|
|
{
|
|
accessorKey: 'birthDate',
|
|
header: t('profile.profile_table.table.birth_date'),
|
|
cell: ({ row }) => {
|
|
const profile = row.original;
|
|
return (
|
|
<div className="text-sm break-words font-medium">
|
|
{formatDate(profile.birthday)}
|
|
</div>
|
|
);
|
|
},
|
|
enableHiding: true,
|
|
size: 80,
|
|
},
|
|
{
|
|
id: 'actions',
|
|
header: t('common.actions'),
|
|
cell: ({ row }) => (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" className="data-[state=open]:bg-muted text-muted-foreground flex size-8" size="icon">
|
|
<IconDotsVertical />
|
|
<span className="sr-only">{t('common.open_menu')}</span>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-32">
|
|
<DropdownMenuItem onClick={() => onViewDetail?.(row.original.id)}>
|
|
<IconEye className="mr-2 h-4 w-4 cursor-pointer" />
|
|
{t('common.view_details')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem variant="destructive" onClick={() => onDeleteEmployee?.(row.original.id)}>
|
|
<IconTrash className="mr-2 h-4 w-4" />
|
|
{t('common.delete')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
),
|
|
enableHiding: false,
|
|
size: 50,
|
|
},
|
|
];
|
|
|
|
// 当数据更新时,同步本地状态
|
|
React.useEffect(() => {
|
|
setData(initialData);
|
|
}, [initialData]);
|
|
|
|
const dataIds = React.useMemo<UniqueIdentifier[]>(() => data?.map(({ id }) => id) || [], [data]);
|
|
|
|
const table = useReactTable({
|
|
data,
|
|
columns,
|
|
state: {
|
|
sorting,
|
|
columnVisibility,
|
|
rowSelection,
|
|
columnFilters,
|
|
},
|
|
getRowId: (row) => row.id,
|
|
enableRowSelection: true,
|
|
onRowSelectionChange: setRowSelection,
|
|
onSortingChange: setSorting,
|
|
onColumnFiltersChange: setColumnFilters,
|
|
onColumnVisibilityChange: setColumnVisibility,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getFilteredRowModel: getFilteredRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getFacetedRowModel: getFacetedRowModel(),
|
|
getFacetedUniqueValues: getFacetedUniqueValues(),
|
|
// 禁用本地分页,使用服务端分页
|
|
manualPagination: true,
|
|
// 禁用列调整大小功能,保持列宽度固定
|
|
enableColumnResizing: false,
|
|
});
|
|
|
|
function handleDragEnd(event: DragEndEvent) {
|
|
const { active, over } = event;
|
|
if (active && over && active.id !== over.id) {
|
|
setData((data) => {
|
|
const oldIndex = dataIds.indexOf(active.id);
|
|
const newIndex = dataIds.indexOf(over.id);
|
|
return arrayMove(data, oldIndex, newIndex);
|
|
});
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="w-full space-y-4">
|
|
{/* 顶部工具栏 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<h2 className="text-lg font-semibold">{t('profile.profile_table.title')}</h2>
|
|
<Badge variant="outline">
|
|
{isLoading ? t('common.loading') : t('profile.profile_table.table.records_count', { count: pagination?.totalCount || 0 })}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* 部门筛选器 */}
|
|
<MultipleDeptSelector
|
|
value={selectedOrganizationIds}
|
|
onValueChange={onOrganizationChange}
|
|
placeholder={t('profile.profile_table.toolbar.filter_organization')}
|
|
className="w-92 h-8"
|
|
allowClear
|
|
/>
|
|
{/* 搜索框 */}
|
|
<div className="relative]">
|
|
<Input
|
|
placeholder={t('profile.profile_table.toolbar.search_placeholder')}
|
|
value={searchValue}
|
|
onChange={(e) => onSearchChange?.(e.target.value)}
|
|
className="w-56 border border-[#5D6E89] h-8"
|
|
/>
|
|
</div>
|
|
{/* 列显示选择器 */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm">
|
|
<IconLayoutColumns className="size-4" />
|
|
<span className="hidden lg:inline">{t('common.custom_columns')}</span>
|
|
<span className="lg:hidden">{t('common.columns')}</span>
|
|
<IconChevronDown className="size-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56">
|
|
{table
|
|
.getAllColumns()
|
|
.filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide())
|
|
.map((column) => {
|
|
return (
|
|
<DropdownMenuCheckboxItem
|
|
key={column.id}
|
|
className="capitalize"
|
|
checked={column.getIsVisible()}
|
|
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
|
onSelect={(event) => {
|
|
event.preventDefault();
|
|
}}
|
|
>
|
|
{column.columnDef.header as string}
|
|
</DropdownMenuCheckboxItem>
|
|
);
|
|
})}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* 排序方式选择器 */}
|
|
<Select value={sortBy || 'default'} onValueChange={onSortChange}>
|
|
<SelectTrigger className="w-46 border-[#5D6E89] !h-8 min-h-8">
|
|
<SelectValue placeholder={t('profile.profile_table.toolbar.sort_method')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="default">{t('profile.profile_table.toolbar.default_sort')}</SelectItem>
|
|
<SelectItem value="dutyLevelDown">{t('profile.profile_table.toolbar.duty_level_desc')}</SelectItem>
|
|
<SelectItem value="dutyLevelUp">{t('profile.profile_table.toolbar.duty_level_asc')}</SelectItem>
|
|
<SelectItem value="hireDateDown">{t('profile.profile_table.toolbar.hire_date_desc')}</SelectItem>
|
|
<SelectItem value="hireDateUp">{t('profile.profile_table.toolbar.hire_date_asc')}</SelectItem>
|
|
<SelectItem value="birthdayDown">{t('profile.profile_table.toolbar.birthday_desc')}</SelectItem>
|
|
<SelectItem value="birthdayUp">{t('profile.profile_table.toolbar.birthday_asc')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Button variant="outline" className='border-[#5D6E89]' size="sm" onClick={onAddEmployee}>
|
|
<IconPlus className="size-4" />
|
|
<span className="hidden lg:inline">{t('profile.profile_table.toolbar.add_employee')}</span>
|
|
<span className="lg:hidden">{t('common.add')}</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 数据表 */}
|
|
<div className="rounded-md border border-border max-h-[720px] overflow-auto">
|
|
<DndContext
|
|
collisionDetection={closestCenter}
|
|
modifiers={[restrictToVerticalAxis]}
|
|
onDragEnd={handleDragEnd}
|
|
sensors={sensors}
|
|
id={sortableId}
|
|
>
|
|
<Table className="table-fixed w-full" stickyHeader>
|
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<TableRow key={headerGroup.id} className="bg-muted/50">
|
|
{headerGroup.headers.map((header) => {
|
|
return (
|
|
<TableHead
|
|
key={header.id}
|
|
colSpan={header.colSpan}
|
|
className="whitespace-normal break-words"
|
|
style={{
|
|
width: `${header.getSize()}px`,
|
|
minWidth: `${header.getSize()}px`,
|
|
maxWidth: `${header.getSize()}px`,
|
|
}}
|
|
>
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
</TableHead>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
<TableBody>
|
|
{isLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={table.getAllColumns().filter(column => column.getIsVisible()).length} className="h-24 text-center">
|
|
{t('common.loading')}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : table.getRowModel().rows?.length ? (
|
|
<SortableContext items={dataIds} strategy={verticalListSortingStrategy}>
|
|
{table.getRowModel().rows.map((row) => (
|
|
<DraggableRow key={row.id} row={row} />
|
|
))}
|
|
</SortableContext>
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={table.getAllColumns().filter(column => column.getIsVisible()).length} className="h-24 text-center">
|
|
{t('common.no_data')}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</DndContext>
|
|
</div>
|
|
|
|
{/* 分页器 - 使用服务端分页 */}
|
|
{pagination && (
|
|
<div className="flex items-center justify-between px-4">
|
|
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
|
{t('pagination.selected_rows', {
|
|
selected: table.getFilteredSelectedRowModel().rows.length,
|
|
total: pagination.totalCount
|
|
})}
|
|
</div>
|
|
<div className="flex w-full items-center gap-8 lg:w-fit">
|
|
<div className="hidden items-center gap-2 lg:flex">
|
|
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
|
{t('pagination.rows_per_page')}
|
|
</Label>
|
|
<Select
|
|
value={`${pagination.pageSize}`}
|
|
onValueChange={(value) => {
|
|
onPageSizeChange?.(Number(value));
|
|
}}
|
|
>
|
|
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
|
<SelectValue placeholder={pagination.pageSize} />
|
|
</SelectTrigger>
|
|
<SelectContent side="top">
|
|
{[10, 20, 30, 40, 50].map((pageSize) => (
|
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
|
{pageSize}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
|
{t('pagination.page_info', {
|
|
current: pagination.page,
|
|
total: pagination.totalPages
|
|
})}
|
|
</div>
|
|
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
|
<Button
|
|
variant="outline"
|
|
className="hidden h-8 w-8 p-0 lg:flex"
|
|
onClick={() => onPageChange?.(1)}
|
|
disabled={!pagination.hasPreviousPage}
|
|
>
|
|
<span className="sr-only">{t('pagination.first_page')}</span>
|
|
<IconChevronsLeft />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="size-8"
|
|
size="icon"
|
|
onClick={() => onPageChange?.(pagination.page - 1)}
|
|
disabled={!pagination.hasPreviousPage}
|
|
>
|
|
<span className="sr-only">{t('pagination.previous_page')}</span>
|
|
<IconChevronLeft />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="size-8"
|
|
size="icon"
|
|
onClick={() => onPageChange?.(pagination.page + 1)}
|
|
disabled={!pagination.hasNextPage}
|
|
>
|
|
<span className="sr-only">{t('pagination.next_page')}</span>
|
|
<IconChevronRight />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="hidden size-8 lg:flex"
|
|
size="icon"
|
|
onClick={() => onPageChange?.(pagination.totalPages)}
|
|
disabled={!pagination.hasNextPage}
|
|
>
|
|
<span className="sr-only">{t('pagination.last_page')}</span>
|
|
<IconChevronsRight />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|