477 lines
15 KiB
TypeScript
477 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import { TimeFilter } from "@/components/label/api/workload/typing"
|
|
import { formatWorkTime } from "@/components/label/utils/time"
|
|
import {
|
|
SettingContentPanel,
|
|
SettingDataTable,
|
|
SettingFilterActions,
|
|
SettingFilterPanel,
|
|
SettingHeaderActions,
|
|
SettingInlineFilterField,
|
|
SettingListHeader,
|
|
settingSurfaceClassNames,
|
|
} from "@/components/setting/PageSurface"
|
|
import { Button, Collapse, Stack, TextInput } from "@mantine/core"
|
|
import { DateInput } from "@mantine/dates"
|
|
import { notifications } from "@mantine/notifications"
|
|
import {
|
|
IconChevronDown,
|
|
IconChevronUp,
|
|
IconDownload,
|
|
IconRefresh,
|
|
IconSearch,
|
|
} from "@tabler/icons-react"
|
|
import dayjs from "dayjs"
|
|
import "dayjs/locale/zh-cn"
|
|
import { DataTableColumn, DataTableSortStatus } from "mantine-datatable"
|
|
import { useEffect, useMemo, useState } from "react"
|
|
import * as XLSX from "xlsx-js-style"
|
|
import { DimensionType, GroupedData, MetricColumn } from "./config"
|
|
|
|
function emptyGroupedData(): GroupedData {
|
|
return {
|
|
all: [],
|
|
order_by_group_name: [],
|
|
order_by_project_name: [],
|
|
order_by_project_type: [],
|
|
order_by_user_name: [],
|
|
}
|
|
}
|
|
|
|
function createInitialFilters() {
|
|
const currentDate = dayjs().subtract(1, "day").format("YYYY-MM-DD")
|
|
return {
|
|
startDate: currentDate,
|
|
endDate: currentDate,
|
|
dimension: "all" as DimensionType,
|
|
projectName: "",
|
|
projectType: "",
|
|
userName: "",
|
|
groupName: "",
|
|
}
|
|
}
|
|
|
|
function createDimensionColumn(
|
|
accessor: string,
|
|
title: string,
|
|
width: number
|
|
): DataTableColumn<any> {
|
|
return {
|
|
accessor,
|
|
title,
|
|
width,
|
|
sortable: true,
|
|
}
|
|
}
|
|
|
|
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 [filters, setFilters] = useState(() => createInitialFilters())
|
|
const [appliedFilters, setAppliedFilters] = useState(() =>
|
|
createInitialFilters()
|
|
)
|
|
const [data, setData] = useState<GroupedData>(emptyGroupedData())
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
|
columnAccessor: "work_time",
|
|
direction: "desc",
|
|
})
|
|
|
|
const refresh = async (nextFilters = appliedFilters) => {
|
|
if (!nextFilters.startDate || !nextFilters.endDate) {
|
|
notifications.show({
|
|
color: "yellow",
|
|
title: "参数不完整",
|
|
message: "请选择起止日期",
|
|
})
|
|
return
|
|
}
|
|
try {
|
|
setLoading(true)
|
|
const res = await fetchData({
|
|
start_date: nextFilters.startDate,
|
|
end_date: nextFilters.endDate,
|
|
})
|
|
setData({
|
|
...emptyGroupedData(),
|
|
...(res ?? {}),
|
|
})
|
|
} catch (e) {
|
|
notifications.show({
|
|
color: "red",
|
|
title: "加载失败",
|
|
message: e instanceof Error ? e.message : "请求失败",
|
|
})
|
|
setData(emptyGroupedData())
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const initialFilters = createInitialFilters()
|
|
queueMicrotask(() => refresh(initialFilters))
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
const filteredRecords = useMemo(() => {
|
|
let records = [...(data[appliedFilters.dimension] ?? [])]
|
|
if (appliedFilters.projectName.trim()) {
|
|
records = records.filter((item) =>
|
|
String(item.project_name ?? "")
|
|
.toLowerCase()
|
|
.includes(appliedFilters.projectName.trim().toLowerCase())
|
|
)
|
|
}
|
|
if (appliedFilters.projectType.trim()) {
|
|
records = records.filter((item) =>
|
|
String(item.project_type ?? "")
|
|
.toLowerCase()
|
|
.includes(appliedFilters.projectType.trim().toLowerCase())
|
|
)
|
|
}
|
|
if (appliedFilters.userName.trim()) {
|
|
records = records.filter((item) =>
|
|
String(item.user_name ?? "")
|
|
.toLowerCase()
|
|
.includes(appliedFilters.userName.trim().toLowerCase())
|
|
)
|
|
}
|
|
if (appliedFilters.groupName.trim()) {
|
|
records = records.filter((item) =>
|
|
String(item.group_name ?? "")
|
|
.toLowerCase()
|
|
.includes(appliedFilters.groupName.trim().toLowerCase())
|
|
)
|
|
}
|
|
return records
|
|
}, [appliedFilters, data])
|
|
|
|
const baseColumns = useMemo(() => {
|
|
const allCols: Record<DimensionType, DataTableColumn<any>[]> = {
|
|
all: [
|
|
createDimensionColumn("uid", "用户ID", 100),
|
|
createDimensionColumn("user_name", "用户名", 140),
|
|
createDimensionColumn("group_name", "用户组", 160),
|
|
createDimensionColumn("project_id", "项目ID", 120),
|
|
createDimensionColumn("project_name", "项目名称", 180),
|
|
createDimensionColumn("project_type", "项目类型", 320),
|
|
createDimensionColumn("project_label_type", "标注类别", 140),
|
|
],
|
|
order_by_group_name: [createDimensionColumn("group_name", "用户组", 180)],
|
|
order_by_project_name: [
|
|
createDimensionColumn("project_name", "项目名称", 220),
|
|
],
|
|
order_by_project_type: [
|
|
createDimensionColumn("project_type", "项目类型", 320),
|
|
],
|
|
order_by_user_name: [createDimensionColumn("user_name", "用户名", 180)],
|
|
}
|
|
return allCols[appliedFilters.dimension]
|
|
}, [appliedFilters.dimension])
|
|
|
|
const defaultSortStatus = useMemo<DataTableSortStatus>(() => {
|
|
const dimensionDefaultAccessorMap: Record<DimensionType, string> = {
|
|
all: "work_time",
|
|
order_by_group_name: "group_name",
|
|
order_by_project_name: "project_name",
|
|
order_by_project_type: "project_type",
|
|
order_by_user_name: "user_name",
|
|
}
|
|
|
|
const preferredAccessor =
|
|
dimensionDefaultAccessorMap[appliedFilters.dimension] ??
|
|
metrics[0]?.key ??
|
|
String(baseColumns[0]?.accessor ?? "index")
|
|
const isMetricColumn = metrics.some(
|
|
(metric) => metric.key === preferredAccessor
|
|
)
|
|
|
|
return {
|
|
columnAccessor: preferredAccessor,
|
|
direction: isMetricColumn ? "desc" : "asc",
|
|
}
|
|
}, [appliedFilters.dimension, baseColumns, metrics])
|
|
|
|
const availableSortAccessors = useMemo(
|
|
() =>
|
|
new Set([
|
|
...baseColumns.map((column) => String(column.accessor)),
|
|
...metrics.map((metric) => metric.key),
|
|
]),
|
|
[baseColumns, metrics]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (availableSortAccessors.has(String(sortStatus.columnAccessor))) {
|
|
return
|
|
}
|
|
setSortStatus(defaultSortStatus)
|
|
}, [availableSortAccessors, defaultSortStatus, sortStatus.columnAccessor])
|
|
|
|
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 ?? ""), "zh-CN", {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
}) * dir
|
|
)
|
|
})
|
|
return list
|
|
}, [filteredRecords, sortStatus.columnAccessor, sortStatus.direction])
|
|
|
|
const columns = useMemo(() => {
|
|
const totalsByKey = Object.fromEntries(
|
|
metrics.map((metric) => {
|
|
const sum = sortedRecords.reduce((acc, item) => {
|
|
const value = Number(item?.[metric.key] ?? 0)
|
|
return acc + (Number.isFinite(value) ? value : 0)
|
|
}, 0)
|
|
if (["work_time"].includes(metric.key)) {
|
|
return [metric.key, formatWorkTime(sum)]
|
|
} else {
|
|
return [metric.key, sum]
|
|
}
|
|
})
|
|
) as Record<string, string | number>
|
|
|
|
const metricCols = metrics.map((metric) => {
|
|
if (["work_time"].includes(metric.key)) {
|
|
return {
|
|
accessor: metric.key,
|
|
title: metric.title,
|
|
width: 140,
|
|
sortable: true,
|
|
footer: totalsByKey[metric.key] ?? 0,
|
|
render: (record) => formatWorkTime(record[metric.key]),
|
|
} as DataTableColumn<any>
|
|
} else {
|
|
return {
|
|
accessor: metric.key,
|
|
title: metric.title,
|
|
width: 140,
|
|
sortable: true,
|
|
footer: totalsByKey[metric.key] ?? 0,
|
|
} as DataTableColumn<any>
|
|
}
|
|
})
|
|
return [
|
|
{
|
|
accessor: "index",
|
|
title: "序号",
|
|
width: 80,
|
|
footer: "总计",
|
|
render: (_record: any, index: number) => index + 1,
|
|
} as DataTableColumn<any>,
|
|
...baseColumns,
|
|
...metricCols,
|
|
]
|
|
}, [baseColumns, 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 (appliedFilters.dimension === "all") {
|
|
return (record: any) =>
|
|
`${record.project_id ?? "p"}_${record.uid ?? "u"}_${record.project_label_type ?? ""}_${record.project_name ?? ""}_${record.user_name ?? ""}`
|
|
}
|
|
if (appliedFilters.dimension === "order_by_group_name") return "group_name"
|
|
if (appliedFilters.dimension === "order_by_project_name")
|
|
return "project_name"
|
|
if (appliedFilters.dimension === "order_by_project_type")
|
|
return "project_type"
|
|
return "user_name"
|
|
}, [appliedFilters.dimension])
|
|
|
|
const handleSearch = () => {
|
|
setAppliedFilters(filters)
|
|
void refresh(filters)
|
|
}
|
|
|
|
const handleReset = () => {
|
|
const nextFilters = createInitialFilters()
|
|
setFilters(nextFilters)
|
|
setAppliedFilters(nextFilters)
|
|
void refresh(nextFilters)
|
|
}
|
|
|
|
const [searchExpanded, setSearchExpanded] = useState(false)
|
|
|
|
return (
|
|
<Stack h="100%" gap={12} style={{ minWidth: 0 }}>
|
|
<SettingFilterPanel>
|
|
<Stack gap={12}>
|
|
<div className={settingSurfaceClassNames.filterTopRow}>
|
|
<SettingInlineFilterField label="开始日期">
|
|
<DateInput
|
|
locale="zh-cn"
|
|
value={filters.startDate}
|
|
onChange={(e) =>
|
|
setFilters((s) => ({
|
|
...s,
|
|
startDate: dayjs(e).format("YYYY-MM-DD"),
|
|
}))
|
|
}
|
|
/>
|
|
</SettingInlineFilterField>
|
|
<SettingInlineFilterField label="结束日期">
|
|
<DateInput
|
|
locale="zh-cn"
|
|
value={filters.endDate}
|
|
onChange={(e) =>
|
|
setFilters((s) => ({
|
|
...s,
|
|
endDate: dayjs(e).format("YYYY-MM-DD"),
|
|
}))
|
|
}
|
|
/>
|
|
</SettingInlineFilterField>
|
|
<SettingInlineFilterField label="项目名称">
|
|
<TextInput
|
|
placeholder="请输入项目名称"
|
|
value={filters.projectName}
|
|
onChange={(e) =>
|
|
setFilters((prev) => ({
|
|
...prev,
|
|
projectName: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</SettingInlineFilterField>
|
|
|
|
<SettingFilterActions
|
|
mt={0}
|
|
className={settingSurfaceClassNames.filterActions}>
|
|
<Button
|
|
variant="transparent"
|
|
className={settingSurfaceClassNames.collapseToggle}
|
|
rightSection={
|
|
searchExpanded ? (
|
|
<IconChevronUp size={16} />
|
|
) : (
|
|
<IconChevronDown size={16} />
|
|
)
|
|
}
|
|
onClick={() => setSearchExpanded((v) => !v)}>
|
|
{searchExpanded ? "收起" : "展开"}
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
leftSection={<IconRefresh size={16} />}
|
|
onClick={handleReset}>
|
|
重置
|
|
</Button>
|
|
<Button
|
|
leftSection={<IconSearch size={16} />}
|
|
onClick={handleSearch}
|
|
loading={loading}>
|
|
查询
|
|
</Button>
|
|
</SettingFilterActions>
|
|
</div>
|
|
<Collapse in={searchExpanded} transitionDuration={150} animateOpacity>
|
|
<div className={settingSurfaceClassNames.filterExpandedGrid}>
|
|
<SettingInlineFilterField label="项目类型">
|
|
<TextInput
|
|
placeholder="请输入项目类型"
|
|
value={filters.projectType}
|
|
onChange={(e) =>
|
|
setFilters((prev) => ({
|
|
...prev,
|
|
projectType: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</SettingInlineFilterField>
|
|
</div>
|
|
</Collapse>
|
|
</Stack>
|
|
</SettingFilterPanel>
|
|
|
|
<SettingContentPanel
|
|
style={{ flex: 1, minHeight: 0, minWidth: 0, display: "flex" }}>
|
|
<Stack gap={12} style={{ flex: 1, minHeight: 0, minWidth: 0 }}>
|
|
<SettingListHeader
|
|
title={title}
|
|
count={`${sortedRecords.length} 条`}
|
|
actions={
|
|
<SettingHeaderActions>
|
|
<Button
|
|
variant="default"
|
|
leftSection={<IconRefresh size={16} />}
|
|
onClick={() => void refresh(appliedFilters)}
|
|
loading={loading}>
|
|
刷新
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
leftSection={<IconDownload size={16} />}
|
|
onClick={handleExport}>
|
|
下载数据
|
|
</Button>
|
|
</SettingHeaderActions>
|
|
}
|
|
/>
|
|
<SettingDataTable<any>
|
|
fetching={loading}
|
|
records={sortedRecords}
|
|
columns={columns}
|
|
idAccessor={rowKey as any}
|
|
minHeight={0}
|
|
scrollAreaProps={{ type: "auto" }}
|
|
style={{ width: "100%", flex: 1, minWidth: 0, minHeight: 0 }}
|
|
sortStatus={sortStatus}
|
|
onSortStatusChange={setSortStatus}
|
|
/>
|
|
</Stack>
|
|
</SettingContentPanel>
|
|
</Stack>
|
|
)
|
|
}
|