casualroom/apps/fenghuo/web/components/profile/elite-data-table.tsx

451 lines
22 KiB
TypeScript
Raw Normal View History

2025-07-28 07:50:50 +08:00
'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>
);
}