451 lines
22 KiB
TypeScript
451 lines
22 KiB
TypeScript
'use client';
|
||
|
||
import * as React from 'react';
|
||
import { IconStarFilled, IconBuilding, IconPlus, IconDownload, IconSearch, IconChevronLeft, IconChevronRight, IconChevronsLeft, IconChevronsRight } from '@tabler/icons-react';
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@nice/ui/components/table';
|
||
import { Button } from '@nice/ui/components/button';
|
||
import { Badge } from '@nice/ui/components/badge';
|
||
import { Input } from '@nice/ui/components/input';
|
||
import { Label } from '@nice/ui/components/label';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@nice/ui/components/select';
|
||
import { MultipleProfessionSelector, MultipleStationSelector, MultipleDutyLevelSelector } from '@/components/selector';
|
||
import { DutyLevel } from '@fenghuo/common';
|
||
import { useTranslation } from '@nice/i18n';
|
||
import { EliteFormData } from '@fenghuo/common';
|
||
|
||
|
||
const DUTY_LEVEL_OPTIONS = [
|
||
{ value: 3, label: DutyLevel.HIGH },
|
||
{ value: 2, label: DutyLevel.MIDDLE },
|
||
{ value: 1, label: DutyLevel.PRIMARY },
|
||
];
|
||
|
||
// 职务等级星星显示组件
|
||
function DutyLevelStars({ level }: { level: number }) {
|
||
const stars = Array.from({ length: level }, (_, index) => (
|
||
<IconStarFilled key={index} className="size-2.5 text-yellow-500" />
|
||
));
|
||
if (level === 0) {
|
||
return null;
|
||
}
|
||
return <div className="flex items-center gap-1">{stars}</div>;
|
||
}
|
||
|
||
function DutyLevelText({ level }: { level: number }) {
|
||
const option = DUTY_LEVEL_OPTIONS.find(option => option.value === level);
|
||
return <div className="text-xs text-muted-foreground break-words">{option?.label}</div>;
|
||
}
|
||
|
||
// 人员信息显示组件
|
||
function PersonInfo({
|
||
person,
|
||
onViewDetail
|
||
}: {
|
||
person: {
|
||
id: string;
|
||
name: string;
|
||
dutyName: string;
|
||
dutyCode: string;
|
||
dutyLevel: number;
|
||
};
|
||
onViewDetail: (id: string) => void;
|
||
}) {
|
||
const hasStars = person.dutyLevel > 0;
|
||
|
||
return (
|
||
<div
|
||
onClick={() => onViewDetail(person.id)}
|
||
className="flex flex-row justify-center break-words min-h-[35px] items-center p-1 bg-muted rounded-lg"
|
||
>
|
||
<div className="flex items-center flex-wrap justify-center cursor-pointer">
|
||
<span className="text-[14px] break-words mt-[1px]">{person.name}</span>
|
||
</div>
|
||
<div className={`flex ${hasStars ? 'flex-col' : ''} items-center justify-center cursor-pointer`}>
|
||
<span className="text-[12px] text-muted-foreground break-words">(#{person.dutyCode})</span>
|
||
{hasStars && <DutyLevelStars level={person.dutyLevel} />}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 人员列表显示组件
|
||
function PersonList({
|
||
persons,
|
||
onViewDetail
|
||
}: {
|
||
persons: Array<{
|
||
id: string;
|
||
name: string;
|
||
dutyName: string;
|
||
dutyCode: string;
|
||
dutyLevel: number;
|
||
}>;
|
||
onViewDetail: (id: string) => void;
|
||
}) {
|
||
if (!persons || persons.length === 0) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-wrap justify-center items-center gap-1 min-h-[2rem]">
|
||
{persons.sort((a, b) => b.dutyLevel - a.dutyLevel).map((person) => (
|
||
<PersonInfo
|
||
key={person.id}
|
||
person={person}
|
||
onViewDetail={onViewDetail}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface EliteDataTableProps {
|
||
data: EliteFormData[];
|
||
isLoading?: boolean;
|
||
onViewDetail?: (personId: string) => void;
|
||
onProfessionClick?: (professionName: string, professionId: string) => void; // 修改为传递专业ID
|
||
onStationClick?: (stationName: string, stationId: string, professionId: string) => void; // 新增台站点击处理
|
||
onAddEmployee?: () => void;
|
||
onExportTable?: () => void;
|
||
onExportFilteredTable?: () => void;
|
||
isExporting?: boolean;
|
||
// 筛选相关props
|
||
selectedProfessionIds?: string[];
|
||
onProfessionIdsChange?: (ids: string[]) => void;
|
||
selectedStationIds?: string[];
|
||
onStationIdsChange?: (ids: string[]) => void;
|
||
selectedDutyLevels?: number[];
|
||
onDutyLevelsChange?: (levels: number[]) => void;
|
||
// 搜索相关props
|
||
searchKeyword?: string;
|
||
onSearchKeywordChange?: (keyword: string) => void;
|
||
// 新增分页相关props
|
||
currentPage?: number;
|
||
totalPages?: number;
|
||
totalCount?: number;
|
||
pageSize?: number;
|
||
hasNextPage?: boolean;
|
||
hasPreviousPage?: boolean;
|
||
onPageChange?: (page: number) => void;
|
||
onPageSizeChange?: (pageSize: number) => void;
|
||
}
|
||
|
||
export function EliteDataTable({
|
||
data,
|
||
isLoading = false,
|
||
onViewDetail = () => { },
|
||
onProfessionClick = () => { },
|
||
onStationClick = () => { }, // 新增台站点击处理
|
||
onAddEmployee = () => { },
|
||
onExportTable = () => { },
|
||
onExportFilteredTable = () => { },
|
||
isExporting = false,
|
||
selectedProfessionIds = [],
|
||
onProfessionIdsChange = () => { },
|
||
selectedStationIds = [],
|
||
onStationIdsChange = () => { },
|
||
selectedDutyLevels = [],
|
||
onDutyLevelsChange = () => { },
|
||
searchKeyword = '',
|
||
onSearchKeywordChange = () => { },
|
||
// 新增分页相关props
|
||
currentPage = 1,
|
||
totalPages = 1,
|
||
totalCount = 0,
|
||
pageSize = 10,
|
||
hasNextPage = false,
|
||
hasPreviousPage = false,
|
||
onPageChange = () => { },
|
||
onPageSizeChange = () => { },
|
||
}: EliteDataTableProps) {
|
||
const { t } = useTranslation();
|
||
// 防抖处理搜索输入
|
||
const [localSearchKeyword, setLocalSearchKeyword] = React.useState(searchKeyword);
|
||
|
||
React.useEffect(() => {
|
||
setLocalSearchKeyword(searchKeyword);
|
||
}, [searchKeyword]);
|
||
|
||
React.useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
onSearchKeywordChange(localSearchKeyword);
|
||
}, 300); // 300ms 防抖
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [localSearchKeyword, onSearchKeywordChange]);
|
||
|
||
// 按专业分组数据(用于显示)
|
||
const groupedData = React.useMemo(() => {
|
||
const groups: Record<string, EliteFormData[]> = {};
|
||
data.forEach((item) => {
|
||
const profession = item.profession || '';
|
||
if (!groups[profession]) {
|
||
groups[profession] = [];
|
||
}
|
||
groups[profession].push(item);
|
||
});
|
||
return groups;
|
||
}, [data]);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* 筛选器和操作按钮栏 */}
|
||
<div className="flex flex-col gap-4">
|
||
{/* 标题和操作按钮 */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<h2 className="text-lg font-semibold">{t('profile.elite_table.title')}</h2>
|
||
<Badge variant="outline">
|
||
{isLoading ? t('common.loading') : t('common.count_items', { count: totalCount || 0 })}
|
||
</Badge>
|
||
{/* 筛选状态显示 */}
|
||
{(selectedProfessionIds.length > 0 || selectedStationIds.length > 0 || selectedDutyLevels.length > 0 || searchKeyword.trim()) && (
|
||
<Badge variant="secondary">
|
||
{t('common.filtered')}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={onExportFilteredTable}
|
||
disabled={isExporting || isLoading}
|
||
className="flex items-center gap-2 border-[#5D6E89]"
|
||
>
|
||
<IconDownload className="size-4" />
|
||
{isExporting ? t('common.exporting') : t('common.export_filtered')}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={onExportTable}
|
||
disabled={isExporting || isLoading}
|
||
className="flex items-center gap-2 border-[#5D6E89]"
|
||
>
|
||
<IconDownload className="size-4" />
|
||
{isExporting ? t('common.exporting') : t('common.export_all')}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={onAddEmployee}
|
||
className="flex items-center gap-2 border border-[#5D6E89]"
|
||
>
|
||
<IconPlus className="size-4" />
|
||
{t('common.add_employee')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{/* 筛选器区域 */}
|
||
<div className="flex flex-wrap items-center gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-medium text-muted-foreground">{t('common.filter_conditions')}</span>
|
||
<div className="w-64">
|
||
<MultipleProfessionSelector
|
||
value={selectedProfessionIds}
|
||
onValueChange={onProfessionIdsChange}
|
||
placeholder={t('profile.elite_table.filter.select_profession')}
|
||
allowClear
|
||
className='h-8'
|
||
/>
|
||
</div>
|
||
<div className="w-72">
|
||
<MultipleStationSelector
|
||
value={selectedStationIds}
|
||
onValueChange={onStationIdsChange}
|
||
placeholder={t('profile.elite_table.filter.select_station')}
|
||
allowClear
|
||
className='h-8'
|
||
/>
|
||
</div>
|
||
<div className="w-70">
|
||
<MultipleDutyLevelSelector
|
||
value={selectedDutyLevels}
|
||
onValueChange={onDutyLevelsChange}
|
||
placeholder={t('profile.elite_table.filter.select_duty_level')}
|
||
allowClear
|
||
className='h-8'
|
||
/>
|
||
</div>
|
||
{/* 搜索框 */}
|
||
<div className="w-70">
|
||
<div className="relative">
|
||
<IconSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||
<Input
|
||
placeholder={t('profile.elite_table.filter.search_placeholder')}
|
||
value={localSearchKeyword}
|
||
onChange={(e) => setLocalSearchKeyword(e.target.value)}
|
||
className="pl-10 h-8 border border-[#5D6E89]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 表格 */}
|
||
<div className="rounded-md border border-border max-h-[650px] overflow-auto">
|
||
<Table className="table-fixed w-full" stickyHeader>
|
||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||
<TableRow className="bg-muted/50">
|
||
<TableHead className="text-center w-24 border-r-[1px] border-border break-words">{t('profile.elite_table.column.profession')}</TableHead>
|
||
<TableHead className="text-center w-36 border-r-[1px] border-border break-words">{t('profile.elite_table.column.stations')}</TableHead>
|
||
<TableHead className="text-center w-50 border-r-[1px] border-border break-words">{t('profile.elite_table.column.technician')}</TableHead>
|
||
<TableHead className="text-center w-50 border-r-[1px] border-border break-words">{t('profile.elite_table.column.leader')}</TableHead>
|
||
<TableHead className="text-center w-50 break-words">{t('profile.elite_table.column.operator')}</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{isLoading ? (
|
||
<TableRow>
|
||
<TableCell colSpan={6} className="h-24 text-center align-middle">
|
||
{t('common.loading')}
|
||
</TableCell>
|
||
</TableRow>
|
||
) : Object.keys(groupedData).length > 0 ? (
|
||
Object.entries(groupedData).map(([profession, items]) => (
|
||
<React.Fragment key={profession}>
|
||
{items.map((item, index) => (
|
||
<TableRow
|
||
key={`${profession}-${item.sequence}`}
|
||
className="hover:bg-muted/50"
|
||
>
|
||
{index === 0 ? (
|
||
<TableCell
|
||
className="text-center align-middle font-medium bg-muted/30 border-r-[1px] border-border break-words w-24 cursor-pointer hover:bg-muted/50 transition-colors"
|
||
rowSpan={items.length}
|
||
onClick={() => onProfessionClick(profession, item.professionId)}
|
||
>
|
||
<div className="flex flex-row items-center justify-center gap-2 py-3">
|
||
<span className="font-medium text-sm">{profession}</span>
|
||
</div>
|
||
</TableCell>
|
||
) : null}
|
||
<TableCell
|
||
className="text-center align-middle font-medium border-r-[1px] border-border break-words w-36 cursor-pointer hover:bg-muted/50 transition-colors"
|
||
onClick={() => onStationClick(item.profession, item.stationId, item.professionId)}
|
||
>
|
||
<div className="flex flex-wrap items-center justify-center gap-[1px] py-2">
|
||
{item.station && (
|
||
<>
|
||
<IconBuilding className="size-3 text-foreground/80" />
|
||
<span className="text-sm break-words whitespace-normal">
|
||
{item.parentOrganization ? `${item.parentOrganization}/${item.station}` : item.station}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-center align-middle border-r-[1px] border-border break-words w-50">
|
||
<PersonList
|
||
persons={item.technicians}
|
||
onViewDetail={onViewDetail}
|
||
/>
|
||
</TableCell>
|
||
<TableCell className="text-center align-middle border-r-[1px] border-border break-words w-50">
|
||
<PersonList
|
||
persons={item.supervisors}
|
||
onViewDetail={onViewDetail}
|
||
/>
|
||
</TableCell>
|
||
<TableCell className="text-center align-middle break-words w-50">
|
||
<PersonList
|
||
persons={item.operators}
|
||
onViewDetail={onViewDetail}
|
||
/>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</React.Fragment>
|
||
))
|
||
) : (
|
||
<TableRow>
|
||
<TableCell
|
||
colSpan={5}
|
||
className="h-24 text-center align-middle text-muted-foreground"
|
||
>
|
||
{searchKeyword.trim() || selectedDutyLevels.length > 0 ? t('common.no_records_found') : t('common.no_data')}
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
{/* 分页控制 */}
|
||
<div className="flex items-center justify-between space-x-2 py-3">
|
||
<div className="flex-1 text-sm text-muted-foreground">
|
||
{t('pagination.page_display', { current: currentPage, total: totalPages, count: totalCount })}
|
||
</div>
|
||
<div className="flex items-center space-x-4 lg:space-x-6">
|
||
<div className="flex items-center space-x-2">
|
||
<Label htmlFor="page-size" className="text-sm font-medium">
|
||
{t('pagination.rows_per_page')}
|
||
</Label>
|
||
<Select
|
||
value={`${pageSize}`}
|
||
onValueChange={(value) => {
|
||
onPageSizeChange(Number(value));
|
||
onPageChange(1); // 重置到第一页
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-7 w-[70px] text-sm" id="page-size">
|
||
<SelectValue placeholder={pageSize} />
|
||
</SelectTrigger>
|
||
<SelectContent side="top">
|
||
{[5, 10, 15, 20, 30].map((size) => (
|
||
<SelectItem key={size} value={`${size}`}>
|
||
{size}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<span className="text-sm text-muted-foreground">{t('pagination.items_per_page')}</span>
|
||
</div>
|
||
<div className="flex w-[120px] items-center justify-center text-sm font-medium">
|
||
{t('pagination.page_info_simple', { current: currentPage, total: totalPages })}
|
||
</div>
|
||
<div className="flex items-center space-x-1">
|
||
<Button
|
||
variant="outline"
|
||
className="hidden h-7 w-7 p-0 lg:flex"
|
||
onClick={() => onPageChange(1)}
|
||
disabled={!hasPreviousPage || isLoading}
|
||
>
|
||
<span className="sr-only">{t('pagination.go_to_first_page')}</span>
|
||
<IconChevronsLeft className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className="h-7 w-7 p-0"
|
||
onClick={() => onPageChange(currentPage - 1)}
|
||
disabled={!hasPreviousPage || isLoading}
|
||
>
|
||
<span className="sr-only">{t('pagination.go_to_previous_page')}</span>
|
||
<IconChevronLeft className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className="h-7 w-7 p-0"
|
||
onClick={() => onPageChange(currentPage + 1)}
|
||
disabled={!hasNextPage || isLoading}
|
||
>
|
||
<span className="sr-only">{t('pagination.go_to_next_page')}</span>
|
||
<IconChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className="hidden h-7 w-7 p-0 lg:flex"
|
||
onClick={() => onPageChange(totalPages)}
|
||
disabled={!hasNextPage || isLoading}
|
||
>
|
||
<span className="sr-only">{t('pagination.go_to_last_page')}</span>
|
||
<IconChevronsRight className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|