diff --git a/app/management/team/board/page.tsx b/app/management/team/board/page.tsx new file mode 100644 index 0000000..74a0f55 --- /dev/null +++ b/app/management/team/board/page.tsx @@ -0,0 +1,26 @@ +"use client" + +import { Center, Paper, Stack, Text } from "@mantine/core" + +export default function TeamBoardPage() { + return ( +
+ + + 项目参与 + + 该页面已迁移至团队管理模块,后续可在此继续扩展业务视图。 + + + +
+ ) +} diff --git a/app/management/team/cost/page.tsx b/app/management/team/cost/page.tsx new file mode 100644 index 0000000..a707664 --- /dev/null +++ b/app/management/team/cost/page.tsx @@ -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([]) + 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[] = [ + { 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) => ( + { + 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 : "请求失败", + }) + } + }}> + + + ), + }, + ] + return cols + }, [isShow]) + + return ( + + + + + + + + + + + 导出记录 + + + + + + + withTableBorder + withRowBorders + fetching={loading} + records={records} + columns={columns} + noRecordsText="暂无数据" + scrollAreaProps={{ type: "auto" }} + style={{ width: "100%" }} + /> + + + ) +} diff --git a/app/management/team/dailypaper/page.tsx b/app/management/team/dailypaper/page.tsx index c8c76c8..0911345 100644 --- a/app/management/team/dailypaper/page.tsx +++ b/app/management/team/dailypaper/page.tsx @@ -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([]) + const [summary, setSummary] = useState(null) + + const [sortStatus, setSortStatus] = useState({ + columnAccessor: "id", + direction: "desc", + }) + + const [modalOpened, setModalOpened] = useState(false) + const [editingRecord, setEditingRecord] = useState(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[] = [ + { 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) => ( + + {record.project_name ?? "-"} + + ), + }, + { + accessor: "performance_accounting", + title: "绩效核算", + width: 110, + render: (record) => { + if (record.performance_accounting === true) { + return {performanceTextMap.get(true)} + } + if (record.performance_accounting === false) { + return {performanceTextMap.get(false)} + } + 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) => ( + + {record.remarks ?? "-"} + + ), + }, + { + accessor: "operation", + title: "操作", + width: 120, + textAlign: "center", + render: (record) => { + const locked = !!record.lock_status + return ( + + {isShow("edit") ? ( + { + if (locked) return + setEditingRecord(record) + setModalOpened(true) + }}> + + + ) : null} + {isShow("lock") ? ( + { + if (!record.id) return + await lockDailyWorkData(!locked, record.id) + notifications.show({ + color: "green", + title: "操作成功", + message: locked ? "已解锁日报" : "已锁定日报", + }) + load() + }}> + {locked ? : } + + ) : null} + {isShow("delete") ? ( + { + if (locked || !record.id) return + modals.openConfirmModal({ + title: "删除日报", + centered: true, + children: ( + 删除后不可恢复,你确定删除该日报吗? + ), + labels: { confirm: "删除", cancel: "取消" }, + confirmProps: { color: "red" }, + onConfirm: async () => { + await deleteDailyWorkData(record.id as number) + notifications.show({ + color: "green", + title: "操作成功", + message: "已删除日报", + }) + load() + }, + }) + }}> + + + ) : null} + + ) + }, + }, + ] + 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 ( + + + + + + 搜索条件 + + + + + + + + + + + setFilters((s) => ({ ...s, date_start: e.target.value }))} + /> + setFilters((s) => ({ ...s, date_end: e.target.value }))} + /> + setFilters((s) => ({ ...s, uid: v }))} + /> + setFilters((s) => ({ ...s, project_id: v }))} + /> + setFilters((s) => ({ ...s, leader_uid: v }))} + /> + setFilters((s) => ({ ...s, set_type: v[0] ?? "" }))} + /> + setFilters((s) => ({ ...s, actual_type: v[0] ?? "" }))} + /> + setFilters((s) => ({ ...s, work_type: v[0] ?? "" }))} + /> + + setFilters((s) => ({ ...s, accounting_type: v[0] ?? "" })) + } + /> + + setFilters((s) => ({ ...s, performance_accounting: v[0] ?? "" })) + } + /> + setFilters((s) => ({ ...s, obj_type: v[0] ?? "" }))} + /> + + + + + + + + + + + + + + + + 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) + }} + /> + + + + + 当前页总计 + + + 工时:{summaryText.total_work_time} + 标准量:{summaryText.total_standard_size} + 完成量:{summaryText.total_completed_size} + 余量:{summaryText.total_residual_size} + + 余量工时:{summaryText.total_residual_work_time} + + + + + + + + { + setModalOpened(false) + setEditingRecord(null) + if (refresh) load() + }} + /> + + ) } diff --git a/app/management/team/employee/components/CreateUserModal.tsx b/app/management/team/employee/components/CreateUserModal.tsx new file mode 100644 index 0000000..ed04943 --- /dev/null +++ b/app/management/team/employee/components/CreateUserModal.tsx @@ -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>( + [] + ) + 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 ( + onCloseAction()} + title="新增用户" + centered + size={560} + closeOnClickOutside={false}> +
+ + + + + + + + + + + + + + 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%" }} + /> + + + { + setOpen(false) + setEditingRecord(null) + setEditingLocation("") + }} + title="更新同步服务所在地" + centered + size={520} + closeOnClickOutside={false}> + + setEditingLocation(e.currentTarget.value)} + /> + + + + + + + + ) +} diff --git a/app/management/team/sync_task/page.tsx b/app/management/team/sync_task/page.tsx new file mode 100644 index 0000000..8f1e28d --- /dev/null +++ b/app/management/team/sync_task/page.tsx @@ -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([ + [1, "待同步"], + [2, "同步中"], + [3, "同步失败"], + [4, "同步成功"], + [5, "待删除"], + [6, "删除中"], + [7, "删除失败"], + [8, "删除完成"], + [9, "等待暂停"], + [10, "已暂停"], + [11, "等待关闭"], + [12, "已关闭"], +]) + +export default function TeamSyncTaskPage() { + const { isShow } = useAuth() + + const [records, setRecords] = useState([]) + const [loading, setLoading] = useState(false) + const [totalItems, setTotalItems] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [status, setStatus] = useState(null) + const [projectId, setProjectId] = useState(null) + const [serverId, setServerId] = useState(null) + const [appliedStatus, setAppliedStatus] = useState(null) + const [appliedProjectId, setAppliedProjectId] = useState(null) + const [appliedServerId, setAppliedServerId] = useState(null) + + const [projectOpt, setProjectOpt] = useState>([]) + const [serverAllOpt, setServerAllOpt] = useState>([]) + const [serverOnlineOpt, setServerOnlineOpt] = useState>( + [] + ) + const [serverMap, setServerMap] = useState>(new Map()) + + const [open, setOpen] = useState(false) + const [newProjectId, setNewProjectId] = useState(null) + const [newServerId, setNewServerId] = useState(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[] = [ + { 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 ( + + {couldRun ? ( + + performStatusAction(record.id as number, 1, "已恢复同步") + }> + + + ) : null} + {couldStop ? ( + { + modals.openConfirmModal({ + title: "暂停任务", + centered: true, + children: ( + 是否确定暂停当前同步任务? + ), + labels: { confirm: "确定", cancel: "取消" }, + onConfirm: () => + performStatusAction(record.id as number, 9, "已暂停任务"), + }) + }}> + + + ) : null} + {couldDelete ? ( + { + modals.openConfirmModal({ + title: "删除任务", + centered: true, + children: ( + 是否确定删除当前同步任务? + ), + labels: { confirm: "确定", cancel: "取消" }, + confirmProps: { color: "red" }, + onConfirm: () => + performStatusAction(record.id as number, 5, "已提交删除"), + }) + }}> + + + ) : null} + {couldClose ? ( + { + modals.openConfirmModal({ + title: "关闭任务", + centered: true, + children: ( + 是否确定关闭当前同步任务? + ), + labels: { confirm: "确定", cancel: "取消" }, + onConfirm: () => + performStatusAction(record.id as number, 11, "已关闭任务"), + }) + }}> + + + ) : null} + + ) + }, + }, + ] + 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 ( + + + + + + + setDimension((v as DimensionType) || "all")} + /> + + + + + + setProjectName(e.currentTarget.value)} + /> + setProjectType(e.currentTarget.value)} + /> + setUserName(e.currentTarget.value)} + /> + setGroupName(e.currentTarget.value)} + /> + + + + + + withTableBorder + withRowBorders + fetching={loading} + records={sortedRecords} + columns={columns} + noRecordsText="暂无数据" + rowKey={rowKey as any} + scrollAreaProps={{ type: "auto" }} + style={{ width: "100%" }} + sortStatus={sortStatus} + onSortStatusChange={setSortStatus} + /> + + + + + + 总计 + + {totals.map((item) => ( + + {item.title}:{item.value} + + ))} + + + + ) +} diff --git a/app/management/team/workload/config.ts b/app/management/team/workload/config.ts new file mode 100644 index 0000000..607093e --- /dev/null +++ b/app/management/team/workload/config.ts @@ -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[] +} diff --git a/app/management/team/workload/page.tsx b/app/management/team/workload/page.tsx new file mode 100644 index 0000000..e4c8ea5 --- /dev/null +++ b/app/management/team/workload/page.tsx @@ -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 ( + + + + + 标注工时 + 审核工时 + 复审工时 + 全部任务 + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/label/api/sync/index.ts b/components/label/api/sync/index.ts new file mode 100644 index 0000000..9736922 --- /dev/null +++ b/components/label/api/sync/index.ts @@ -0,0 +1,65 @@ +import httpFetch from "@/api/fetch" +import { BASE_LABEL_API } from "../const" +import { Params, Response } from "./typing" + +// 获取同步服务列表 +export const getDataSyncServerList = (params: Params.ServerRequest) => { + return httpFetch({ + url: BASE_LABEL_API + "/api/v1/label_server/data_sync/list", + method: "GET", + data: params, + }) +} + +// 更新同步服务所在地 +export const updateSyncServerLocation = (location: string, id: number) => { + return httpFetch({ + url: BASE_LABEL_API + `/api/v1/label_server/data_sync/update/${id}`, + method: "PUT", + body: JSON.stringify({ location }), + }) +} + +// 获取所有同步服务 +export const getAllSyncServerList = () => { + return httpFetch< + { + id: number + ip: string + is_online: 0 | 1 + }[] + >({ + url: BASE_LABEL_API + "/api/v1/label_server/data_sync/all", + method: "GET", + }) +} + +// 获取同步任务列表 +export const getDataSyncTaskList = (params: Params.TaskRequest) => { + return httpFetch({ + url: BASE_LABEL_API + "/api/v1/label_server/data_sync/sync_task_list", + method: "GET", + data: params, + }) +} + +// 新增同步任务 +export const newSyncTask = (data: { + project_id: number + sync_server_id: number +}) => { + return httpFetch({ + url: BASE_LABEL_API + "/api/v1/label_server/data_sync/add_sync_task", + method: "POST", + body: JSON.stringify(data), + }) +} + +// 更新同步任务状态 +export const updateSyncTaskStatus = (id: number, status: number) => { + return httpFetch({ + url: BASE_LABEL_API + `/api/v1/label_server/data_sync/update_sync_task/${id}`, + method: "PUT", + body: JSON.stringify({ status }), + }) +} diff --git a/components/label/api/sync/typing.ts b/components/label/api/sync/typing.ts new file mode 100644 index 0000000..1bc9ea6 --- /dev/null +++ b/components/label/api/sync/typing.ts @@ -0,0 +1,64 @@ +export interface TimeFilter { + start_date: string + end_date: string +} + +export namespace Params { + export interface ServerRequest { + is_online?: boolean + location?: string + page_number: number + page_size: number + [property: string]: any + } + + export interface TaskRequest { + location?: string + page_number: number + page_size: number + project_id?: number + status?: number + sync_server_id?: number + [property: string]: any + } +} + +export namespace Response { + export interface SyncServerRes { + sync_servers: SyncServer[] + total_items: number + total_pages: number + [property: string]: any + } + + export interface SyncServer { + create_at?: string + id?: number + ip?: string + is_online?: boolean + location?: string + update_at?: string + [property: string]: any + } + + export interface SyncTaskRes { + sync_tasks: SyncTask[] + total_items: number + total_pages: number + [property: string]: any + } + + export interface SyncTask { + create_at?: string + id?: number + ip?: string + location?: string + project_id?: number + project_name?: string + status?: number + sync_server_id?: number + update_at?: string + uuid?: string + [property: string]: any + } +} diff --git a/components/label/api/user/index.ts b/components/label/api/user/index.ts index f1a1467..beea05e 100644 --- a/components/label/api/user/index.ts +++ b/components/label/api/user/index.ts @@ -1,15 +1,72 @@ import httpFetch from "@/api/fetch" import { BASE_LABEL_API } from "../const" -import { Organization } from "./typing" +import { Organization, User } from "./typing" + +// 获取用户列表 +export const getUserList = (params: User.ListRequest) => { + return httpFetch({ + url: BASE_LABEL_API + "/api/v1/label_server/user/list", + method: "GET", + data: params, + }) +} + +// 新增用户 +export const userAdd = (data: User.CreateProps) => { + return httpFetch({ + url: BASE_LABEL_API + "/api/v1/label_server/user/add", + method: "POST", + body: JSON.stringify(data), + }) +} + +// 批量导入用户 +export const userImport = (data: { user_infos: any[] }) => { + return httpFetch({ + url: BASE_LABEL_API + "/api/v1/label_server/user/batch_add", + method: "POST", + body: JSON.stringify(data), + }) +} + +// 编辑用户 +export const userEditById = (data: User.EditProps, id: number) => { + return httpFetch({ + url: BASE_LABEL_API + `/api/v1/label_server/user/edit/${id}`, + method: "PUT", + body: JSON.stringify(data), + }) +} // 获取用户组织树 export const getUserGroupTree = () => { return httpFetch({ - url: "/api/v1/label_server/user/group/tree", + url: BASE_LABEL_API + "/api/v1/label_server/user/group/tree", method: "GET", }) } +// 新增用户组 +export const userGroupAdd = (data: Organization.CreateProps) => { + return httpFetch({ + url: BASE_LABEL_API + "/api/v1/label_server/user/group/add", + method: "POST", + body: JSON.stringify(data), + }) +} + +// 修改用户密码 +export const modifyUserPassword = (data: { + old_password: string + new_password: string +}) => { + return httpFetch({ + url: BASE_LABEL_API + "/api/v1/label_server/user/modify_password", + method: "PUT", + body: JSON.stringify(data), + }) +} + /* ****************************** Options ****************************** */ interface Option { label: string diff --git a/components/label/api/user/typing.ts b/components/label/api/user/typing.ts index 23ca1c3..a9a29c2 100644 --- a/components/label/api/user/typing.ts +++ b/components/label/api/user/typing.ts @@ -1,4 +1,53 @@ +export namespace User { + export interface UserInfo { + uid: number + name: string + all_projects: { [x: number]: number[] } + collect_projects: number[] + group_name: string + work_status: number + base_city: string + } + + export interface LoginInfo extends UserInfo { + token: string + refresh_token: string + } + + export interface ListRequest { + name?: string + page_number?: number + page_size?: number + } + + export interface ListResponse { + user_list: UserInfo[] + total_pages: number + total_items: number + } + + export interface CreateProps { + user_name: string + group_uuid: string + base_city: string + } + + export interface EditProps { + user_name?: string + password?: string + group_uuid?: string + base_city?: string + work_status?: number + } +} + export namespace Organization { + export interface CreateProps { + group_name: string + leader_uid: number + parent_group_uuid: string + } + interface Member { base_city: string id: number diff --git a/components/label/api/workload/index.ts b/components/label/api/workload/index.ts new file mode 100644 index 0000000..7ed353f --- /dev/null +++ b/components/label/api/workload/index.ts @@ -0,0 +1,65 @@ +import httpFetch from "@/api/fetch" +import { BASE_LABEL_API } from "../const" +import { TimeFilter, WorkLoad } from "./typing" + +// 标注工作量统计 +export const getLabelStatisticsList = (params: TimeFilter) => { + return httpFetch({ + url: BASE_LABEL_API + "/api/v1/label_server/workload/label_statistics/list", + method: "GET", + data: params, + }) +} + +// 审核1工作量统计 +export const getReview1StatisticsList = (params: TimeFilter) => { + return httpFetch({ + url: + BASE_LABEL_API + "/api/v1/label_server/workload/review1_statistics/list", + method: "GET", + data: params, + }) +} + +// 审核2工作量统计 +export const getReview2StatisticsList = (params: TimeFilter) => { + return httpFetch({ + url: + BASE_LABEL_API + "/api/v1/label_server/workload/review2_statistics/list", + method: "GET", + data: params, + }) +} + +// 结算表格列表 +export const getDownloadLog = () => { + return httpFetch({ + url: BASE_LABEL_API + "/api/v1/label_server/workload/settlement_form/list", + method: "GET", + }) +} + +// 导出结算表格 +export const exportSettlementForm = (params: { + year: number + month: number +}) => { + return httpFetch({ + url: + BASE_LABEL_API + "/api/v1/label_server/workload/settlement_form/export", + method: "GET", + data: params, + isDownload: true, + }) +} + +// 下载结算表格 +export const downloadSettlementForm = (params: { id: number }) => { + return httpFetch({ + url: + BASE_LABEL_API + "/api/v1/label_server/workload/settlement_form/download", + method: "GET", + data: params, + isDownload: true, + }) +} diff --git a/components/label/api/workload/typing.ts b/components/label/api/workload/typing.ts new file mode 100644 index 0000000..b5e0767 --- /dev/null +++ b/components/label/api/workload/typing.ts @@ -0,0 +1,107 @@ +export interface TimeFilter { + start_date: string + end_date: string +} + +export namespace WorkLoad { + export interface LabelData { + commented_question_size: number + commented_size: number + commit_label_1: number + commit_reviewed_1: number + confirmed_prelabel_size: number + dynamic_size: number + group_name?: string + key_frame_size: number + object_size: number + prelabel_object_size: number + project_id: number + project_name: string + project_type: string + question_size: number + rejected_label_1: number + reviewed_question_size: number + reviewed_size: number + static_size: number + total_reviewed_size: number + uid: number + user_name: string + work_time: number + [property: string]: any + } + + export interface LabelResponse { + all: LabelData[] + order_by_group_name: any[] + order_by_project_name: any[] + order_by_project_type: any[] + order_by_user_name: any[] + } + + export interface Review1Data { + comment_size: number + commented_size: number + commit_review_1: number + commit_reviewed_2: number + confirmed_prelabel_size: number + group_name?: string + object_size: number + prelabel_object_size: number + project_id: number + project_name: string + project_type: string + reject_review_1: number + rejected_review_1: number + review_dynamic_size: number + review_key_frame_size: number + review_question_size: number + review_size: number + review_static_size: number + reviewed_size: number + total_review_size: number + total_reviewed_size: number + uid: number + user_name: string + work_time: number + [property: string]: any + } + + export interface Review1Response { + all: Review1Data[] + order_by_group_name: any[] + order_by_project_name: any[] + order_by_project_type: any[] + order_by_user_name: any[] + } + + export interface Review2Data { + comment_size: number + commit_review_2: number + confirmed_prelabel_size: number + group_name?: string + object_size: number + prelabel_object_size: number + project_id: number + project_name: string + project_type: string + reject_review_2: number + review_dynamic_size: number + review_key_frame_size: number + review_question_size: number + review_size: number + review_static_size: number + total_review_size: number + uid: number + user_name: string + work_time: number + [property: string]: any + } + + export interface Review2Response { + all: Review2Data[] + order_by_group_name: any[] + order_by_project_name: any[] + order_by_project_type: any[] + order_by_user_name: any[] + } +} diff --git a/components/label/hooks/useAuth.ts b/components/label/hooks/useAuth.ts new file mode 100644 index 0000000..eb500f8 --- /dev/null +++ b/components/label/hooks/useAuth.ts @@ -0,0 +1,120 @@ +"use client" + +import { usePermissionStore } from "@/components/label/store/auth" +import { usePathname } from "next/navigation" + +interface PermissionItem { + [x: string]: [boolean, null, string] +} + +type PermissionMap = { + [x: string]: PermissionItem +} + +const mappingList: { [x: string]: [string, string] } = { + "/management/team/employee": ["user", "view"], + "/management/team/organization": ["user", "group_view"], + "/management/team/dailypaper": ["daily_work", "team_view"], + "/management/team/cost": ["settlement_form", "view"], + "/management/team/workload": ["workload", "view"], + "/management/team/sync_server": ["data_sync", "server_view"], + "/management/team/sync_task": ["data_sync", "task_view"], + "/management/team/board": ["user", "view"], +} + +export const headerBarCheckItems = (data: PermissionMap) => { + const projectTitle = [ + ["daily_work", "self_view"], + ["label_schema", "view"], + ["project", "view"], + ["task", "view"], + ] as const + + let projectVisible = false + projectTitle.forEach(([prop, subProp]) => { + if (data[prop] && data[prop][subProp] && data[prop][subProp][0]) { + projectVisible = true + } + }) + + const staffTitle = [ + ["daily_work", "team_view"], + ["user", "view"], + ["user", "group_view"], + ] as const + + let staffVisible = false + staffTitle.forEach(([prop, subProp]) => { + if (data[prop] && data[prop][subProp] && data[prop][subProp][0]) { + staffVisible = true + } + }) + + return [projectVisible, staffVisible] +} + +const useAuth = () => { + const pathname = usePathname() + const detailInfo = usePermissionStore((s) => s.detailInfo) + const permission = detailInfo?.permissions as PermissionMap | undefined + const roleName: string[] = detailInfo?.role_name ?? [] + + return { + hasPermission: () => { + if ( + [ + "/", + "/login", + "/management", + "/management/person/dashboard", + "/management/team", + ].includes(pathname) + ) { + return true + } + + if (!permission) return false + if (mappingList[pathname]) { + const [prop, subProp] = mappingList[pathname] + return ( + !!permission[prop] && + !!permission[prop][subProp] && + !!permission[prop][subProp][0] + ) + } + return false + }, + // path 跳转是否可见 + canNv: (path: string, type = "view") => { + if (!permission) return false + if (mappingList[path]) { + const [prop] = mappingList[path] + return ( + !!permission[prop] && + !!permission[prop][type] && + !!permission[prop][type][0] + ) + } + return false + }, + // 当前页面是否可见某个操作 + isShow: (type = "view") => { + if (!permission) return false + if (mappingList[pathname]) { + const [prop] = mappingList[pathname] + return ( + !!permission[prop] && + !!permission[prop][type] && + !!permission[prop][type][0] + ) + } + return false + }, + checkRole: (name: string) => { + if (!name) return false + return roleName.includes(name) + }, + } +} + +export default useAuth diff --git a/components/layout/common.ts b/components/layout/common.ts index f1ea507..5252ea1 100644 --- a/components/layout/common.ts +++ b/components/layout/common.ts @@ -72,6 +72,26 @@ export const componentList: MenuItem[] = [ title: "团队日报", url: "dailypaper", }, + { + title: "数据统计", + url: "workload", + }, + { + title: "结算管理", + url: "cost", + }, + { + title: "同步服务", + url: "sync_server", + }, + { + title: "同步任务", + url: "sync_task", + }, + { + title: "项目参与", + url: "board", + }, ], }, ],