Files
labelmain/app/management/team/workload/WorkloadMetricsTable.tsx
2026-02-27 09:51:33 +08:00

353 lines
10 KiB
TypeScript
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 { TimeFilter } from "@/components/label/api/workload/typing"
import {
Button,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core"
import { notifications } from "@mantine/notifications"
import { IconDownload, IconRefresh } from "@tabler/icons-react"
import dayjs from "dayjs"
import {
DataTable,
DataTableColumn,
DataTableSortStatus,
} from "mantine-datatable"
import { useEffect, useMemo, useState } from "react"
import * as XLSX from "xlsx-js-style"
import { DimensionType, GroupedData, MetricColumn, dimensionOpts } from "./config"
function emptyGroupedData(): GroupedData {
return {
all: [],
order_by_group_name: [],
order_by_project_name: [],
order_by_project_type: [],
order_by_user_name: [],
}
}
export default function WorkloadMetricsTable(props: {
title: string
sheetName: string
fileName: string
metrics: MetricColumn[]
fetchData: (params: TimeFilter) => Promise<GroupedData>
}) {
const { title, sheetName, fileName, metrics, fetchData } = props
const [startDate, setStartDate] = useState(dayjs().subtract(1, "day").format("YYYY-MM-DD"))
const [endDate, setEndDate] = useState(dayjs().subtract(1, "day").format("YYYY-MM-DD"))
const [dimension, setDimension] = useState<DimensionType>("all")
const [data, setData] = useState<GroupedData>(emptyGroupedData())
const [loading, setLoading] = useState(false)
const [projectName, setProjectName] = useState("")
const [projectType, setProjectType] = useState("")
const [userName, setUserName] = useState("")
const [groupName, setGroupName] = useState("")
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: "work_time",
direction: "desc",
})
const refresh = async () => {
if (!startDate || !endDate) {
notifications.show({
color: "yellow",
title: "参数不完整",
message: "请选择起止日期",
})
return
}
try {
setLoading(true)
const res = await fetchData({
start_date: startDate,
end_date: endDate,
})
setData({
...emptyGroupedData(),
...(res ?? {}),
})
} catch (e) {
notifications.show({
color: "red",
title: "加载失败",
message: e instanceof Error ? e.message : "请求失败",
})
setData(emptyGroupedData())
} finally {
setLoading(false)
}
}
useEffect(() => {
queueMicrotask(refresh)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const filteredRecords = useMemo(() => {
let records = [...(data[dimension] ?? [])]
if (projectName.trim()) {
records = records.filter((item) =>
String(item.project_name ?? "")
.toLowerCase()
.includes(projectName.trim().toLowerCase())
)
}
if (projectType.trim()) {
records = records.filter((item) =>
String(item.project_type ?? "")
.toLowerCase()
.includes(projectType.trim().toLowerCase())
)
}
if (userName.trim()) {
records = records.filter((item) =>
String(item.user_name ?? "")
.toLowerCase()
.includes(userName.trim().toLowerCase())
)
}
if (groupName.trim()) {
records = records.filter((item) =>
String(item.group_name ?? "")
.toLowerCase()
.includes(groupName.trim().toLowerCase())
)
}
return records
}, [data, dimension, groupName, projectName, projectType, userName])
const sortedRecords = useMemo(() => {
const list = [...filteredRecords]
const accessor = sortStatus.columnAccessor
const dir = sortStatus.direction === "asc" ? 1 : -1
list.sort((a, b) => {
const av = a?.[accessor as string]
const bv = b?.[accessor as string]
const an = Number(av)
const bn = Number(bv)
if (Number.isFinite(an) && Number.isFinite(bn)) {
return (an - bn) * dir
}
return String(av ?? "").localeCompare(String(bv ?? "")) * dir
})
return list
}, [filteredRecords, sortStatus.columnAccessor, sortStatus.direction])
const baseColumns = useMemo(() => {
const allCols: Record<DimensionType, DataTableColumn<any>[]> = {
all: [
{ accessor: "project_name", title: "项目名称", width: 180 },
{ accessor: "project_type", title: "项目类型", width: 180 },
{ accessor: "user_name", title: "用户名", width: 120 },
{ accessor: "group_name", title: "用户组", width: 160 },
],
order_by_group_name: [{ accessor: "group_name", title: "用户组", width: 180 }],
order_by_project_name: [
{ accessor: "project_name", title: "项目名称", width: 220 },
],
order_by_project_type: [
{ accessor: "project_type", title: "项目类型", width: 220 },
],
order_by_user_name: [{ accessor: "user_name", title: "用户名", width: 180 }],
}
return allCols[dimension]
}, [dimension])
const columns = useMemo(() => {
const metricCols = metrics.map((metric) => {
return {
accessor: metric.key,
title: metric.title,
width: 140,
sortable: true,
} as DataTableColumn<any>
})
return [
{
accessor: "index",
title: "序号",
width: 80,
render: (_record: any, index: number) => index + 1,
} as DataTableColumn<any>,
...baseColumns,
...metricCols,
]
}, [baseColumns, metrics])
const totals = useMemo(() => {
return metrics.map((metric) => {
const sum = sortedRecords.reduce((acc, item) => {
const value = Number(item?.[metric.key] ?? 0)
return acc + (Number.isFinite(value) ? value : 0)
}, 0)
return { title: metric.title, value: sum }
})
}, [metrics, sortedRecords])
const handleExport = () => {
if (!sortedRecords.length) {
notifications.show({
color: "yellow",
title: "暂无数据",
message: "当前筛选条件下无可导出数据",
})
return
}
const baseHeaders = baseColumns.map((c) => String(c.title ?? c.accessor))
const metricHeaders = metrics.map((m) => m.title)
const header = ["序号", ...baseHeaders, ...metricHeaders]
const baseAccessors = baseColumns.map((c) => String(c.accessor))
const rows = sortedRecords.map((item, index) => {
return [
index + 1,
...baseAccessors.map((key) => item?.[key] ?? "-"),
...metrics.map((m) => item?.[m.key] ?? 0),
]
})
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.aoa_to_sheet([header, ...rows])
XLSX.utils.book_append_sheet(wb, ws, sheetName)
XLSX.writeFile(wb, fileName)
}
const rowKey = useMemo(() => {
if (dimension === "all") {
return (record: any) =>
`${record.project_id ?? "p"}_${record.uid ?? "u"}_${record.project_name ?? ""}_${record.user_name ?? ""}`
}
if (dimension === "order_by_group_name") return "group_name"
if (dimension === "order_by_project_name") return "project_name"
if (dimension === "order_by_project_type") return "project_type"
return "user_name"
}, [dimension])
return (
<Stack h="100%" gap="sm">
<Paper withBorder p="sm" radius="sm">
<Group justify="space-between" wrap="wrap">
<Group>
<Text size="sm" fw={700}>
{title}
</Text>
<TextInput
label="开始日期"
size="xs"
radius="xs"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.currentTarget.value)}
/>
<TextInput
label="结束日期"
size="xs"
radius="xs"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.currentTarget.value)}
/>
<Button
size="xs"
radius="xs"
leftSection={<IconRefresh size={16} />}
onClick={refresh}
loading={loading}>
</Button>
</Group>
<Group>
<Select
label="聚合维度"
size="xs"
radius="xs"
data={dimensionOpts}
value={dimension}
onChange={(v) => setDimension((v as DimensionType) || "all")}
/>
<Button
variant="default"
size="xs"
radius="xs"
leftSection={<IconDownload size={16} />}
onClick={handleExport}>
</Button>
</Group>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="sm" mt="sm">
<TextInput
label="项目名称"
size="xs"
radius="xs"
value={projectName}
onChange={(e) => setProjectName(e.currentTarget.value)}
/>
<TextInput
label="项目类型"
size="xs"
radius="xs"
value={projectType}
onChange={(e) => setProjectType(e.currentTarget.value)}
/>
<TextInput
label="用户名"
size="xs"
radius="xs"
value={userName}
onChange={(e) => setUserName(e.currentTarget.value)}
/>
<TextInput
label="用户组"
size="xs"
radius="xs"
value={groupName}
onChange={(e) => setGroupName(e.currentTarget.value)}
/>
</SimpleGrid>
</Paper>
<Paper withBorder p="sm" radius="sm" style={{ flex: 1, minHeight: 0, display: "flex" }}>
<DataTable<any>
withTableBorder
withRowBorders
fetching={loading}
records={sortedRecords}
columns={columns}
noRecordsText="暂无数据"
rowKey={rowKey as any}
scrollAreaProps={{ type: "auto" }}
style={{ width: "100%" }}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
/>
</Paper>
<Paper withBorder p="sm" radius="sm">
<Group wrap="wrap" gap="md">
<Text size="sm" fw={700}>
</Text>
{totals.map((item) => (
<Text size="sm" key={item.title}>
{item.title}{item.value}
</Text>
))}
</Group>
</Paper>
</Stack>
)
}