Files
labelmain-demo/app/management/team/dailypaper/page.tsx
2026-02-28 15:43:50 +08:00

776 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import {
deleteDailyWorkData,
getTeamDailyWorkList,
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 getTeamDailyWorkList(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>
)
}