Files
labelmain-demo/app/person/report/page.tsx
2026-05-14 17:15:09 +08:00

830 lines
26 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,
getSelfDailyWorkList,
} from "@/components/label/api/daily"
import { Daily } from "@/components/label/api/daily/typing"
import { getSelfProjectList } from "@/components/label/api/project"
import { useAllUserStore } from "@/components/label/store/auth"
import {
SettingContentPanel,
SettingDataTable,
SettingFilterActions,
SettingFilterPanel,
SettingHeaderActions,
SettingListHeader,
SettingPage,
} from "@/components/setting/PageSurface"
import {
Badge,
Button,
Collapse,
Group,
Modal,
MultiSelect,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core"
import { modals } from "@mantine/modals"
import { notifications } from "@mantine/notifications"
import {
IconChevronDown,
IconChevronUp,
IconPlus,
IconRefresh,
IconSearch,
} from "@tabler/icons-react"
import { formatUserTreeOptions } from "@/components/tree-select/libs/util"
import { VirtualTreeSelect } from "@/components/tree-select/tree"
import { DateInput } from "@mantine/dates"
import dayjs from "dayjs"
import "dayjs/locale/zh-cn"
import duration from "dayjs/plugin/duration"
import { DataTableColumn, DataTableSortStatus } from "mantine-datatable"
import { useCallback, useEffect, useMemo, useState } from "react"
import DailyReportModal from "./components/DailyReportModal"
import {
accountTypeOpts,
actualTypeOpts,
objTypeOpts,
objTypeTextMap,
performanceOpts,
performanceTextMap,
setTypeOpts,
workTypeOpts,
} from "./config"
import classes from "./page.module.css"
dayjs.extend(duration)
function weekDayText(day: number) {
const list = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
return list[day] ?? "-"
}
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 DEFAULT_RECORDS_PER_PAGE_OPTIONS = [10, 15, 20, 50]
function InlineFilterField({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className={classes.filterField}>
<Text className={classes.filterLabel}>{label}</Text>
<div className={classes.filterControl}>{children}</div>
</div>
)
}
export default function PersonReportPage() {
const treeData = useAllUserStore((s) => s.treeData)
const leaderOptions = useMemo(
() => formatUserTreeOptions(treeData ?? []),
[treeData]
)
const defaultFilters = useMemo(() => {
return {
date_start: dayjs().subtract(6, "day").format("YYYY-MM-DD"),
date_end: dayjs().format("YYYY-MM-DD"),
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 [recordsPerPageOptions, setRecordsPerPageOptions] = useState<number[]>(
() => [...DEFAULT_RECORDS_PER_PAGE_OPTIONS]
)
const [customPageSizeInput, setCustomPageSizeInput] = useState("")
const [customPageSizeModalOpened, setCustomPageSizeModalOpened] =
useState(false)
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: 0,
date_start: appliedFilters.date_start || undefined,
date_end: appliedFilters.date_end || undefined,
set_type: appliedFilters.set_type || undefined,
actual_type: appliedFilters.actual_type || undefined,
leader_uid: appliedFilters.leader_uid.length
? appliedFilters.leader_uid
.map((v) => Number(v))
.filter((v) => Number.isFinite(v))
: undefined,
work_type: appliedFilters.work_type || undefined,
accounting_type: appliedFilters.accounting_type || undefined,
project_id: appliedFilters.project_id.length
? appliedFilters.project_id
.map((v) => Number(v))
.filter((v) => Number.isFinite(v))
: undefined,
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 getSelfDailyWorkList(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)
})
}, [])
const openCustomPageSizeModal = useCallback(() => {
setCustomPageSizeModalOpened(true)
}, [])
const closeCustomPageSizeModal = useCallback(() => {
setCustomPageSizeModalOpened(false)
setCustomPageSizeInput("")
}, [])
const handleAddCustomPageSize = useCallback(() => {
const rawValue = customPageSizeInput.trim()
if (!rawValue) return
const size = Number(rawValue)
if (!Number.isInteger(size) || size <= 0) {
notifications.show({
color: "red",
title: "输入无效",
message: "请填写大于 0 的整数条数",
})
return
}
if (recordsPerPageOptions.includes(size)) {
notifications.show({
color: "blue",
title: "已存在",
message: `每页 ${size} 条已在可选项中`,
})
return
}
setRecordsPerPageOptions((prev) => [...prev, size].sort((a, b) => a - b))
closeCustomPageSizeModal()
notifications.show({
color: "green",
title: "添加成功",
message: `已新增每页 ${size}`,
})
}, [closeCustomPageSizeModal, customPageSizeInput, recordsPerPageOptions])
const handleCustomPageSizeEnter = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key !== "Enter") return
event.preventDefault()
handleAddCustomPageSize()
},
[handleAddCustomPageSize]
)
const sum = (arr: number[]) =>
arr.reduce((accumulator, currentValue) => {
const value = Number(currentValue ?? 0)
return accumulator + (Number.isFinite(value) ? value : 0)
}, 0)
const getTime = (time: number) => {
return dayjs.duration(time, "seconds").format("HH:mm:ss")
}
const columns = useMemo(() => {
const cols: DataTableColumn<Daily.DailyWorkList>[] = [
{
accessor: "id",
title: "ID",
width: 80,
// sortable: true,
footer: "总计",
},
{
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) => record.set_type ?? "-",
},
{
accessor: "actual_type",
title: "出勤类型",
width: 120,
render: (record) => record.actual_type ?? "-",
},
{ accessor: "leader_name", title: "任务组长", width: 120 },
{ accessor: "work_type", title: "工作类别", width: 140 },
{ accessor: "accounting_type", title: "核算方式", width: 140 },
{
accessor: "project_name",
title: "项目名称",
width: 220,
render: (record) => (
<Text
size="sm"
className={classes.ellipsisText}
style={{ maxWidth: 220 }}>
{record.project_name ?? "-"}
</Text>
),
},
{
accessor: "performance_accounting",
title: "绩效核算",
width: 110,
render: (record) => {
if (record.performance_accounting === true)
return (
<Badge
className={`${classes.statusBadge} ${classes.statusPositive}`}>
{performanceTextMap.get(true)}
</Badge>
)
if (record.performance_accounting === false)
return (
<Badge
className={`${classes.statusBadge} ${classes.statusNegative}`}>
{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: "start_time", title: "开始时间", width: 120 },
{ accessor: "end_time", title: "结束时间", width: 120 },
{
accessor: "work_time",
title: "工时",
width: 90,
sortable: true,
render: (record) => (record.work_time ? getTime(record.work_time) : 0),
footer: getTime(sum(records.map((r) => r.work_time ?? 0))),
},
{ accessor: "rate", title: "额定倍率", width: 90 },
{
accessor: "standard_size",
title: "标准量",
width: 90,
sortable: true,
footer: sum(records.map((r) => r.standard_size ?? 0)).toLocaleString(),
},
{
accessor: "completed_size",
title: "完成量",
width: 90,
sortable: true,
footer: sum(records.map((r) => r.completed_size ?? 0)).toLocaleString(),
},
{
accessor: "residual_size",
title: "余量",
width: 90,
sortable: true,
footer: sum(records.map((r) => r.residual_size ?? 0)).toLocaleString(),
},
{
accessor: "residual_work_time",
title: "余量工时",
width: 110,
sortable: true,
footer: sum(
records.map((r) => r.residual_work_time ?? 0)
).toLocaleString(),
},
{
accessor: "remarks",
title: "备注",
width: 200,
render: (record) => (
<Text
size="sm"
title={record.remarks ?? "-"}
className={classes.ellipsisText}
style={{ maxWidth: 200 }}>
{record.remarks ?? "-"}
</Text>
),
},
{
accessor: "operation",
title: "操作",
width: 90,
textAlign: "center",
render: (record) => {
const disabled = !!record.lock_status
return (
<Group gap={0} justify="center" wrap="nowrap">
<div className={classes.operationGroup}>
<button
type="button"
className={`${classes.operationLink} ${
disabled ? classes.operationLinkDisabled : ""
}`}
disabled={disabled}
onClick={() => {
if (disabled) return
setEditingRecord(record)
setModalOpened(true)
}}>
</button>
<span className={classes.operationDivider} />
<button
type="button"
className={`${classes.operationLink} ${classes.operationLinkDanger} ${
disabled ? classes.operationLinkDisabled : ""
}`}
disabled={disabled}
onClick={() => {
if (disabled || !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()
},
})
}}>
</button>
</div>
</Group>
)
},
},
]
return cols
}, [load, records])
// 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])
const asyncGetSelfProjectList = useCallback(async () => {
const res = await getSelfProjectList()
setProjectOptions(
(res ?? []).map((r) => ({ label: r.label, value: String(r.value) }))
)
}, [])
useEffect(() => {
queueMicrotask(() => asyncGetSelfProjectList())
}, [asyncGetSelfProjectList])
return (
<SettingPage>
<Stack className={classes.pageIntro}>
<Text className={classes.breadcrumb}>
/ <span className={classes.breadcrumbCurrent}></span>
</Text>
</Stack>
<SettingFilterPanel>
<Stack gap={12}>
<div className={classes.filterTopRow}>
<InlineFilterField label="开始日期">
<DateInput
locale="zh-cn"
value={filters.date_start}
onChange={(e) =>
setFilters((s) => ({
...s,
date_start: dayjs(e).format("YYYY-MM-DD"),
}))
}
/>
</InlineFilterField>
<InlineFilterField label="结束日期">
<DateInput
locale="zh-cn"
value={filters.date_end}
onChange={(e) => {
setFilters((s) => ({
...s,
date_end: dayjs(e).format("YYYY-MM-DD"),
}))
}}
/>
</InlineFilterField>
<InlineFilterField label="项目名称">
<MultiSelect
data={projectOptions}
searchable
clearable
placeholder="请选择项目"
value={filters.project_id}
onChange={(v) => setFilters((s) => ({ ...s, project_id: v }))}
/>
</InlineFilterField>
<SettingFilterActions mt={0} className={classes.filterActions}>
<Button
variant="transparent"
className={classes.collapseToggle}
rightSection={
searchExpanded ? (
<IconChevronUp size={16} />
) : (
<IconChevronDown size={16} />
)
}
onClick={() => setSearchExpanded((v) => !v)}>
{searchExpanded ? "收起" : "展开"}
</Button>
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={() => {
setFilters(defaultFilters)
setAppliedFilters(defaultFilters)
setPage(1)
setQueryTrigger((v) => v + 1)
}}>
</Button>
<Button
color="#1874ff"
leftSection={<IconSearch size={16} />}
onClick={() => {
setAppliedFilters(filters)
setPage(1)
setQueryTrigger((v) => v + 1)
}}>
</Button>
</SettingFilterActions>
</div>
<Collapse in={searchExpanded} transitionDuration={150} animateOpacity>
<div className={classes.filterExpandedGrid}>
<InlineFilterField label="任务组长">
<VirtualTreeSelect
mode="multiple"
data={leaderOptions}
value={filters.leader_uid}
onChange={(v) => setFilters((s) => ({ ...s, leader_uid: v }))}
/>
</InlineFilterField>
<InlineFilterField label="规定类型">
<Select
data={setTypeOpts}
searchable
clearable
placeholder="请选择"
value={filters.set_type || null}
onChange={(value) =>
setFilters((s) => ({ ...s, set_type: value ?? "" }))
}
/>
</InlineFilterField>
<InlineFilterField label="出勤类型">
<Select
data={actualTypeOpts}
searchable
clearable
placeholder="请选择"
value={filters.actual_type || null}
onChange={(value) =>
setFilters((s) => ({ ...s, actual_type: value ?? "" }))
}
/>
</InlineFilterField>
<InlineFilterField label="工作类别">
<Select
data={workTypeOpts}
searchable
clearable
placeholder="请选择"
value={filters.work_type || null}
onChange={(value) =>
setFilters((s) => ({ ...s, work_type: value ?? "" }))
}
/>
</InlineFilterField>
<InlineFilterField label="核算方式">
<Select
data={accountTypeOpts}
searchable
clearable
placeholder="请选择"
value={filters.accounting_type || null}
onChange={(value) =>
setFilters((s) => ({
...s,
accounting_type: value ?? "",
}))
}
/>
</InlineFilterField>
<InlineFilterField label="绩效核算">
<Select
data={performanceOpts}
searchable
clearable
placeholder="请选择"
value={filters.performance_accounting || null}
onChange={(value) =>
setFilters((s) => ({
...s,
performance_accounting: value ?? "",
}))
}
/>
</InlineFilterField>
<InlineFilterField label="对象类型">
<Select
data={objTypeOpts}
searchable
clearable
placeholder="请选择"
value={filters.obj_type || null}
onChange={(value) =>
setFilters((s) => ({ ...s, obj_type: value ?? "" }))
}
/>
</InlineFilterField>
</div>
</Collapse>
</Stack>
</SettingFilterPanel>
<SettingContentPanel
style={{
flex: 1,
minHeight: 0,
padding: 0,
display: "flex",
flexDirection: "column",
}}>
<div className={classes.contentPanelBody}>
<div className={classes.tableActionBar}>
<SettingListHeader
title="日报列表"
count={`${totalItems} 条记录`}
actions={
<SettingHeaderActions>
<Button
color="#1874ff"
leftSection={<IconPlus size={16} />}
onClick={() => {
setEditingRecord(null)
setModalOpened(true)
}}>
</Button>
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={load}
loading={loading}>
</Button>
</SettingHeaderActions>
}
/>
</div>
<Stack gap={12} align="stretch" style={{ flex: 1, minHeight: 0 }}>
<SettingDataTable<Daily.DailyWorkList>
width="100%"
minHeight={0}
style={{ width: "100%", flex: 1 }}
pinFirstColumn
pinLastColumn
scrollAreaProps={{ type: "auto" }}
fetching={loading}
records={records}
columns={columns}
totalRecords={totalItems}
recordsPerPage={pageSize}
page={page}
paginationText={({ from, to, totalRecords }) =>
`显示 ${from}-${to} 条,共 ${totalRecords} 条记录`
}
onPageChange={setPage}
onRecordsPerPageChange={(v) => {
setPageSize(v)
setPage(1)
}}
recordsPerPageOptions={recordsPerPageOptions}
sortStatus={sortStatus}
onSortStatusChange={(s) => {
setSortStatus(s)
setPage(1)
}}
renderPagination={({ Controls }) => (
<Group
justify="space-between"
align="center"
w="100%"
gap="sm"
wrap="wrap">
<Group gap="xs" align="center" wrap="wrap">
<Controls.Text />
<Controls.PageSizeSelector />
<Button
variant="default"
size="sm"
onClick={openCustomPageSizeModal}>
</Button>
</Group>
<Controls.Pagination />
</Group>
)}
/>
{/* <SettingPanel radius="md" 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>
</SettingPanel> */}
</Stack>
</div>
</SettingContentPanel>
<DailyReportModal
opened={modalOpened}
record={editingRecord}
projectOptions={projectOptions}
onCloseAction={(refresh) => {
setModalOpened(false)
setEditingRecord(null)
if (refresh) load()
}}
/>
<Modal
opened={customPageSizeModalOpened}
onClose={closeCustomPageSizeModal}
title="添加每页条数"
centered>
<Stack gap="sm">
<TextInput
data-autofocus
label="每页条数"
placeholder="请输入大于 0 的整数,例如 99"
value={customPageSizeInput}
onChange={(event) =>
setCustomPageSizeInput(event.currentTarget.value)
}
onKeyDown={handleCustomPageSizeEnter}
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeCustomPageSizeModal}>
</Button>
<Button color="#1874ff" onClick={handleAddCustomPageSize}>
</Button>
</Group>
</Stack>
</Modal>
</SettingPage>
)
}