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

451 lines
22 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}