feat(team): add page
This commit is contained in:
26
app/management/team/board/page.tsx
Normal file
26
app/management/team/board/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { Center, Paper, Stack, Text } from "@mantine/core"
|
||||
|
||||
export default function TeamBoardPage() {
|
||||
return (
|
||||
<Center w="100%" h="calc(100vh - 56px)" p="md">
|
||||
<Paper
|
||||
withBorder
|
||||
p="xl"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
minWidth: 320,
|
||||
}}>
|
||||
<Stack gap={6}>
|
||||
<Text fw={700}>项目参与</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
该页面已迁移至团队管理模块,后续可在此继续扩展业务视图。
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
197
app/management/team/cost/page.tsx
Normal file
197
app/management/team/cost/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
downloadSettlementForm,
|
||||
exportSettlementForm,
|
||||
getDownloadLog,
|
||||
} from "@/components/label/api/workload"
|
||||
import useAuth from "@/components/label/hooks/useAuth"
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { IconDownload, IconFileExport, IconRefresh } from "@tabler/icons-react"
|
||||
import dayjs from "dayjs"
|
||||
import { DataTable, DataTableColumn } from "mantine-datatable"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
function triggerDownload(blob: Blob, fileName: string) {
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
type LogRecord = {
|
||||
id: number
|
||||
create_at: string
|
||||
file_name: string
|
||||
create_user: string
|
||||
}
|
||||
|
||||
export default function TeamCostPage() {
|
||||
const { isShow } = useAuth()
|
||||
|
||||
const [records, setRecords] = useState<LogRecord[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await getDownloadLog()
|
||||
const list = (res ?? []) as LogRecord[]
|
||||
setRecords(list.sort((a, b) => (b.id ?? 0) - (a.id ?? 0)))
|
||||
} catch (e) {
|
||||
setRecords([])
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "加载失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setExporting(true)
|
||||
const time = dayjs().subtract(1, "month")
|
||||
const year = time.year()
|
||||
const month = time.month() + 1
|
||||
const blob = await exportSettlementForm({ year, month })
|
||||
const fileName = `结算表格_${year}_${String(month).padStart(2, "0")}.zip`
|
||||
triggerDownload(blob, fileName)
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "导出成功",
|
||||
message: "已开始下载结算表格",
|
||||
})
|
||||
load()
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "导出失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const cols: DataTableColumn<LogRecord>[] = [
|
||||
{ accessor: "id", title: "序号", width: 80 },
|
||||
{
|
||||
accessor: "create_at",
|
||||
title: "导出时间",
|
||||
width: 200,
|
||||
render: (record) =>
|
||||
record.create_at ? dayjs(record.create_at).format("YYYY-MM-DD HH:mm:ss") : "-",
|
||||
},
|
||||
{ accessor: "file_name", title: "导出文件名", width: 280 },
|
||||
{ accessor: "create_user", title: "操作人", width: 160 },
|
||||
{
|
||||
accessor: "operation",
|
||||
title: "操作",
|
||||
width: 90,
|
||||
textAlign: "center",
|
||||
render: (record) => (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
disabled={!isShow("download")}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const blob = await downloadSettlementForm({ id: record.id })
|
||||
const fileName = record.file_name || `结算表格_${record.id}.zip`
|
||||
triggerDownload(blob, fileName)
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "下载成功",
|
||||
message: "已开始下载文件",
|
||||
})
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "下载失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
}
|
||||
}}>
|
||||
<IconDownload size={16} />
|
||||
</ActionIcon>
|
||||
),
|
||||
},
|
||||
]
|
||||
return cols
|
||||
}, [isShow])
|
||||
|
||||
return (
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p="md" gap="md">
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
}}>
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconFileExport size={16} />}
|
||||
onClick={handleExport}
|
||||
loading={exporting}
|
||||
disabled={!isShow("export")}>
|
||||
结算表格导出
|
||||
</Button>
|
||||
<ActionIcon onClick={load} variant="default" loading={loading}>
|
||||
<IconRefresh size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
导出记录
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
}}>
|
||||
<DataTable<LogRecord>
|
||||
withTableBorder
|
||||
withRowBorders
|
||||
fetching={loading}
|
||||
records={records}
|
||||
columns={columns}
|
||||
noRecordsText="暂无数据"
|
||||
scrollAreaProps={{ type: "auto" }}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,730 @@
|
||||
"use client"
|
||||
|
||||
export default function DailypaperPage() {
|
||||
return <></>
|
||||
import {
|
||||
deleteDailyWorkData,
|
||||
getDailyWorkList,
|
||||
lockDailyWorkData,
|
||||
} from "@/components/label/api/daily"
|
||||
import { Daily } from "@/components/label/api/daily/typing"
|
||||
import { getSelfProjectList } from "@/components/label/api/project"
|
||||
import useAuth from "@/components/label/hooks/useAuth"
|
||||
import { useAllUserStore } from "@/components/label/store/auth"
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Collapse,
|
||||
Flex,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core"
|
||||
import { modals } from "@mantine/modals"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconEdit,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react"
|
||||
import dayjs from "dayjs"
|
||||
import {
|
||||
DataTable,
|
||||
DataTableColumn,
|
||||
DataTableSortStatus,
|
||||
} from "mantine-datatable"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import * as XLSX from "xlsx-js-style"
|
||||
import DailyReportModal from "../../person/report/components/DailyReportModal"
|
||||
import {
|
||||
accountTypeOpts,
|
||||
actualTypeOpts,
|
||||
objTypeOpts,
|
||||
objTypeTextMap,
|
||||
performanceOpts,
|
||||
performanceTextMap,
|
||||
setTypeOpts,
|
||||
workTypeOpts,
|
||||
} from "../../person/report/config"
|
||||
import { flattenLeaderTree, toNumberArray, weekDayText } from "../utils"
|
||||
|
||||
function toBoolean(value?: string) {
|
||||
if (value === "true") return true
|
||||
if (value === "false") return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toNumber(value?: string) {
|
||||
if (!value) return undefined
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
const setTypeMap = new Map(setTypeOpts.map((i) => [i.value, i.label]))
|
||||
const actualTypeMap = new Map(actualTypeOpts.map((i) => [i.value, i.label]))
|
||||
const workTypeMap = new Map(workTypeOpts.map((i) => [i.value, i.label]))
|
||||
const accountTypeMap = new Map(accountTypeOpts.map((i) => [i.value, i.label]))
|
||||
|
||||
export default function TeamDailypaperPage() {
|
||||
const { isShow } = useAuth()
|
||||
const treeData = useAllUserStore((s) => s.treeData)
|
||||
|
||||
const leaderOptions = useMemo(() => flattenLeaderTree(treeData ?? []), [treeData])
|
||||
|
||||
const defaultFilters = useMemo(() => {
|
||||
return {
|
||||
date_start: dayjs().subtract(6, "day").format("YYYY-MM-DD"),
|
||||
date_end: dayjs().format("YYYY-MM-DD"),
|
||||
uid: [] as string[],
|
||||
set_type: "",
|
||||
actual_type: "",
|
||||
leader_uid: [] as string[],
|
||||
work_type: "",
|
||||
accounting_type: "",
|
||||
project_id: [] as string[],
|
||||
performance_accounting: "",
|
||||
obj_type: "",
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [projectOptions, setProjectOptions] = useState<
|
||||
Array<{ label: string; value: string }>
|
||||
>([])
|
||||
|
||||
const [filters, setFilters] = useState(defaultFilters)
|
||||
const [appliedFilters, setAppliedFilters] = useState(defaultFilters)
|
||||
const [queryTrigger, setQueryTrigger] = useState(0)
|
||||
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [records, setRecords] = useState<Daily.DailyWorkList[]>([])
|
||||
const [summary, setSummary] = useState<Daily.Summary | null>(null)
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: "id",
|
||||
direction: "desc",
|
||||
})
|
||||
|
||||
const [modalOpened, setModalOpened] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<Daily.DailyWorkList | null>(null)
|
||||
const [searchExpanded, setSearchExpanded] = useState(false)
|
||||
|
||||
const queryParams = useMemo(() => {
|
||||
const params: Daily.Request = {
|
||||
page_number: page,
|
||||
page_size: pageSize,
|
||||
flag: 1,
|
||||
date_start: appliedFilters.date_start || undefined,
|
||||
date_end: appliedFilters.date_end || undefined,
|
||||
uid: toNumberArray(appliedFilters.uid),
|
||||
set_type: appliedFilters.set_type || undefined,
|
||||
actual_type: appliedFilters.actual_type || undefined,
|
||||
leader_uid: toNumberArray(appliedFilters.leader_uid),
|
||||
work_type: appliedFilters.work_type || undefined,
|
||||
accounting_type: appliedFilters.accounting_type || undefined,
|
||||
project_id: toNumberArray(appliedFilters.project_id),
|
||||
performance_accounting: toBoolean(appliedFilters.performance_accounting),
|
||||
obj_type: toNumber(appliedFilters.obj_type),
|
||||
order_by: `${sortStatus.columnAccessor} ${sortStatus.direction}`,
|
||||
}
|
||||
Object.keys(params).forEach((k) => {
|
||||
const key = k as keyof Daily.Request
|
||||
const value = params[key]
|
||||
if (value === "" || value === null) delete params[key]
|
||||
if (Array.isArray(value) && value.length === 0) delete params[key]
|
||||
if (value !== 0 && value !== false && value !== "" && !value) {
|
||||
delete params[key]
|
||||
}
|
||||
})
|
||||
return params
|
||||
}, [appliedFilters, page, pageSize, sortStatus.columnAccessor, sortStatus.direction])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!queryTrigger) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getDailyWorkList(queryParams)
|
||||
setRecords(res?.daily_work_list ?? [])
|
||||
setTotalItems(res?.total_items ?? 0)
|
||||
setSummary(res?.total_summary ?? null)
|
||||
} catch (e) {
|
||||
setRecords([])
|
||||
setTotalItems(0)
|
||||
setSummary(null)
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "加载失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [queryParams, queryTrigger])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setQueryTrigger((v) => v + 1))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const res = await getSelfProjectList()
|
||||
setProjectOptions((res ?? []).map((p) => ({ label: p.label, value: String(p.value) })))
|
||||
}
|
||||
queueMicrotask(run)
|
||||
}, [])
|
||||
|
||||
const handleExportData = () => {
|
||||
if (!records.length) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
title: "暂无数据",
|
||||
message: "当前无可导出日报数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
const exportTableHeader = [
|
||||
"员工姓名",
|
||||
"日期",
|
||||
"星期",
|
||||
"规定类型",
|
||||
"实际类型",
|
||||
"任务组长",
|
||||
"工作类别",
|
||||
"核算方式",
|
||||
"项目名称",
|
||||
"绩效核算",
|
||||
"对象类型",
|
||||
"余量正负",
|
||||
"开始时间",
|
||||
"结束时间",
|
||||
"工时",
|
||||
"额定倍率",
|
||||
"标准量",
|
||||
"完成量",
|
||||
"余量",
|
||||
"余量工时",
|
||||
"备注",
|
||||
]
|
||||
const exportData = records.map((item) => [
|
||||
item.username ?? "-",
|
||||
item.date ?? "-",
|
||||
item.date ? weekDayText(dayjs(item.date).day()) : "-",
|
||||
setTypeMap.get(item.set_type ?? "") ?? "-",
|
||||
actualTypeMap.get(item.actual_type ?? "") ?? "-",
|
||||
item.leader_name ?? "-",
|
||||
workTypeMap.get(item.work_type ?? "") ?? "-",
|
||||
accountTypeMap.get(item.accounting_type ?? "") ?? "-",
|
||||
item.project_name ?? "-",
|
||||
item.performance_accounting === true
|
||||
? "是"
|
||||
: item.performance_accounting === false
|
||||
? "否"
|
||||
: "-",
|
||||
item.obj_type === 0 || item.obj_type === 1
|
||||
? (objTypeTextMap.get(item.obj_type) ?? "-")
|
||||
: "-",
|
||||
item.residual_pm === true ? "正" : item.residual_pm === false ? "负" : "-",
|
||||
item.start_time ?? "-",
|
||||
item.end_time ?? "-",
|
||||
item.work_time ?? 0,
|
||||
item.rate ?? 0,
|
||||
item.standard_size ?? 0,
|
||||
item.completed_size ?? 0,
|
||||
item.residual_size ?? 0,
|
||||
item.residual_work_time ?? 0,
|
||||
item.remarks ?? "-",
|
||||
])
|
||||
const wb = XLSX.utils.book_new()
|
||||
const ws = XLSX.utils.aoa_to_sheet([exportTableHeader, ...exportData])
|
||||
XLSX.utils.book_append_sheet(wb, ws, "团队日报数据")
|
||||
XLSX.writeFile(wb, "团队日报数据.xlsx")
|
||||
}
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const cols: DataTableColumn<Daily.DailyWorkList>[] = [
|
||||
{ accessor: "id", title: "ID", width: 80, sortable: true },
|
||||
{
|
||||
accessor: "username",
|
||||
title: "员工姓名",
|
||||
width: 120,
|
||||
render: (record) => record.username ?? "-",
|
||||
},
|
||||
{
|
||||
accessor: "date",
|
||||
title: "日期",
|
||||
width: 120,
|
||||
sortable: true,
|
||||
render: (record) => record.date ?? "-",
|
||||
},
|
||||
{
|
||||
accessor: "week",
|
||||
title: "星期",
|
||||
width: 90,
|
||||
render: (record) => (record.date ? weekDayText(dayjs(record.date).day()) : "-"),
|
||||
},
|
||||
{
|
||||
accessor: "set_type",
|
||||
title: "规定类型",
|
||||
width: 120,
|
||||
render: (record) => setTypeMap.get(record.set_type ?? "") ?? "-",
|
||||
},
|
||||
{
|
||||
accessor: "actual_type",
|
||||
title: "实际类型",
|
||||
width: 120,
|
||||
render: (record) => actualTypeMap.get(record.actual_type ?? "") ?? "-",
|
||||
},
|
||||
{ accessor: "leader_name", title: "任务组长", width: 120 },
|
||||
{
|
||||
accessor: "work_type",
|
||||
title: "工作类别",
|
||||
width: 140,
|
||||
render: (record) => workTypeMap.get(record.work_type ?? "") ?? "-",
|
||||
},
|
||||
{
|
||||
accessor: "accounting_type",
|
||||
title: "核算方式",
|
||||
width: 140,
|
||||
render: (record) => accountTypeMap.get(record.accounting_type ?? "") ?? "-",
|
||||
},
|
||||
{
|
||||
accessor: "project_name",
|
||||
title: "项目名称",
|
||||
width: 220,
|
||||
render: (record) => (
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
maxWidth: 220,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{record.project_name ?? "-"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: "performance_accounting",
|
||||
title: "绩效核算",
|
||||
width: 110,
|
||||
render: (record) => {
|
||||
if (record.performance_accounting === true) {
|
||||
return <Badge color="green">{performanceTextMap.get(true)}</Badge>
|
||||
}
|
||||
if (record.performance_accounting === false) {
|
||||
return <Badge color="gray">{performanceTextMap.get(false)}</Badge>
|
||||
}
|
||||
return "-"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "obj_type",
|
||||
title: "对象类型",
|
||||
width: 100,
|
||||
render: (record) => {
|
||||
if (record.obj_type === 0 || record.obj_type === 1) {
|
||||
return objTypeTextMap.get(record.obj_type) ?? "-"
|
||||
}
|
||||
return "-"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "residual_pm",
|
||||
title: "余量正负",
|
||||
width: 100,
|
||||
render: (record) =>
|
||||
record.residual_pm === true ? "正" : record.residual_pm === false ? "负" : "-",
|
||||
},
|
||||
{ accessor: "start_time", title: "开始时间", width: 120 },
|
||||
{ accessor: "end_time", title: "结束时间", width: 120 },
|
||||
{ accessor: "work_time", title: "工时", width: 90, sortable: true },
|
||||
{ accessor: "rate", title: "额定倍率", width: 90 },
|
||||
{ accessor: "standard_size", title: "标准量", width: 90, sortable: true },
|
||||
{ accessor: "completed_size", title: "完成量", width: 90, sortable: true },
|
||||
{ accessor: "residual_size", title: "余量", width: 90, sortable: true },
|
||||
{
|
||||
accessor: "residual_work_time",
|
||||
title: "余量工时",
|
||||
width: 110,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
accessor: "remarks",
|
||||
title: "备注",
|
||||
width: 200,
|
||||
render: (record) => (
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
maxWidth: 200,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{record.remarks ?? "-"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: "operation",
|
||||
title: "操作",
|
||||
width: 120,
|
||||
textAlign: "center",
|
||||
render: (record) => {
|
||||
const locked = !!record.lock_status
|
||||
return (
|
||||
<Group gap={4} justify="center" wrap="nowrap">
|
||||
{isShow("edit") ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
disabled={locked}
|
||||
onClick={() => {
|
||||
if (locked) return
|
||||
setEditingRecord(record)
|
||||
setModalOpened(true)
|
||||
}}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
{isShow("lock") ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={locked ? "yellow" : "gray"}
|
||||
onClick={async () => {
|
||||
if (!record.id) return
|
||||
await lockDailyWorkData(!locked, record.id)
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "操作成功",
|
||||
message: locked ? "已解锁日报" : "已锁定日报",
|
||||
})
|
||||
load()
|
||||
}}>
|
||||
{locked ? <IconLock size={16} /> : <IconLockOpen size={16} />}
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
{isShow("delete") ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={locked}
|
||||
onClick={() => {
|
||||
if (locked || !record.id) return
|
||||
modals.openConfirmModal({
|
||||
title: "删除日报",
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">删除后不可恢复,你确定删除该日报吗?</Text>
|
||||
),
|
||||
labels: { confirm: "删除", cancel: "取消" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: async () => {
|
||||
await deleteDailyWorkData(record.id as number)
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "操作成功",
|
||||
message: "已删除日报",
|
||||
})
|
||||
load()
|
||||
},
|
||||
})
|
||||
}}>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
return cols
|
||||
}, [isShow, load])
|
||||
|
||||
const summaryText = useMemo(() => {
|
||||
return {
|
||||
total_work_time: summary?.total_work_time ?? 0,
|
||||
total_standard_size: summary?.total_standard_size ?? 0,
|
||||
total_completed_size: summary?.total_completed_size ?? 0,
|
||||
total_residual_size: summary?.total_residual_size ?? 0,
|
||||
total_residual_work_time: summary?.total_residual_work_time ?? 0,
|
||||
}
|
||||
}, [summary])
|
||||
|
||||
return (
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p="md" gap="md">
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
}}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="xs">
|
||||
<Text fw={700}>搜索条件</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
rightSection={
|
||||
searchExpanded ? (
|
||||
<IconChevronUp size={16} />
|
||||
) : (
|
||||
<IconChevronDown size={16} />
|
||||
)
|
||||
}
|
||||
onClick={() => setSearchExpanded((v) => !v)}>
|
||||
{searchExpanded ? "收起" : "展开"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => {
|
||||
setFilters(defaultFilters)
|
||||
setAppliedFilters(defaultFilters)
|
||||
setPage(1)
|
||||
setQueryTrigger((v) => v + 1)
|
||||
}}>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
onClick={() => {
|
||||
setAppliedFilters(filters)
|
||||
setPage(1)
|
||||
setQueryTrigger((v) => v + 1)
|
||||
}}>
|
||||
查询
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Collapse in={searchExpanded} transitionDuration={150} animateOpacity>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing="sm">
|
||||
<TextInput
|
||||
label="开始日期"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
type="date"
|
||||
value={filters.date_start}
|
||||
onChange={(e) => setFilters((s) => ({ ...s, date_start: e.target.value }))}
|
||||
/>
|
||||
<TextInput
|
||||
label="结束日期"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
type="date"
|
||||
value={filters.date_end}
|
||||
onChange={(e) => setFilters((s) => ({ ...s, date_end: e.target.value }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="员工姓名"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={leaderOptions}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.uid}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, uid: v }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="项目名称"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={projectOptions}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.project_id}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, project_id: v }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="任务组长"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={leaderOptions}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.leader_uid}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, leader_uid: v }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="规定类型"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={setTypeOpts}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.set_type ? [filters.set_type] : []}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, set_type: v[0] ?? "" }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="出勤类型"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={actualTypeOpts}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.actual_type ? [filters.actual_type] : []}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, actual_type: v[0] ?? "" }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="工作类别"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={workTypeOpts}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.work_type ? [filters.work_type] : []}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, work_type: v[0] ?? "" }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="核算方式"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={accountTypeOpts}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.accounting_type ? [filters.accounting_type] : []}
|
||||
onChange={(v) =>
|
||||
setFilters((s) => ({ ...s, accounting_type: v[0] ?? "" }))
|
||||
}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="绩效核算"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={performanceOpts}
|
||||
searchable
|
||||
clearable
|
||||
value={
|
||||
filters.performance_accounting ? [filters.performance_accounting] : []
|
||||
}
|
||||
onChange={(v) =>
|
||||
setFilters((s) => ({ ...s, performance_accounting: v[0] ?? "" }))
|
||||
}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="对象类型"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={objTypeOpts}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.obj_type ? [filters.obj_type] : []}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, obj_type: v[0] ?? "" }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
}}>
|
||||
<Stack gap={12} align="stretch" style={{ flex: 1, minHeight: 0 }}>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
onClick={handleExportData}>
|
||||
导出日报
|
||||
</Button>
|
||||
<ActionIcon onClick={load} variant="transparent">
|
||||
<IconRefresh size={16} />
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
|
||||
<DataTable<Daily.DailyWorkList>
|
||||
width="100%"
|
||||
style={{ width: "100%" }}
|
||||
withTableBorder
|
||||
withRowBorders
|
||||
pinFirstColumn
|
||||
pinLastColumn
|
||||
scrollAreaProps={{ type: "auto" }}
|
||||
fetching={loading}
|
||||
records={records}
|
||||
columns={columns}
|
||||
totalRecords={totalItems}
|
||||
recordsPerPage={pageSize}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
onRecordsPerPageChange={(v) => {
|
||||
setPageSize(v)
|
||||
setPage(1)
|
||||
}}
|
||||
recordsPerPageOptions={[10, 15, 20, 50]}
|
||||
noRecordsText="暂无数据"
|
||||
recordsPerPageLabel="当前页数"
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={(s) => {
|
||||
setSortStatus(s)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="xs" p="sm">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text size="sm" fw={700}>
|
||||
当前页总计
|
||||
</Text>
|
||||
<Group gap="md">
|
||||
<Text size="sm">工时:{summaryText.total_work_time}</Text>
|
||||
<Text size="sm">标准量:{summaryText.total_standard_size}</Text>
|
||||
<Text size="sm">完成量:{summaryText.total_completed_size}</Text>
|
||||
<Text size="sm">余量:{summaryText.total_residual_size}</Text>
|
||||
<Text size="sm">
|
||||
余量工时:{summaryText.total_residual_work_time}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<DailyReportModal
|
||||
opened={modalOpened}
|
||||
record={editingRecord}
|
||||
projectOptions={projectOptions}
|
||||
onCloseAction={(refresh) => {
|
||||
setModalOpened(false)
|
||||
setEditingRecord(null)
|
||||
if (refresh) load()
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
131
app/management/team/employee/components/CreateUserModal.tsx
Normal file
131
app/management/team/employee/components/CreateUserModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
|
||||
import { getUserGroupAll, userAdd } from "@/components/label/api/user"
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
TextInput,
|
||||
} from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
interface ComponentProps {
|
||||
opened: boolean
|
||||
onCloseAction: (refresh?: boolean) => void
|
||||
}
|
||||
|
||||
export default function CreateUserModal({
|
||||
opened,
|
||||
onCloseAction,
|
||||
}: ComponentProps) {
|
||||
const [groupOpts, setGroupOpts] = useState<Array<{ label: string; value: string }>>(
|
||||
[]
|
||||
)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
user_name: "",
|
||||
group_uuid: "",
|
||||
base_city: "",
|
||||
},
|
||||
validate: {
|
||||
user_name: (v) => (v.trim() ? null : "请输入用户名"),
|
||||
group_uuid: (v) => (v ? null : "请选择组织"),
|
||||
base_city: (v) => (v.trim() ? null : "请输入所在地"),
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return
|
||||
const run = async () => {
|
||||
const res = await getUserGroupAll()
|
||||
setGroupOpts(res ?? [])
|
||||
}
|
||||
queueMicrotask(run)
|
||||
}, [opened])
|
||||
|
||||
const submit = form.onSubmit(async (values) => {
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await userAdd({
|
||||
user_name: values.user_name.trim(),
|
||||
group_uuid: values.group_uuid,
|
||||
base_city: values.base_city.trim(),
|
||||
})
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "操作成功",
|
||||
message: "已新增用户",
|
||||
})
|
||||
form.reset()
|
||||
onCloseAction(true)
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "操作失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => onCloseAction()}
|
||||
title="新增用户"
|
||||
centered
|
||||
size={560}
|
||||
closeOnClickOutside={false}>
|
||||
<form onSubmit={submit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="用户名"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
withAsterisk
|
||||
{...form.getInputProps("user_name")}
|
||||
/>
|
||||
<Select
|
||||
label="组织"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
withAsterisk
|
||||
searchable
|
||||
data={groupOpts}
|
||||
{...form.getInputProps("group_uuid")}
|
||||
/>
|
||||
<TextInput
|
||||
label="所在地"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
withAsterisk
|
||||
{...form.getInputProps("base_city")}
|
||||
/>
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
onClick={() => onCloseAction()}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
loading={submitting}>
|
||||
确定
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
168
app/management/team/employee/components/EditUserModal.tsx
Normal file
168
app/management/team/employee/components/EditUserModal.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import { getUserGroupAll, userEditById } from "@/components/label/api/user"
|
||||
import { User } from "@/components/label/api/user/typing"
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
TextInput,
|
||||
} from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
|
||||
interface ComponentProps {
|
||||
opened: boolean
|
||||
data: User.UserInfo | null
|
||||
onCloseAction: (refresh?: boolean) => void
|
||||
}
|
||||
|
||||
export default function EditUserModal({
|
||||
opened,
|
||||
data,
|
||||
onCloseAction,
|
||||
}: ComponentProps) {
|
||||
const [groupOpts, setGroupOpts] = useState<Array<{ label: string; value: string }>>(
|
||||
[]
|
||||
)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const groupValueByName = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
groupOpts.forEach((g) => map.set(g.label, g.value))
|
||||
return map
|
||||
}, [groupOpts])
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: "",
|
||||
group_uuid: "",
|
||||
base_city: "",
|
||||
work_status: "1",
|
||||
},
|
||||
validate: {
|
||||
name: (v) => (v.trim() ? null : "请输入用户名"),
|
||||
group_uuid: (v) => (v ? null : "请选择组织"),
|
||||
base_city: (v) => (v.trim() ? null : "请输入所在地"),
|
||||
work_status: (v) => (v ? null : "请选择状态"),
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return
|
||||
const run = async () => {
|
||||
const res = await getUserGroupAll()
|
||||
setGroupOpts(res ?? [])
|
||||
}
|
||||
queueMicrotask(run)
|
||||
}, [opened])
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened || !data) return
|
||||
form.setValues({
|
||||
name: data.name ?? "",
|
||||
group_uuid: groupValueByName.get(data.group_name ?? "") ?? "",
|
||||
base_city: data.base_city ?? "",
|
||||
work_status: data.work_status === 0 ? "0" : "1",
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opened, data, groupValueByName])
|
||||
|
||||
const submit = form.onSubmit(async (values) => {
|
||||
if (!data?.uid) return
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await userEditById(
|
||||
{
|
||||
user_name: values.name.trim(),
|
||||
group_uuid: values.group_uuid,
|
||||
base_city: values.base_city.trim(),
|
||||
work_status: Number(values.work_status),
|
||||
},
|
||||
data.uid
|
||||
)
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "操作成功",
|
||||
message: "已更新用户信息",
|
||||
})
|
||||
onCloseAction(true)
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "操作失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => onCloseAction()}
|
||||
title="编辑用户"
|
||||
centered
|
||||
size={560}
|
||||
closeOnClickOutside={false}>
|
||||
<form onSubmit={submit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="用户名"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Select
|
||||
label="组织"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
withAsterisk
|
||||
searchable
|
||||
data={groupOpts}
|
||||
{...form.getInputProps("group_uuid")}
|
||||
/>
|
||||
<TextInput
|
||||
label="所在地"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
withAsterisk
|
||||
{...form.getInputProps("base_city")}
|
||||
/>
|
||||
<Select
|
||||
label="状态"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
withAsterisk
|
||||
data={[
|
||||
{ label: "在职", value: "1" },
|
||||
{ label: "离职", value: "0" },
|
||||
]}
|
||||
{...form.getInputProps("work_status")}
|
||||
/>
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
onClick={() => onCloseAction()}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
loading={submitting}>
|
||||
确定
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
265
app/management/team/employee/components/ImportModal.tsx
Normal file
265
app/management/team/employee/components/ImportModal.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
"use client"
|
||||
|
||||
import { userImport } from "@/components/label/api/user"
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
FileInput,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { IconAlertCircle, IconDownload, IconUpload } from "@tabler/icons-react"
|
||||
import { useState } from "react"
|
||||
import * as XLSX from "xlsx-js-style"
|
||||
|
||||
interface ComponentProps {
|
||||
opened: boolean
|
||||
onCloseAction: (refresh?: boolean) => void
|
||||
}
|
||||
|
||||
type ImportItem = {
|
||||
user_name: string
|
||||
group_name: string
|
||||
base_city: string
|
||||
}
|
||||
|
||||
export default function ImportModal({ opened, onCloseAction }: ComponentProps) {
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [sheetList, setSheetList] = useState<ImportItem[]>([])
|
||||
const [failedDataTips, setFailedDataTips] = useState<string[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const clearState = () => {
|
||||
setFile(null)
|
||||
setSheetList([])
|
||||
setFailedDataTips([])
|
||||
}
|
||||
|
||||
const parseFile = async (nextFile: File | null) => {
|
||||
setFile(nextFile)
|
||||
setSheetList([])
|
||||
setFailedDataTips([])
|
||||
if (!nextFile) return
|
||||
if (!nextFile.name.endsWith(".xlsx")) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "文件格式错误",
|
||||
message: "仅支持 .xlsx 文件",
|
||||
})
|
||||
setFile(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await nextFile.arrayBuffer()
|
||||
const workbook = XLSX.read(data, { type: "array" })
|
||||
const sheetName = workbook.SheetNames[0]
|
||||
const ws = workbook.Sheets[sheetName]
|
||||
const arr = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1, raw: false })
|
||||
const tableData = arr.slice(4).filter((row) => row.length > 0)
|
||||
|
||||
if (tableData.length === 0) {
|
||||
setSheetList([])
|
||||
return
|
||||
}
|
||||
|
||||
const checkList: Record<number, string> = {
|
||||
0: "用户名",
|
||||
1: "组织",
|
||||
2: "所在地",
|
||||
}
|
||||
const tipRows = 4
|
||||
const errors: string[] = []
|
||||
|
||||
const list: ImportItem[] = tableData.map((item: any[], index: number) => {
|
||||
Object.entries(checkList).forEach(([key, fieldName]) => {
|
||||
const value = item[Number(key)]
|
||||
if (!value || (typeof value === "string" && !value.trim())) {
|
||||
errors.push(
|
||||
`第${index + 1 + tipRows}行数据缺少必填项【${fieldName}】或该必填项全为空格`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
user_name: item[0] ? String(item[0]).trim() : "",
|
||||
group_name: item[1] ? String(item[1]).trim() : "",
|
||||
base_city: item[2] ? String(item[2]).trim() : "",
|
||||
}
|
||||
})
|
||||
|
||||
if (errors.length > 0) {
|
||||
setFailedDataTips(errors)
|
||||
setSheetList([])
|
||||
return
|
||||
}
|
||||
|
||||
setSheetList(list)
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "解析失败",
|
||||
message: e instanceof Error ? e.message : "读取文件失败",
|
||||
})
|
||||
setFile(null)
|
||||
}
|
||||
}
|
||||
|
||||
const exportTemplateFile = () => {
|
||||
const tips = [
|
||||
[`提示:\n1.下列字段均为必填项\n2.【组织】仅可以填入一个组织名称`],
|
||||
[""],
|
||||
[""],
|
||||
]
|
||||
const col = [["用户名", "组织", "所在地"]]
|
||||
const wb = XLSX.utils.book_new()
|
||||
const ws = XLSX.utils.aoa_to_sheet([...tips, ...col])
|
||||
XLSX.utils.book_append_sheet(wb, ws, "用户信息")
|
||||
wb.Sheets["用户信息"]["!rows"] = [{ hpx: 20 }, { hpx: 20 }, { hpx: 20 }]
|
||||
wb.Sheets["用户信息"]["!cols"] = [{ wch: 20 }, { wch: 20 }, { wch: 20 }]
|
||||
wb.Sheets["用户信息"]["!merges"] = [{ s: { c: 0, r: 0 }, e: { c: 2, r: 2 } }]
|
||||
if (wb.Sheets["用户信息"]["A1"]) {
|
||||
wb.Sheets["用户信息"]["A1"].s = { alignment: { wrapText: true } }
|
||||
}
|
||||
XLSX.writeFile(wb, "用户信息批量导入模板.xlsx")
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (sheetList.length === 0) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
title: "无导入数据",
|
||||
message: "请先上传并校验 Excel 文件",
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
setSubmitting(true)
|
||||
const res = await userImport({ user_infos: sheetList })
|
||||
if (Array.isArray(res) && res.length > 0) {
|
||||
setFailedDataTips(res)
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "导入失败",
|
||||
message: "请根据错误提示修正后重试",
|
||||
})
|
||||
return
|
||||
}
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "导入成功",
|
||||
message: "批量新增用户数据成功",
|
||||
})
|
||||
clearState()
|
||||
onCloseAction(true)
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "导入失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {
|
||||
clearState()
|
||||
onCloseAction()
|
||||
}}
|
||||
title="批量导入用户信息"
|
||||
centered
|
||||
size={760}
|
||||
closeOnClickOutside={false}>
|
||||
<Stack gap="sm">
|
||||
<Paper
|
||||
withBorder
|
||||
p="sm"
|
||||
radius="sm"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
}}>
|
||||
<Group gap={8}>
|
||||
<Text size="sm">点击下载</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
leftSection={<IconDownload size={14} />}
|
||||
onClick={exportTemplateFile}>
|
||||
用户信息批量导入模板
|
||||
</Button>
|
||||
<Text size="sm">后,填写上传</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<FileInput
|
||||
label="上传文件"
|
||||
description="支持单个 xlsx 文件上传"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
accept=".xlsx"
|
||||
placeholder="请选择文件"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
value={file}
|
||||
onChange={parseFile}
|
||||
clearable
|
||||
/>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
p="xs"
|
||||
radius="sm"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
}}>
|
||||
<Text size="sm">待导入数据:{sheetList.length} 条</Text>
|
||||
</Paper>
|
||||
|
||||
{failedDataTips.length > 0 ? (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
color="red"
|
||||
radius="sm"
|
||||
title="错误信息提示">
|
||||
<Stack gap={4} style={{ maxHeight: 180, overflowY: "auto" }}>
|
||||
{failedDataTips.map((item) => (
|
||||
<Text key={item} size="sm">
|
||||
{item}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
onClick={() => {
|
||||
clearState()
|
||||
onCloseAction()
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
onClick={submit}
|
||||
loading={submitting}>
|
||||
开始导入
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
288
app/management/team/employee/page.tsx
Normal file
288
app/management/team/employee/page.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
"use client"
|
||||
|
||||
import { getUserList, userEditById } from "@/components/label/api/user"
|
||||
import { User } from "@/components/label/api/user/typing"
|
||||
import useAuth from "@/components/label/hooks/useAuth"
|
||||
import { useAllUserStore } from "@/components/label/store/auth"
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core"
|
||||
import { modals } from "@mantine/modals"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { IconEdit, IconPlus, IconRefresh, IconSearch, IconUpload } from "@tabler/icons-react"
|
||||
import { DataTable, DataTableColumn } from "mantine-datatable"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import CreateUserModal from "./components/CreateUserModal"
|
||||
import EditUserModal from "./components/EditUserModal"
|
||||
import ImportModal from "./components/ImportModal"
|
||||
|
||||
export default function TeamEmployeePage() {
|
||||
const { isShow } = useAuth()
|
||||
const setFlag = useAllUserStore((s) => s.setFlag)
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [appliedName, setAppliedName] = useState("")
|
||||
const [records, setRecords] = useState<User.UserInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const [createOpened, setCreateOpened] = useState(false)
|
||||
const [editOpened, setEditOpened] = useState(false)
|
||||
const [importOpened, setImportOpened] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<User.UserInfo | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params: User.ListRequest = {
|
||||
page_number: page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (appliedName.trim()) params.name = appliedName.trim()
|
||||
const res = await getUserList(params)
|
||||
setRecords(res?.user_list ?? [])
|
||||
setTotalItems(res?.total_items ?? 0)
|
||||
} catch (e) {
|
||||
setRecords([])
|
||||
setTotalItems(0)
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "加载失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [appliedName, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(load)
|
||||
}, [load])
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const cols: DataTableColumn<User.UserInfo>[] = [
|
||||
{ accessor: "uid", title: "UID", width: 120, textAlign: "center" },
|
||||
{ accessor: "name", title: "姓名", width: 120, textAlign: "center" },
|
||||
{
|
||||
accessor: "work_status",
|
||||
title: "状态",
|
||||
width: 100,
|
||||
textAlign: "center",
|
||||
render: (record) =>
|
||||
record.work_status === 1 ? (
|
||||
<Badge color="green" variant="light">
|
||||
在职
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="gray" variant="light">
|
||||
离职
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: "group_name",
|
||||
title: "组织",
|
||||
width: 180,
|
||||
textAlign: "center",
|
||||
},
|
||||
{
|
||||
accessor: "base_city",
|
||||
title: "所在地",
|
||||
width: 180,
|
||||
textAlign: "center",
|
||||
},
|
||||
{
|
||||
accessor: "operation",
|
||||
title: "操作",
|
||||
width: 220,
|
||||
textAlign: "center",
|
||||
render: (record) => (
|
||||
<Group justify="center" gap={4} wrap="nowrap">
|
||||
{isShow("edit") ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
onClick={() => {
|
||||
modals.openConfirmModal({
|
||||
title: "重置密码",
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">是否确定重置当前用户密码为 123456?</Text>
|
||||
),
|
||||
labels: { confirm: "确定", cancel: "取消" },
|
||||
onConfirm: async () => {
|
||||
await userEditById({ password: "123456" }, record.uid)
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "操作成功",
|
||||
message: "已重置密码",
|
||||
})
|
||||
},
|
||||
})
|
||||
}}>
|
||||
重置密码
|
||||
</Button>
|
||||
) : null}
|
||||
{isShow("edit") ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => {
|
||||
setSelectedUser(record)
|
||||
setEditOpened(true)
|
||||
}}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]
|
||||
return cols
|
||||
}, [isShow])
|
||||
|
||||
return (
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p="md" gap="md">
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
}}>
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="请输入姓名"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
onClick={() => {
|
||||
setAppliedName(name)
|
||||
setPage(1)
|
||||
}}>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => {
|
||||
setName("")
|
||||
setAppliedName("")
|
||||
setPage(1)
|
||||
setPageSize(20)
|
||||
}}>
|
||||
重置
|
||||
</Button>
|
||||
</Group>
|
||||
<Group>
|
||||
{isShow("add") ? (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setCreateOpened(true)}>
|
||||
新增用户
|
||||
</Button>
|
||||
) : null}
|
||||
{isShow("add") ? (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
variant="default"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => setImportOpened(true)}>
|
||||
批量导入
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
}}>
|
||||
<DataTable<User.UserInfo>
|
||||
withTableBorder
|
||||
withRowBorders
|
||||
fetching={loading}
|
||||
records={records}
|
||||
columns={columns}
|
||||
noRecordsText="暂无数据"
|
||||
totalRecords={totalItems}
|
||||
recordsPerPage={pageSize}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
onRecordsPerPageChange={(v) => {
|
||||
setPageSize(v)
|
||||
setPage(1)
|
||||
}}
|
||||
recordsPerPageOptions={[10, 20, 50, 100]}
|
||||
scrollAreaProps={{ type: "auto" }}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<CreateUserModal
|
||||
opened={createOpened}
|
||||
onCloseAction={(refresh) => {
|
||||
setCreateOpened(false)
|
||||
if (refresh) {
|
||||
setFlag(true)
|
||||
load()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ImportModal
|
||||
opened={importOpened}
|
||||
onCloseAction={(refresh) => {
|
||||
setImportOpened(false)
|
||||
if (refresh) {
|
||||
setFlag(true)
|
||||
load()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditUserModal
|
||||
opened={editOpened}
|
||||
data={selectedUser}
|
||||
onCloseAction={(refresh) => {
|
||||
setEditOpened(false)
|
||||
setSelectedUser(null)
|
||||
if (refresh) {
|
||||
setFlag(true)
|
||||
load()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
"use client"
|
||||
|
||||
export default function EmployeePage() {
|
||||
return <></>
|
||||
}
|
||||
140
app/management/team/organization/components/CreateModal.tsx
Normal file
140
app/management/team/organization/components/CreateModal.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
getUserAll,
|
||||
getUserGroupAll,
|
||||
userGroupAdd,
|
||||
} from "@/components/label/api/user"
|
||||
import { useAllUserStore } from "@/components/label/store/auth"
|
||||
import { Button, Group, Modal, Select, Stack, TextInput } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
|
||||
interface ComponentProps {
|
||||
opened: boolean
|
||||
onCloseAction: (refresh?: boolean) => void
|
||||
}
|
||||
|
||||
export default function CreateModal({ opened, onCloseAction }: ComponentProps) {
|
||||
const storeUserOpts = useAllUserStore((s) => s.userOpts)
|
||||
|
||||
const [groupOpts, setGroupOpts] = useState<Array<{ label: string; value: string }>>(
|
||||
[]
|
||||
)
|
||||
const [userOpts, setUserOpts] = useState<Array<{ label: string; value: string }>>(
|
||||
[]
|
||||
)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const finalUserOpts = useMemo(() => {
|
||||
return userOpts.length > 0 ? userOpts : (storeUserOpts as any[])
|
||||
}, [storeUserOpts, userOpts])
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
group_name: "",
|
||||
leader_uid: "",
|
||||
parent_group_uuid: "",
|
||||
},
|
||||
validate: {
|
||||
group_name: (v) => (v.trim() ? null : "请输入用户组名称"),
|
||||
leader_uid: (v) => (v ? null : "请选择用户组负责人"),
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return
|
||||
const run = async () => {
|
||||
const [groupRes, userRes] = await Promise.all([
|
||||
getUserGroupAll(),
|
||||
getUserAll(),
|
||||
])
|
||||
setGroupOpts(groupRes ?? [])
|
||||
setUserOpts((userRes ?? []).map((u) => ({ label: u.label, value: String(u.value) })))
|
||||
}
|
||||
queueMicrotask(run)
|
||||
}, [opened])
|
||||
|
||||
const submit = form.onSubmit(async (values) => {
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await userGroupAdd({
|
||||
group_name: values.group_name.trim(),
|
||||
leader_uid: Number(values.leader_uid),
|
||||
parent_group_uuid: values.parent_group_uuid,
|
||||
})
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "操作成功",
|
||||
message: "已新增用户组",
|
||||
})
|
||||
form.reset()
|
||||
onCloseAction(true)
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "操作失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => onCloseAction()}
|
||||
title="新增用户组"
|
||||
centered
|
||||
size={560}
|
||||
closeOnClickOutside={false}>
|
||||
<form onSubmit={submit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="用户组名称"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
withAsterisk
|
||||
{...form.getInputProps("group_name")}
|
||||
/>
|
||||
<Select
|
||||
label="用户组负责人"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
withAsterisk
|
||||
searchable
|
||||
data={finalUserOpts}
|
||||
{...form.getInputProps("leader_uid")}
|
||||
/>
|
||||
<Select
|
||||
label="上级用户组"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
searchable
|
||||
clearable
|
||||
data={groupOpts}
|
||||
{...form.getInputProps("parent_group_uuid")}
|
||||
/>
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
onClick={() => onCloseAction()}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
loading={submitting}>
|
||||
确定
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,262 @@
|
||||
"use client"
|
||||
|
||||
export default function OrganizationPage() {
|
||||
return <></>
|
||||
import { getUserGroupTree } from "@/components/label/api/user"
|
||||
import { Organization } from "@/components/label/api/user/typing"
|
||||
import useAuth from "@/components/label/hooks/useAuth"
|
||||
import { useAllUserStore } from "@/components/label/store/auth"
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { IconPlus, IconRefresh, IconSearch, IconUserCircle } from "@tabler/icons-react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import CreateModal from "./components/CreateModal"
|
||||
|
||||
type NodeItem = {
|
||||
key: string
|
||||
name: string
|
||||
isUser?: boolean
|
||||
isManager?: boolean
|
||||
children: NodeItem[]
|
||||
}
|
||||
|
||||
function toNodeTree(data: Organization.Response[]): NodeItem[] {
|
||||
return (data ?? []).map((item) => {
|
||||
const children: NodeItem[] = []
|
||||
|
||||
if (Array.isArray(item.children) && item.children.length > 0) {
|
||||
children.push(...toNodeTree(item.children))
|
||||
}
|
||||
|
||||
if (item.leader_uid && item.leader_name) {
|
||||
children.push({
|
||||
key: `${item.group_uuid}/${item.leader_uid}`,
|
||||
name: item.leader_name,
|
||||
isUser: true,
|
||||
isManager: true,
|
||||
children: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(item.members) && item.members.length > 0) {
|
||||
item.members.forEach((member) => {
|
||||
if (member.id === item.leader_uid) return
|
||||
children.push({
|
||||
key: `${item.group_uuid}/${member.id}`,
|
||||
name: member.name,
|
||||
isUser: true,
|
||||
children: [],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
key: item.group_uuid,
|
||||
name: item.name,
|
||||
children,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function filterTree(nodes: NodeItem[], keyword: string): NodeItem[] {
|
||||
if (!keyword.trim()) return nodes
|
||||
const lower = keyword.trim().toLowerCase()
|
||||
return nodes
|
||||
.map((node) => {
|
||||
const matched = node.name.toLowerCase().includes(lower)
|
||||
const children = filterTree(node.children, keyword)
|
||||
if (!matched && children.length === 0) return null
|
||||
return { ...node, children }
|
||||
})
|
||||
.filter((n): n is NodeItem => !!n)
|
||||
}
|
||||
|
||||
type FlatNode = NodeItem & { depth: number }
|
||||
|
||||
function flattenTree(nodes: NodeItem[], depth = 0): FlatNode[] {
|
||||
const list: FlatNode[] = []
|
||||
nodes.forEach((node) => {
|
||||
list.push({ ...node, depth })
|
||||
if (node.children.length > 0) {
|
||||
list.push(...flattenTree(node.children, depth + 1))
|
||||
}
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
function HighlightText(props: { text: string; keyword: string }) {
|
||||
const { text, keyword } = props
|
||||
if (!keyword.trim()) return <>{text}</>
|
||||
const index = text.toLowerCase().indexOf(keyword.trim().toLowerCase())
|
||||
if (index < 0) return <>{text}</>
|
||||
|
||||
const before = text.slice(0, index)
|
||||
const match = text.slice(index, index + keyword.length)
|
||||
const after = text.slice(index + keyword.length)
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<Text component="span" c="blue" fw={700}>
|
||||
{match}
|
||||
</Text>
|
||||
{after}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TeamOrganizationPage() {
|
||||
const { isShow } = useAuth()
|
||||
const setFlag = useAllUserStore((s) => s.setFlag)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [groupName, setGroupName] = useState("")
|
||||
const [data, setData] = useState<NodeItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await getUserGroupTree()
|
||||
setData(toNodeTree(res ?? []))
|
||||
} catch (e) {
|
||||
setData([])
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "加载失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(load)
|
||||
}, [load])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const filtered = filterTree(data, groupName)
|
||||
return flattenTree(filtered)
|
||||
}, [data, groupName])
|
||||
|
||||
return (
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p="md" gap="md">
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
}}>
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="请输入用户组名称或用户名称进行搜索"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.currentTarget.value)}
|
||||
style={{ minWidth: 360 }}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="md"
|
||||
onClick={load}
|
||||
loading={loading}>
|
||||
<IconRefresh size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
{isShow("group_add") ? (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setOpen(true)}>
|
||||
新增用户组
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
}}>
|
||||
<ScrollArea h="100%" type="auto">
|
||||
<Stack gap={6}>
|
||||
{rows.map((node) => {
|
||||
return (
|
||||
<Paper
|
||||
key={node.key}
|
||||
withBorder
|
||||
radius="sm"
|
||||
p="xs"
|
||||
style={{
|
||||
marginLeft: node.depth * 18,
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))",
|
||||
backgroundColor: node.isUser
|
||||
? "var(--mantine-color-gray-0)"
|
||||
: "var(--mantine-color-blue-0)",
|
||||
}}>
|
||||
<Group gap={8}>
|
||||
{node.isUser ? <IconUserCircle size={16} /> : null}
|
||||
<Text size="sm">
|
||||
<HighlightText text={node.name} keyword={groupName} />
|
||||
{!node.isUser && node.children.length > 0
|
||||
? `(${node.children.length})`
|
||||
: ""}
|
||||
</Text>
|
||||
{node.isManager ? (
|
||||
<Text
|
||||
size="xs"
|
||||
c="blue"
|
||||
style={{
|
||||
backgroundColor: "var(--mantine-color-blue-0)",
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
}}>
|
||||
负责人
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
)
|
||||
})}
|
||||
{rows.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
暂无匹配数据
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
|
||||
<CreateModal
|
||||
opened={open}
|
||||
onCloseAction={(refresh) => {
|
||||
setOpen(false)
|
||||
if (refresh) {
|
||||
setFlag(true)
|
||||
load()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
14
app/management/team/page.tsx
Normal file
14
app/management/team/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export default function TeamPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
router.push("/management/team/employee")
|
||||
}, [router])
|
||||
|
||||
return <></>
|
||||
}
|
||||
279
app/management/team/sync_server/page.tsx
Normal file
279
app/management/team/sync_server/page.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
getDataSyncServerList,
|
||||
updateSyncServerLocation,
|
||||
} from "@/components/label/api/sync"
|
||||
import { Response } from "@/components/label/api/sync/typing"
|
||||
import useAuth from "@/components/label/hooks/useAuth"
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
TextInput,
|
||||
} from "@mantine/core"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { IconEdit, IconRefresh, IconSearch } from "@tabler/icons-react"
|
||||
import { DataTable, DataTableColumn } from "mantine-datatable"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
export default function TeamSyncServerPage() {
|
||||
const { isShow } = useAuth()
|
||||
|
||||
const [records, setRecords] = useState<Response.SyncServer[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const [location, setLocation] = useState("")
|
||||
const [appliedLocation, setAppliedLocation] = useState("")
|
||||
const [online, setOnline] = useState<string | null>(null)
|
||||
const [appliedOnline, setAppliedOnline] = useState<string | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<Response.SyncServer | null>(null)
|
||||
const [editingLocation, setEditingLocation] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params: any = {
|
||||
page_number: page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (appliedLocation.trim()) params.location = appliedLocation.trim()
|
||||
if (appliedOnline === "true") params.is_online = true
|
||||
if (appliedOnline === "false") params.is_online = false
|
||||
|
||||
const res = await getDataSyncServerList(params)
|
||||
setRecords(res?.sync_servers ?? [])
|
||||
setTotalItems(res?.total_items ?? 0)
|
||||
} catch (e) {
|
||||
setRecords([])
|
||||
setTotalItems(0)
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "加载失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [appliedLocation, appliedOnline, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const cols: DataTableColumn<Response.SyncServer>[] = [
|
||||
{ accessor: "id", title: "ID", width: 80 },
|
||||
{ accessor: "ip", title: "IP", width: 180 },
|
||||
{ accessor: "location", title: "IP所在地", width: 180 },
|
||||
{
|
||||
accessor: "is_online",
|
||||
title: "状态",
|
||||
width: 100,
|
||||
render: (record) =>
|
||||
record.is_online ? (
|
||||
<Badge color="green" variant="light">
|
||||
在线
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="red" variant="light">
|
||||
离线
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ accessor: "create_at", title: "创建时间", width: 180 },
|
||||
{ accessor: "update_at", title: "更新时间", width: 180 },
|
||||
{
|
||||
accessor: "operation",
|
||||
title: "操作",
|
||||
width: 100,
|
||||
textAlign: "center",
|
||||
render: (record) =>
|
||||
isShow("server_edit") ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => {
|
||||
setEditingRecord(record)
|
||||
setEditingLocation(record.location ?? "")
|
||||
setOpen(true)
|
||||
}}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
) : null,
|
||||
},
|
||||
]
|
||||
return cols
|
||||
}, [isShow])
|
||||
|
||||
const saveLocation = async () => {
|
||||
if (!editingRecord?.id) return
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await updateSyncServerLocation(editingLocation.trim(), editingRecord.id)
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "操作成功",
|
||||
message: "已更新同步服务所在地",
|
||||
})
|
||||
setOpen(false)
|
||||
setEditingRecord(null)
|
||||
setEditingLocation("")
|
||||
load()
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "更新失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p="md" gap="md">
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
}}>
|
||||
<Group wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="请输入IP所在地"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={[
|
||||
{ label: "在线", value: "true" },
|
||||
{ label: "离线", value: "false" },
|
||||
]}
|
||||
clearable
|
||||
value={online}
|
||||
onChange={setOnline}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
onClick={() => {
|
||||
setAppliedLocation(location)
|
||||
setAppliedOnline(online)
|
||||
setPage(1)
|
||||
}}>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => {
|
||||
setLocation("")
|
||||
setOnline(null)
|
||||
setAppliedLocation("")
|
||||
setAppliedOnline(null)
|
||||
setPage(1)
|
||||
setPageSize(20)
|
||||
}}>
|
||||
重置
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
}}>
|
||||
<DataTable<Response.SyncServer>
|
||||
withTableBorder
|
||||
withRowBorders
|
||||
fetching={loading}
|
||||
records={records}
|
||||
columns={columns}
|
||||
totalRecords={totalItems}
|
||||
recordsPerPage={pageSize}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
onRecordsPerPageChange={(v) => {
|
||||
setPageSize(v)
|
||||
setPage(1)
|
||||
}}
|
||||
recordsPerPageOptions={[10, 20, 50, 100]}
|
||||
noRecordsText="暂无数据"
|
||||
scrollAreaProps={{ type: "auto" }}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
setEditingRecord(null)
|
||||
setEditingLocation("")
|
||||
}}
|
||||
title="更新同步服务所在地"
|
||||
centered
|
||||
size={520}
|
||||
closeOnClickOutside={false}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="所在地"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
value={editingLocation}
|
||||
onChange={(e) => setEditingLocation(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setEditingRecord(null)
|
||||
setEditingLocation("")
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
onClick={saveLocation}
|
||||
loading={submitting}>
|
||||
确定
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
455
app/management/team/sync_task/page.tsx
Normal file
455
app/management/team/sync_task/page.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
"use client"
|
||||
|
||||
import { getProjectList } from "@/components/label/api/project"
|
||||
import {
|
||||
getAllSyncServerList,
|
||||
getDataSyncTaskList,
|
||||
newSyncTask,
|
||||
updateSyncTaskStatus,
|
||||
} from "@/components/label/api/sync"
|
||||
import { Response } from "@/components/label/api/sync/typing"
|
||||
import useAuth from "@/components/label/hooks/useAuth"
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core"
|
||||
import { modals } from "@mantine/modals"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { IconPlayerPause, IconPlayerPlay, IconPlus, IconRefresh, IconSearch, IconTrash, IconX } from "@tabler/icons-react"
|
||||
import { DataTable, DataTableColumn } from "mantine-datatable"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
const StatusEnum = new Map<number, string>([
|
||||
[1, "待同步"],
|
||||
[2, "同步中"],
|
||||
[3, "同步失败"],
|
||||
[4, "同步成功"],
|
||||
[5, "待删除"],
|
||||
[6, "删除中"],
|
||||
[7, "删除失败"],
|
||||
[8, "删除完成"],
|
||||
[9, "等待暂停"],
|
||||
[10, "已暂停"],
|
||||
[11, "等待关闭"],
|
||||
[12, "已关闭"],
|
||||
])
|
||||
|
||||
export default function TeamSyncTaskPage() {
|
||||
const { isShow } = useAuth()
|
||||
|
||||
const [records, setRecords] = useState<Response.SyncTask[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [projectId, setProjectId] = useState<string | null>(null)
|
||||
const [serverId, setServerId] = useState<string | null>(null)
|
||||
const [appliedStatus, setAppliedStatus] = useState<string | null>(null)
|
||||
const [appliedProjectId, setAppliedProjectId] = useState<string | null>(null)
|
||||
const [appliedServerId, setAppliedServerId] = useState<string | null>(null)
|
||||
|
||||
const [projectOpt, setProjectOpt] = useState<Array<{ label: string; value: string }>>([])
|
||||
const [serverAllOpt, setServerAllOpt] = useState<Array<{ label: string; value: string }>>([])
|
||||
const [serverOnlineOpt, setServerOnlineOpt] = useState<Array<{ label: string; value: string }>>(
|
||||
[]
|
||||
)
|
||||
const [serverMap, setServerMap] = useState<Map<number, string>>(new Map())
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [newProjectId, setNewProjectId] = useState<string | null>(null)
|
||||
const [newServerId, setNewServerId] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const loadOptions = useCallback(async () => {
|
||||
const [projectRes, serverRes] = await Promise.all([
|
||||
getProjectList({ page_number: 1, page_size: 10000 }),
|
||||
getAllSyncServerList(),
|
||||
])
|
||||
|
||||
const projects = projectRes?.project_list ?? []
|
||||
setProjectOpt(projects.map((p) => ({ label: p.name, value: String(p.id) })))
|
||||
|
||||
const allServers = serverRes ?? []
|
||||
setServerAllOpt(allServers.map((s) => ({ label: s.ip, value: String(s.id) })))
|
||||
setServerOnlineOpt(
|
||||
allServers
|
||||
.filter((s) => s.is_online)
|
||||
.map((s) => ({ label: s.ip, value: String(s.id) }))
|
||||
)
|
||||
setServerMap(new Map(allServers.map((s) => [s.id, s.ip])))
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params: any = {
|
||||
page_number: page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (appliedStatus) params.status = Number(appliedStatus)
|
||||
if (appliedProjectId) params.project_id = Number(appliedProjectId)
|
||||
if (appliedServerId) params.sync_server_id = Number(appliedServerId)
|
||||
|
||||
const res = await getDataSyncTaskList(params)
|
||||
setRecords(res?.sync_tasks ?? [])
|
||||
setTotalItems(res?.total_items ?? 0)
|
||||
} catch (e) {
|
||||
setRecords([])
|
||||
setTotalItems(0)
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "加载失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [appliedProjectId, appliedServerId, appliedStatus, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(loadOptions)
|
||||
}, [loadOptions])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const performStatusAction = async (id: number, statusTo: number, successText: string) => {
|
||||
try {
|
||||
await updateSyncTaskStatus(id, statusTo)
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "操作成功",
|
||||
message: successText,
|
||||
})
|
||||
load()
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "操作失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const cols: DataTableColumn<Response.SyncTask>[] = [
|
||||
{ accessor: "id", title: "ID", width: 80 },
|
||||
{ accessor: "uuid", title: "UUID", width: 320 },
|
||||
{
|
||||
accessor: "status",
|
||||
title: "状态",
|
||||
width: 120,
|
||||
render: (record) => StatusEnum.get(record.status ?? 0) ?? "-",
|
||||
},
|
||||
{ accessor: "project_name", title: "项目名称", width: 260 },
|
||||
{
|
||||
accessor: "sync_server_id",
|
||||
title: "同步服务IP",
|
||||
width: 160,
|
||||
render: (record) =>
|
||||
record.sync_server_id ? serverMap.get(record.sync_server_id) ?? "-" : "-",
|
||||
},
|
||||
{ accessor: "location", title: "IP所在地", width: 140 },
|
||||
{ accessor: "create_at", title: "创建时间", width: 180 },
|
||||
{ accessor: "update_at", title: "更新时间", width: 180 },
|
||||
{
|
||||
accessor: "operation",
|
||||
title: "操作",
|
||||
width: 200,
|
||||
textAlign: "center",
|
||||
render: (record) => {
|
||||
const couldRun = isShow("task_edit") && [10].includes(record.status ?? -1)
|
||||
const couldStop = isShow("task_edit") && [1, 2].includes(record.status ?? -1)
|
||||
const couldDelete =
|
||||
isShow("task_edit") && [1, 2, 3, 4, 9, 10].includes(record.status ?? -1)
|
||||
const couldClose =
|
||||
isShow("task_edit") && ![11, 12].includes(record.status ?? -1)
|
||||
|
||||
return (
|
||||
<Group justify="center" gap={4} wrap="nowrap">
|
||||
{couldRun ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="green"
|
||||
onClick={() =>
|
||||
performStatusAction(record.id as number, 1, "已恢复同步")
|
||||
}>
|
||||
<IconPlayerPlay size={16} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
{couldStop ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="yellow"
|
||||
onClick={() => {
|
||||
modals.openConfirmModal({
|
||||
title: "暂停任务",
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">是否确定暂停当前同步任务?</Text>
|
||||
),
|
||||
labels: { confirm: "确定", cancel: "取消" },
|
||||
onConfirm: () =>
|
||||
performStatusAction(record.id as number, 9, "已暂停任务"),
|
||||
})
|
||||
}}>
|
||||
<IconPlayerPause size={16} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
{couldDelete ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
modals.openConfirmModal({
|
||||
title: "删除任务",
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">是否确定删除当前同步任务?</Text>
|
||||
),
|
||||
labels: { confirm: "确定", cancel: "取消" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () =>
|
||||
performStatusAction(record.id as number, 5, "已提交删除"),
|
||||
})
|
||||
}}>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
{couldClose ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
modals.openConfirmModal({
|
||||
title: "关闭任务",
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">是否确定关闭当前同步任务?</Text>
|
||||
),
|
||||
labels: { confirm: "确定", cancel: "取消" },
|
||||
onConfirm: () =>
|
||||
performStatusAction(record.id as number, 11, "已关闭任务"),
|
||||
})
|
||||
}}>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
</Group>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
return cols
|
||||
}, [isShow, serverMap])
|
||||
|
||||
const createTask = async () => {
|
||||
if (!newProjectId || !newServerId) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
title: "参数不完整",
|
||||
message: "请先选择项目和同步服务",
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
setCreating(true)
|
||||
await newSyncTask({
|
||||
project_id: Number(newProjectId),
|
||||
sync_server_id: Number(newServerId),
|
||||
})
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "操作成功",
|
||||
message: "已新增同步任务",
|
||||
})
|
||||
setOpen(false)
|
||||
setNewProjectId(null)
|
||||
setNewServerId(null)
|
||||
load()
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "操作失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p="md" gap="md">
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
}}>
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group>
|
||||
<Select
|
||||
placeholder="任务状态"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={Array.from(StatusEnum.entries()).map(([value, label]) => ({
|
||||
label,
|
||||
value: String(value),
|
||||
}))}
|
||||
clearable
|
||||
searchable
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Select
|
||||
placeholder="项目名称"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={projectOpt}
|
||||
clearable
|
||||
searchable
|
||||
value={projectId}
|
||||
onChange={setProjectId}
|
||||
/>
|
||||
<Select
|
||||
placeholder="同步服务IP"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={serverAllOpt}
|
||||
clearable
|
||||
searchable
|
||||
value={serverId}
|
||||
onChange={setServerId}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
onClick={() => {
|
||||
setAppliedStatus(status)
|
||||
setAppliedProjectId(projectId)
|
||||
setAppliedServerId(serverId)
|
||||
setPage(1)
|
||||
}}>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => {
|
||||
setStatus(null)
|
||||
setProjectId(null)
|
||||
setServerId(null)
|
||||
setAppliedStatus(null)
|
||||
setAppliedProjectId(null)
|
||||
setAppliedServerId(null)
|
||||
setPage(1)
|
||||
setPageSize(20)
|
||||
}}>
|
||||
重置
|
||||
</Button>
|
||||
</Group>
|
||||
{isShow("task_add") ? (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => {
|
||||
setNewProjectId(null)
|
||||
setNewServerId(null)
|
||||
setOpen(true)
|
||||
}}>
|
||||
新增同步任务
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
}}>
|
||||
<DataTable<Response.SyncTask>
|
||||
withTableBorder
|
||||
withRowBorders
|
||||
fetching={loading}
|
||||
records={records}
|
||||
columns={columns}
|
||||
totalRecords={totalItems}
|
||||
recordsPerPage={pageSize}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
onRecordsPerPageChange={(v) => {
|
||||
setPageSize(v)
|
||||
setPage(1)
|
||||
}}
|
||||
recordsPerPageOptions={[10, 20, 50, 100]}
|
||||
noRecordsText="暂无数据"
|
||||
scrollAreaProps={{ type: "auto" }}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title="新增同步任务"
|
||||
centered
|
||||
size={520}
|
||||
closeOnClickOutside={false}>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label="项目名称"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={projectOpt}
|
||||
searchable
|
||||
value={newProjectId}
|
||||
onChange={setNewProjectId}
|
||||
/>
|
||||
<Select
|
||||
label="同步服务IP(仅在线)"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={serverOnlineOpt}
|
||||
searchable
|
||||
value={newServerId}
|
||||
onChange={setNewServerId}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="xs" radius="xs" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
loading={creating}
|
||||
onClick={createTask}>
|
||||
确定
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
47
app/management/team/utils.ts
Normal file
47
app/management/team/utils.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export type LeaderTreeNode = {
|
||||
title?: string
|
||||
value?: string | number
|
||||
children?: LeaderTreeNode[]
|
||||
}
|
||||
|
||||
export function flattenLeaderTree(
|
||||
nodes: LeaderTreeNode[],
|
||||
prefixTitle: string[] = []
|
||||
): Array<{ label: string; value: string }> {
|
||||
const result: Array<{ label: string; value: string }> = []
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const title = node.title ?? ""
|
||||
const nextPrefix = title ? [...prefixTitle, title] : prefixTitle
|
||||
const children = node.children ?? []
|
||||
const valueRaw = node.value
|
||||
|
||||
if (children.length > 0) {
|
||||
result.push(...flattenLeaderTree(children, nextPrefix))
|
||||
return
|
||||
}
|
||||
|
||||
if (valueRaw === undefined || valueRaw === null) return
|
||||
if (typeof valueRaw === "string" && valueRaw.includes("-")) return
|
||||
|
||||
result.push({
|
||||
label: nextPrefix.join(" / "),
|
||||
value: String(valueRaw),
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function toNumberArray(values?: string[]) {
|
||||
if (!values || values.length === 0) return undefined
|
||||
const numbers = values
|
||||
.map((v) => Number(v))
|
||||
.filter((v) => Number.isFinite(v))
|
||||
return numbers.length > 0 ? numbers : undefined
|
||||
}
|
||||
|
||||
export function weekDayText(day: number) {
|
||||
const list = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
||||
return list[day] ?? "-"
|
||||
}
|
||||
33
app/management/team/workload/LabelWorkloadTable.tsx
Normal file
33
app/management/team/workload/LabelWorkloadTable.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { getLabelStatisticsList } from "@/components/label/api/workload"
|
||||
import WorkloadMetricsTable from "./WorkloadMetricsTable"
|
||||
|
||||
export default function LabelWorkloadTable() {
|
||||
return (
|
||||
<WorkloadMetricsTable
|
||||
title="标注工时"
|
||||
sheetName="标注工时数据"
|
||||
fileName="标注工时数据.xlsx"
|
||||
fetchData={getLabelStatisticsList}
|
||||
metrics={[
|
||||
{ key: "work_time", title: "系统工时" },
|
||||
{ key: "object_size", title: "新增对象数" },
|
||||
{ key: "confirmed_prelabel_size", title: "确认预标注对象数" },
|
||||
{ key: "prelabel_object_size", title: "预标注对象数" },
|
||||
{ key: "commit_label_1", title: "提交标注任务数" },
|
||||
{ key: "commit_reviewed_1", title: "被审核1审核任务数" },
|
||||
{ key: "reviewed_size", title: "被审核对象数" },
|
||||
{ key: "commented_size", title: "被批注对象数" },
|
||||
{ key: "rejected_label_1", title: "被驳回任务数" },
|
||||
{ key: "total_reviewed_size", title: "被审核过任务对象总数" },
|
||||
{ key: "dynamic_size", title: "新增动态对象数" },
|
||||
{ key: "static_size", title: "新增静态对象数" },
|
||||
{ key: "key_frame_size", title: "新增关键帧数" },
|
||||
{ key: "question_size", title: "新增问题数" },
|
||||
{ key: "commented_question_size", title: "被批注问题数" },
|
||||
{ key: "reviewed_question_size", title: "被审核问题数" },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
35
app/management/team/workload/Review1WorkloadTable.tsx
Normal file
35
app/management/team/workload/Review1WorkloadTable.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { getReview1StatisticsList } from "@/components/label/api/workload"
|
||||
import WorkloadMetricsTable from "./WorkloadMetricsTable"
|
||||
|
||||
export default function Review1WorkloadTable() {
|
||||
return (
|
||||
<WorkloadMetricsTable
|
||||
title="审核工时"
|
||||
sheetName="审核工时数据"
|
||||
fileName="审核工时数据.xlsx"
|
||||
fetchData={getReview1StatisticsList}
|
||||
metrics={[
|
||||
{ key: "work_time", title: "系统工时" },
|
||||
{ key: "object_size", title: "新增对象数" },
|
||||
{ key: "confirmed_prelabel_size", title: "确认预标注对象数" },
|
||||
{ key: "prelabel_object_size", title: "预标注对象数" },
|
||||
{ key: "review_size", title: "审核对象数" },
|
||||
{ key: "comment_size", title: "批注对象数" },
|
||||
{ key: "reviewed_size", title: "被审核对象数" },
|
||||
{ key: "commented_size", title: "被批注对象数" },
|
||||
{ key: "commit_review_1", title: "提交审核1任务数" },
|
||||
{ key: "commit_reviewed_2", title: "被审核2审核任务数" },
|
||||
{ key: "reject_review_1", title: "审核1驳回任务数" },
|
||||
{ key: "rejected_review_1", title: "被驳回的审核1任务数" },
|
||||
{ key: "total_review_size", title: "已审核任务对象总数" },
|
||||
{ key: "review_dynamic_size", title: "审核动态对象数" },
|
||||
{ key: "review_static_size", title: "审核静态对象数" },
|
||||
{ key: "total_reviewed_size", title: "已被审核过任务对象总数" },
|
||||
{ key: "review_key_frame_size", title: "审核关键帧数" },
|
||||
{ key: "review_question_size", title: "审核问题数" },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
30
app/management/team/workload/Review2WorkloadTable.tsx
Normal file
30
app/management/team/workload/Review2WorkloadTable.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { getReview2StatisticsList } from "@/components/label/api/workload"
|
||||
import WorkloadMetricsTable from "./WorkloadMetricsTable"
|
||||
|
||||
export default function Review2WorkloadTable() {
|
||||
return (
|
||||
<WorkloadMetricsTable
|
||||
title="复审工时"
|
||||
sheetName="复审工时数据"
|
||||
fileName="复审工时数据.xlsx"
|
||||
fetchData={getReview2StatisticsList}
|
||||
metrics={[
|
||||
{ key: "work_time", title: "系统工时" },
|
||||
{ key: "object_size", title: "新增对象数" },
|
||||
{ key: "confirmed_prelabel_size", title: "确认预标注对象数" },
|
||||
{ key: "prelabel_object_size", title: "预标注对象数" },
|
||||
{ key: "review_size", title: "审核对象数" },
|
||||
{ key: "comment_size", title: "批注对象数" },
|
||||
{ key: "commit_review_2", title: "提交审核2任务数" },
|
||||
{ key: "reject_review_2", title: "审核2驳回任务数" },
|
||||
{ key: "total_review_size", title: "已审核任务对象总数" },
|
||||
{ key: "review_dynamic_size", title: "审核动态对象数" },
|
||||
{ key: "review_static_size", title: "审核静态对象数" },
|
||||
{ key: "review_key_frame_size", title: "审核关键帧数" },
|
||||
{ key: "review_question_size", title: "审核问题数" },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
480
app/management/team/workload/TaskListTable.tsx
Normal file
480
app/management/team/workload/TaskListTable.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
"use client"
|
||||
|
||||
import { getProjectList, getProjectTypeList } from "@/components/label/api/project"
|
||||
import { getTaskList } from "@/components/label/api/task"
|
||||
import { Task } from "@/components/label/api/task/typing"
|
||||
import TaskStatusTag from "@/components/label/components/TaskStatusTag"
|
||||
import { useAllUserStore } from "@/components/label/store/auth"
|
||||
import { TaskStatusEnum } from "@/components/label/utils/constants"
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Group,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { IconChevronDown, IconChevronUp, IconDownload, IconRefresh, IconSearch } from "@tabler/icons-react"
|
||||
import { DataTable, DataTableColumn } from "mantine-datatable"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import * as XLSX from "xlsx-js-style"
|
||||
import { flattenLeaderTree } from "../utils"
|
||||
|
||||
const emptyFilters = {
|
||||
page_size: 1000,
|
||||
project_type: [] as string[],
|
||||
project_id: [] as string[],
|
||||
label_status: [] as string[],
|
||||
current_uid: [] as string[],
|
||||
label_user: [] as string[],
|
||||
review_user1: [] as string[],
|
||||
review_user2: [] as string[],
|
||||
}
|
||||
|
||||
function toNumberArray(values?: string[]) {
|
||||
if (!values || values.length === 0) return undefined
|
||||
const arr = values
|
||||
.map((v) => Number(v))
|
||||
.filter((v) => Number.isFinite(v))
|
||||
return arr.length > 0 ? arr : undefined
|
||||
}
|
||||
|
||||
export default function TaskListTable() {
|
||||
const treeData = useAllUserStore((s) => s.treeData)
|
||||
const userOptions = useMemo(() => flattenLeaderTree(treeData ?? []), [treeData])
|
||||
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [projectTypeOpts, setProjectTypeOpts] = useState<Array<{ label: string; value: string }>>(
|
||||
[]
|
||||
)
|
||||
const [projectNameOpts, setProjectNameOpts] = useState<Array<{ label: string; value: string }>>(
|
||||
[]
|
||||
)
|
||||
const [filters, setFilters] = useState(emptyFilters)
|
||||
|
||||
const [records, setRecords] = useState<Task.DataProps[]>([])
|
||||
const [total, setTotal] = useState<number>(0)
|
||||
const [totalSize, setTotalSize] = useState<Task.SizeProps | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const [typesRes, projectsRes] = await Promise.allSettled([
|
||||
getProjectTypeList(),
|
||||
getProjectList({ page_number: 1, page_size: 10000 }),
|
||||
])
|
||||
|
||||
if (typesRes.status === "fulfilled") {
|
||||
setProjectTypeOpts((typesRes.value ?? []).map((t) => ({ label: t, value: t })))
|
||||
} else {
|
||||
setProjectTypeOpts([])
|
||||
}
|
||||
|
||||
if (projectsRes.status === "fulfilled") {
|
||||
const list = projectsRes.value?.project_list ?? []
|
||||
setProjectNameOpts(list.map((p) => ({ label: p.name, value: String(p.id) })))
|
||||
} else {
|
||||
setProjectNameOpts([])
|
||||
}
|
||||
}
|
||||
queueMicrotask(run)
|
||||
}, [])
|
||||
|
||||
const handleSearch = async (nextFilters = filters) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (!nextFilters.page_size) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
title: "参数不完整",
|
||||
message: "请填写展示数量",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
page_number: 1,
|
||||
page_size: nextFilters.page_size,
|
||||
get_data: false,
|
||||
}
|
||||
if (nextFilters.project_type.length > 0) payload.project_type = nextFilters.project_type
|
||||
if (nextFilters.project_id.length > 0) {
|
||||
payload.project_id = toNumberArray(nextFilters.project_id)
|
||||
}
|
||||
if (nextFilters.label_status.length > 0) {
|
||||
payload.label_status = toNumberArray(nextFilters.label_status)
|
||||
}
|
||||
if (nextFilters.current_uid.length > 0) {
|
||||
payload.current_uid = toNumberArray(nextFilters.current_uid)
|
||||
}
|
||||
if (nextFilters.label_user.length > 0) {
|
||||
payload.label_user = toNumberArray(nextFilters.label_user)
|
||||
}
|
||||
if (nextFilters.review_user1.length > 0) {
|
||||
payload.review_user1 = toNumberArray(nextFilters.review_user1)
|
||||
}
|
||||
if (nextFilters.review_user2.length > 0) {
|
||||
payload.review_user2 = toNumberArray(nextFilters.review_user2)
|
||||
}
|
||||
|
||||
const res = await getTaskList(payload)
|
||||
setRecords(res?.task_list ?? [])
|
||||
setTotal(res?.total_items ?? 0)
|
||||
setTotalSize(res?.total_size ?? null)
|
||||
} catch (e) {
|
||||
setRecords([])
|
||||
setTotal(0)
|
||||
setTotalSize(null)
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "加载失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => handleSearch(emptyFilters))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const cols: DataTableColumn<Task.DataProps>[] = [
|
||||
{ accessor: "project_name", title: "项目名称", width: 220 },
|
||||
{ accessor: "id", title: "任务ID", width: 100 },
|
||||
{
|
||||
accessor: "label_status",
|
||||
title: "状态",
|
||||
width: 160,
|
||||
render: (record) => (
|
||||
<TaskStatusTag status={record.label_status} rejected={record.rejected} />
|
||||
),
|
||||
},
|
||||
{ accessor: "current_username", title: "当前负责人", width: 120 },
|
||||
{ accessor: "label_username", title: "标注员", width: 120 },
|
||||
{ accessor: "review1_username", title: "审核员", width: 120 },
|
||||
{ accessor: "review2_username", title: "复审员", width: 120 },
|
||||
{
|
||||
accessor: "object_size",
|
||||
title: "对象总数",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.object_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "new_size",
|
||||
title: "新增对象数",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.new_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "key_frame_size",
|
||||
title: "关键帧数",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.key_frame_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "dynamic_size",
|
||||
title: "动态对象数",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.dynamic_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "static_size",
|
||||
title: "静态对象数",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.static_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "question_size",
|
||||
title: "问题数",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.question_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "review_size",
|
||||
title: "审核对象数",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.review_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "first_comment_size",
|
||||
title: "批注数(1)",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.first_comment_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "second_comment_size",
|
||||
title: "批注数(2)",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.second_comment_size ?? 0,
|
||||
},
|
||||
{ accessor: "first_commit_label_time", title: "标注首次提交时间", width: 180 },
|
||||
{ accessor: "first_commit_review1_time", title: "审核首次提交时间", width: 180 },
|
||||
{ accessor: "reject_times", title: "驳回次数", width: 100 },
|
||||
{
|
||||
accessor: "label_work_time",
|
||||
title: "标注总耗时",
|
||||
width: 120,
|
||||
render: (record) => record.work_time?.label_work_time ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "first_review_work_time",
|
||||
title: "审核总耗时",
|
||||
width: 120,
|
||||
render: (record) => record.work_time?.first_review_work_time ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "second_review_work_time",
|
||||
title: "复审总耗时",
|
||||
width: 120,
|
||||
render: (record) => record.work_time?.second_review_work_time ?? 0,
|
||||
},
|
||||
]
|
||||
return cols
|
||||
}, [])
|
||||
|
||||
const handleExport = () => {
|
||||
if (records.length === 0) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
title: "暂无数据",
|
||||
message: "当前无可导出任务数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
const header = [
|
||||
"项目名称",
|
||||
"任务ID",
|
||||
"状态",
|
||||
"当前负责人",
|
||||
"标注员",
|
||||
"审核员",
|
||||
"复审员",
|
||||
"对象总数",
|
||||
"新增对象数",
|
||||
"关键帧数",
|
||||
"动态对象数",
|
||||
"静态对象数",
|
||||
"问题数",
|
||||
"审核对象数",
|
||||
"批注数(1)",
|
||||
"批注数(2)",
|
||||
"标注首次提交时间",
|
||||
"审核首次提交时间",
|
||||
"驳回次数",
|
||||
"标注总耗时",
|
||||
"审核总耗时",
|
||||
"复审总耗时",
|
||||
]
|
||||
const rows = records.map((item) => [
|
||||
item.project_name,
|
||||
item.id,
|
||||
`${TaskStatusEnum.get(item.label_status) ?? "-"}${item.rejected ? "返工" : ""}`,
|
||||
item.current_username ?? "-",
|
||||
item.label_username ?? "-",
|
||||
item.review1_username ?? "-",
|
||||
item.review2_username ?? "-",
|
||||
item.label_object_size?.object_size ?? 0,
|
||||
item.label_object_size?.new_size ?? 0,
|
||||
item.label_object_size?.key_frame_size ?? 0,
|
||||
item.label_object_size?.dynamic_size ?? 0,
|
||||
item.label_object_size?.static_size ?? 0,
|
||||
item.label_object_size?.question_size ?? 0,
|
||||
item.label_object_size?.review_size ?? 0,
|
||||
item.label_object_size?.first_comment_size ?? 0,
|
||||
item.label_object_size?.second_comment_size ?? 0,
|
||||
item.first_commit_label_time ?? "-",
|
||||
item.first_commit_review1_time ?? "-",
|
||||
item.reject_times ?? 0,
|
||||
item.work_time?.label_work_time ?? 0,
|
||||
item.work_time?.first_review_work_time ?? 0,
|
||||
item.work_time?.second_review_work_time ?? 0,
|
||||
])
|
||||
const wb = XLSX.utils.book_new()
|
||||
const ws = XLSX.utils.aoa_to_sheet([header, ...rows])
|
||||
XLSX.utils.book_append_sheet(wb, ws, "任务列表数据")
|
||||
XLSX.writeFile(wb, "任务列表数据.xlsx")
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm">
|
||||
<Paper withBorder p="sm" radius="sm">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap="sm">
|
||||
<NumberInput
|
||||
label="展示数量"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
min={10}
|
||||
step={100}
|
||||
value={filters.page_size}
|
||||
onChange={(v) =>
|
||||
setFilters((s) => ({ ...s, page_size: Number(v) || 1000 }))
|
||||
}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
当前筛选条件下共 {records.length} / {total} 条数据
|
||||
</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
rightSection={collapsed ? <IconChevronDown size={14} /> : <IconChevronUp size={14} />}
|
||||
onClick={() => setCollapsed((v) => !v)}>
|
||||
{collapsed ? "展开筛选" : "收起筛选"}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
onClick={() => handleSearch(filters)}>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => {
|
||||
setFilters(emptyFilters)
|
||||
handleSearch(emptyFilters)
|
||||
}}>
|
||||
重置
|
||||
</Button>
|
||||
<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">
|
||||
<MultiSelect
|
||||
label="项目类型"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={projectTypeOpts}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.project_type}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, project_type: v }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="项目名称"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={projectNameOpts}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.project_id}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, project_id: v }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="任务状态"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={Array.from(TaskStatusEnum.entries()).map(([value, label]) => ({
|
||||
label,
|
||||
value: String(value),
|
||||
}))}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.label_status}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, label_status: v }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Collapse in={!collapsed}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="sm">
|
||||
<MultiSelect
|
||||
label="当前负责人"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={userOptions}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.current_uid}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, current_uid: v }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="标注员"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={userOptions}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.label_user}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, label_user: v }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="审核员"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={userOptions}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.review_user1}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, review_user1: v }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="复审员"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={userOptions}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.review_user2}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, review_user2: v }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="sm" radius="sm" style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
||||
<DataTable<Task.DataProps>
|
||||
withTableBorder
|
||||
withRowBorders
|
||||
fetching={loading}
|
||||
records={records}
|
||||
columns={columns}
|
||||
noRecordsText="暂无数据"
|
||||
rowKey="id"
|
||||
scrollAreaProps={{ type: "auto" }}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="sm" radius="sm">
|
||||
<Group wrap="wrap" gap="md">
|
||||
<Text size="sm" fw={700}>
|
||||
当前页总计
|
||||
</Text>
|
||||
<Text size="sm">对象总数:{totalSize?.object_size ?? 0}</Text>
|
||||
<Text size="sm">新增对象数:{totalSize?.new_size ?? 0}</Text>
|
||||
<Text size="sm">关键帧数:{totalSize?.key_frame_size ?? 0}</Text>
|
||||
<Text size="sm">动态对象数:{totalSize?.dynamic_size ?? 0}</Text>
|
||||
<Text size="sm">静态对象数:{totalSize?.static_size ?? 0}</Text>
|
||||
<Text size="sm">问题数:{totalSize?.question_size ?? 0}</Text>
|
||||
<Text size="sm">审核对象数:{totalSize?.review_size ?? 0}</Text>
|
||||
<Text size="sm">批注数(1):{totalSize?.first_comment_size ?? 0}</Text>
|
||||
<Text size="sm">批注数(2):{totalSize?.second_comment_size ?? 0}</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
352
app/management/team/workload/WorkloadMetricsTable.tsx
Normal file
352
app/management/team/workload/WorkloadMetricsTable.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
27
app/management/team/workload/config.ts
Normal file
27
app/management/team/workload/config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type DimensionType =
|
||||
| "all"
|
||||
| "order_by_user_name"
|
||||
| "order_by_group_name"
|
||||
| "order_by_project_name"
|
||||
| "order_by_project_type"
|
||||
|
||||
export const dimensionOpts = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "用户名", value: "order_by_user_name" },
|
||||
{ label: "用户组", value: "order_by_group_name" },
|
||||
{ label: "项目名称", value: "order_by_project_name" },
|
||||
{ label: "项目类型", value: "order_by_project_type" },
|
||||
]
|
||||
|
||||
export type MetricColumn = {
|
||||
key: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type GroupedData = {
|
||||
all: any[]
|
||||
order_by_group_name: any[]
|
||||
order_by_project_name: any[]
|
||||
order_by_project_type: any[]
|
||||
order_by_user_name: any[]
|
||||
}
|
||||
55
app/management/team/workload/page.tsx
Normal file
55
app/management/team/workload/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import { Paper, Stack, Tabs } from "@mantine/core"
|
||||
import LabelWorkloadTable from "./LabelWorkloadTable"
|
||||
import Review1WorkloadTable from "./Review1WorkloadTable"
|
||||
import Review2WorkloadTable from "./Review2WorkloadTable"
|
||||
import TaskListTable from "./TaskListTable"
|
||||
|
||||
export default function TeamWorkloadPage() {
|
||||
return (
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p="md" gap="md">
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}>
|
||||
<Tabs
|
||||
defaultValue="label"
|
||||
variant="pills"
|
||||
radius="sm"
|
||||
styles={{
|
||||
root: { height: "100%", display: "flex", flexDirection: "column" },
|
||||
panel: { flex: 1, minHeight: 0, paddingTop: 12 },
|
||||
}}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="label">标注工时</Tabs.Tab>
|
||||
<Tabs.Tab value="review1">审核工时</Tabs.Tab>
|
||||
<Tabs.Tab value="review2">复审工时</Tabs.Tab>
|
||||
<Tabs.Tab value="task">全部任务</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="label">
|
||||
<LabelWorkloadTable />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="review1">
|
||||
<Review1WorkloadTable />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="review2">
|
||||
<Review2WorkloadTable />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="task">
|
||||
<TaskListTable />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user