370 lines
10 KiB
TypeScript
370 lines
10 KiB
TypeScript
"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="暂无数据"
|
||
idAccessor={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>
|
||
)
|
||
}
|