feat(employee-hours): sync row copy and time entry improvements
This commit is contained in:
parent
eb6fa9d672
commit
f6b8065580
@ -0,0 +1,18 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { assertEmployeeHoursAccess } from '@/server/services/employee-hours-access.service'
|
||||
import { employeeHoursRecordService } from '@/server/services/employee-hours-record.service'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const scope = await assertEmployeeHoursAccess(ctx.user, 'edit')
|
||||
const body: unknown = await request.json().catch(() => null)
|
||||
const id = body && typeof body === 'object' && !Array.isArray(body)
|
||||
&& typeof (body as { id?: unknown }).id === 'string'
|
||||
? (body as { id: string }).id.trim()
|
||||
: ''
|
||||
if (!id) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要复制的员工工时记录', 400, ctx.requestId)
|
||||
return jsonSuccess({ record: await employeeHoursRecordService.copy(ctx.user, id, scope) }, 201, ctx.requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -42,6 +42,11 @@ beforeEach(() => {
|
||||
totals = { minutes: 0, hours: 0, estimatedScore: 0, approvedScore: 0 }
|
||||
mockFetch.mockReset().mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === 'DELETE') return reply({ deletedCount: 1 })
|
||||
if (String(input).endsWith('/records/copy')) {
|
||||
const { id } = JSON.parse(String(init?.body)) as { id: string }
|
||||
const source = records.find((item) => item.id === id)!
|
||||
return reply({ record: { ...source, id: 'hours-copy' } })
|
||||
}
|
||||
if (String(input).includes('/records?')) {
|
||||
const page: EmployeeHoursRecordPage = {
|
||||
records,
|
||||
@ -97,12 +102,92 @@ describe('员工工时业务操作列', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('点击第二行复制,立即在原行下新增相同内容,并可单独编辑新行', async () => {
|
||||
const second = { ...record, id: 'hours-2', businessContent: '业务乙', businessNote: '沿用处理说明', attachment: { name: '旧附件.pdf', url: '/api/storage/object?key=old', mimeType: 'application/pdf', size: 100 } }
|
||||
records = [record, second]
|
||||
employees = [{ id: 'owner-config-1', nickname: '花名甲', department: '运营部' }]
|
||||
config.rules = [{ id: 'rule-2', businessContent: '业务乙', businessCategory: '日常', workFrequency: '每日', initiativeType: '主动', approvedScore: 1, sortOrder: 0 }]
|
||||
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" accessScope="SELF" />)
|
||||
await screen.findByText('业务乙')
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制 花名甲 业务乙' }))
|
||||
await waitFor(() => expect(screen.getAllByText('业务乙')).toHaveLength(2))
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/supply-chain/management/employee-hours/records/copy', expect.objectContaining({ method: 'POST', body: JSON.stringify({ id: 'hours-2' }) }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByText('业务甲')).toHaveLength(1)
|
||||
const rows = screen.getAllByRole('button', { name: '复制 花名甲 业务乙' }).map((button) => button.closest('tr')!)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].nextElementSibling).toBe(rows[1])
|
||||
expect(rows[1].querySelectorAll('td')).toHaveLength(rows[0].querySelectorAll('td').length)
|
||||
expect(rows[1].textContent).toBe(rows[0].textContent)
|
||||
expect(screen.getByText('共 3 条')).toBeInTheDocument()
|
||||
fireEvent.click(within(rows[1]).getByRole('button', { name: '编辑 花名甲 业务乙' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '编辑员工工时' })
|
||||
expect(within(dialog).getByRole('combobox', { name: '业务内容' })).toHaveValue('业务乙')
|
||||
expect(within(dialog).getByText('业务内容补充说明').parentElement!.querySelector('textarea')).toHaveValue('沿用处理说明')
|
||||
expect(within(dialog).getByText('旧附件.pdf')).toBeInTheDocument()
|
||||
expect(dialog.querySelector<HTMLInputElement>('input[type="date"]')).toHaveValue(second.date)
|
||||
expect(within(dialog).getByLabelText('开始时间日期')).toHaveValue(second.date)
|
||||
expect(within(dialog).getByRole('textbox', { name: '开始时间' })).toHaveValue('09:00')
|
||||
})
|
||||
|
||||
it('部门账号不能复制他人记录;全公司权限复制时沿用原记录的人员', async () => {
|
||||
records = [{ ...record, nickname: '花名乙', department: '客服部' }]
|
||||
employees = [{ id: 'owner-config-2', nickname: '花名乙', department: '客服部' }]
|
||||
config.rules = [{ id: 'rule-1', businessContent: '业务甲', businessCategory: '日常', workFrequency: '每日', initiativeType: '主动', approvedScore: 1, sortOrder: 0 }]
|
||||
const { unmount } = render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" accessScope="DEPARTMENT" />)
|
||||
await screen.findByText('花名乙')
|
||||
expect(screen.queryByRole('button', { name: '复制 花名乙 业务甲' })).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="管理员" accessScope="ALL" />)
|
||||
const row = (await screen.findByText('花名乙')).closest('tr')!
|
||||
fireEvent.click(within(row).getByRole('button', { name: '复制 花名乙 业务甲' }))
|
||||
await waitFor(() => expect(screen.getAllByText('花名乙')).toHaveLength(2))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('旧业务配置已失效也可直接复制原始快照', async () => {
|
||||
records = [record]
|
||||
employees = [{ id: 'owner-config-1', nickname: '花名甲', department: '运营部' }]
|
||||
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
|
||||
const row = (await screen.findByText('花名甲')).closest('tr')!
|
||||
fireEvent.click(within(row).getByRole('button', { name: '复制 花名甲 业务甲' }))
|
||||
await waitFor(() => expect(screen.getAllByText('花名甲')).toHaveLength(2))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('原行在当前页末尾时也立即显示新增副本', async () => {
|
||||
records = Array.from({ length: 50 }, (_, index) => ({
|
||||
...record, id: `hours-${index}`, businessContent: `业务-${index}`,
|
||||
}))
|
||||
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
|
||||
await screen.findByText('业务-49')
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制 花名甲 业务-49' }))
|
||||
await waitFor(() => expect(screen.getAllByText('业务-49')).toHaveLength(2))
|
||||
expect(screen.getByText('显示 1 - 51 条,共 51 条')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('复制请求失败时不会增加虚假的本地记录', async () => {
|
||||
records = [record]
|
||||
const defaultFetch = mockFetch.getMockImplementation()!
|
||||
mockFetch.mockImplementation((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
String(input).endsWith('/records/copy')
|
||||
? Promise.resolve({ ok: false, json: async () => ({ success: false, error: { message: '复制失败,请重试' } }) })
|
||||
: defaultFetch(input, init))
|
||||
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
|
||||
await screen.findByText('业务甲')
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制 花名甲 业务甲' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('复制失败,请重试')
|
||||
expect(screen.getAllByText('业务甲')).toHaveLength(1)
|
||||
expect(screen.getByText('共 1 条')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('只读用户不显示业务操作列', async () => {
|
||||
records = [record]
|
||||
render(<EmployeeHoursHoursView canEdit={false} canDownload={false} />)
|
||||
await screen.findByText('花名甲')
|
||||
expect(screen.queryByRole('columnheader', { name: /业务操作/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /新增员工工时|编辑 花名甲|删除 花名甲/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /新增员工工时|复制 花名甲|编辑 花名甲|删除 花名甲/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('根据登录账号自动带出花名和人员配置部门', async () => {
|
||||
@ -115,6 +200,74 @@ describe('员工工时业务操作列', () => {
|
||||
expect(screen.getByRole('textbox', { name: '部门' })).toHaveValue('运营部')
|
||||
})
|
||||
|
||||
it('开始与结束时间覆盖午休时即时预览扣减后的分钟、小时与预估积分', async () => {
|
||||
employees = [{ id: 'owner-config-1', nickname: '花名甲', department: '运营部' }]
|
||||
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
|
||||
await screen.findByText('暂无员工工时记录')
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增员工工时' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '新增员工工时' })
|
||||
fireEvent.change(dialog.querySelector<HTMLInputElement>('input[type="date"]')!, { target: { value: '2026-09-29' } })
|
||||
const startInput = within(dialog).getByRole('textbox', { name: '开始时间' })
|
||||
const endInput = within(dialog).getByRole('textbox', { name: '结束时间' })
|
||||
fireEvent.change(startInput, { target: { value: '900' } })
|
||||
fireEvent.blur(startInput)
|
||||
fireEvent.change(endInput, { target: { value: '18:00' } })
|
||||
expect(startInput).toHaveValue('09:00')
|
||||
expect(within(dialog).getByLabelText('开始时间日期')).toHaveValue('2026-09-29')
|
||||
const preview = (label: string) => within(dialog).getByText(label).parentElement!.querySelector('input')
|
||||
expect(preview('分钟')).toHaveValue('480')
|
||||
expect(preview('小时')).toHaveValue('8.00')
|
||||
expect(preview('预估积分')).toHaveValue('4.1600')
|
||||
})
|
||||
|
||||
it('可直接输入 24 小时制时间,并分别设置跨天日期;格式错误时阻止保存', async () => {
|
||||
employees = [{ id: 'owner-config-1', nickname: '花名甲', department: '运营部' }]
|
||||
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
|
||||
await screen.findByText('暂无员工工时记录')
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增员工工时' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '新增员工工时' })
|
||||
fireEvent.change(dialog.querySelector<HTMLInputElement>('input[type="date"]')!, { target: { value: '2026-09-29' } })
|
||||
const start = within(dialog).getByRole('textbox', { name: '开始时间' })
|
||||
const end = within(dialog).getByRole('textbox', { name: '结束时间' })
|
||||
fireEvent.change(start, { target: { value: '2330' } })
|
||||
fireEvent.blur(start)
|
||||
fireEvent.change(within(dialog).getByLabelText('结束时间日期'), { target: { value: '2026-09-30' } })
|
||||
fireEvent.change(end, { target: { value: '1:00' } })
|
||||
fireEvent.blur(end)
|
||||
expect(start).toHaveValue('23:30')
|
||||
expect(end).toHaveValue('01:00')
|
||||
expect(within(dialog).getByText('分钟').parentElement!.querySelector('input')).toHaveValue('90')
|
||||
fireEvent.change(end, { target: { value: '25:00' } })
|
||||
expect(end).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(within(dialog).getByText(/请输入有效时间/)).toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('button', { name: '保存' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('快捷输入规范化后按原接口格式保存,并在调整工作日期时更新同日时间', async () => {
|
||||
employees = [{ id: 'owner-config-1', nickname: '花名甲', department: '运营部' }]
|
||||
config.rules = [{ id: 'rule-1', businessContent: '业务甲', businessCategory: '日常', workFrequency: '每日', initiativeType: '主动', approvedScore: 1, sortOrder: 0 }]
|
||||
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
|
||||
await screen.findByText('暂无员工工时记录')
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增员工工时' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '新增员工工时' })
|
||||
fireEvent.focus(within(dialog).getByRole('combobox', { name: '业务内容' }))
|
||||
fireEvent.click(within(screen.getByRole('listbox', { name: '业务内容选项' })).getByRole('option'))
|
||||
const start = within(dialog).getByRole('textbox', { name: '开始时间' })
|
||||
const end = within(dialog).getByRole('textbox', { name: '结束时间' })
|
||||
fireEvent.change(start, { target: { value: '930' } })
|
||||
fireEvent.blur(start)
|
||||
fireEvent.change(end, { target: { value: '10:30' } })
|
||||
fireEvent.change(dialog.querySelector<HTMLInputElement>('input[type="date"]')!, { target: { value: '2026-09-29' } })
|
||||
expect(within(dialog).getByLabelText('开始时间日期')).toHaveValue('2026-09-29')
|
||||
expect(within(dialog).getByLabelText('结束时间日期')).toHaveValue('2026-09-29')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(mockFetch).toHaveBeenCalledWith('/api/supply-chain/management/employee-hours/records', expect.objectContaining({ method: 'POST' })))
|
||||
const call = mockFetch.mock.calls.find(([url, init]) => url === '/api/supply-chain/management/employee-hours/records' && init?.method === 'POST')!
|
||||
expect(JSON.parse(call[1].body).record).toMatchObject({
|
||||
date: '2026-09-29', startTime: '2026-09-29T09:30', endTime: '2026-09-29T10:30',
|
||||
})
|
||||
})
|
||||
|
||||
it('业务内容下拉框支持向下向右调整尺寸,并完整展示长选项', async () => {
|
||||
config.rules = [{
|
||||
id: 'rule-long',
|
||||
@ -139,6 +292,56 @@ describe('员工工时业务操作列', () => {
|
||||
expect(within(listbox).getByText(/较长的业务分类说明/)).toHaveClass('whitespace-normal', 'break-words')
|
||||
})
|
||||
|
||||
it('搜索只匹配一个业务内容时,转去填写时间自动确认选项且不会清空', async () => {
|
||||
config.rules = [
|
||||
{ id: 'rule-1688', businessContent: '1688异常订单核实处理:无法操作商品映射', businessCategory: '其他日常-异常处理类', workFrequency: '日度', initiativeType: '提前规划', approvedScore: 1, sortOrder: 0 },
|
||||
{ id: 'rule-other', businessContent: '其他日常事务', businessCategory: '其他日常-其他', workFrequency: '日度', initiativeType: '提前规划', approvedScore: 1, sortOrder: 1 },
|
||||
]
|
||||
employees = [{ id: 'owner-config-1', nickname: '花名甲', department: '运营部' }]
|
||||
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
|
||||
await screen.findByText('暂无员工工时记录')
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增员工工时' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '新增员工工时' })
|
||||
const business = within(dialog).getByRole('combobox', { name: '业务内容' })
|
||||
const start = within(dialog).getByRole('textbox', { name: '开始时间' })
|
||||
fireEvent.focus(business)
|
||||
fireEvent.change(business, { target: { value: '1688异常订单核实处理' } })
|
||||
expect(within(screen.getByRole('listbox', { name: '业务内容选项' })).getAllByRole('option')).toHaveLength(1)
|
||||
fireEvent.blur(business, { relatedTarget: start })
|
||||
fireEvent.focus(start)
|
||||
fireEvent.change(start, { target: { value: '900' } })
|
||||
fireEvent.blur(start)
|
||||
expect(business).toHaveValue('1688异常订单核实处理:无法操作商品映射')
|
||||
expect(within(dialog).getByText('业务分类').parentElement!.querySelector('input')).toHaveValue('其他日常-异常处理类')
|
||||
expect(within(dialog).getByText('业务来源').parentElement!.querySelector('input')).toHaveValue('提前规划')
|
||||
expect(within(dialog).queryByText(/请从下拉列表选择业务内容/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('多个业务内容匹配时保留关键词,明确选择后填写日期时间仍保持选项', async () => {
|
||||
config.rules = [
|
||||
{ id: 'rule-a', businessContent: '订单核实 A', businessCategory: '日常', workFrequency: '日度', initiativeType: '主动', approvedScore: 1, sortOrder: 0 },
|
||||
{ id: 'rule-b', businessContent: '订单核实 B', businessCategory: '日常', workFrequency: '日度', initiativeType: '主动', approvedScore: 1, sortOrder: 1 },
|
||||
]
|
||||
render(<EmployeeHoursHoursView canEdit canDownload={false} />)
|
||||
await screen.findByText('暂无员工工时记录')
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增员工工时' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '新增员工工时' })
|
||||
const business = within(dialog).getByRole('combobox', { name: '业务内容' })
|
||||
const start = within(dialog).getByRole('textbox', { name: '开始时间' })
|
||||
fireEvent.focus(business)
|
||||
fireEvent.change(business, { target: { value: '订单核实' } })
|
||||
fireEvent.blur(business, { relatedTarget: start })
|
||||
expect(business).toHaveValue('订单核实')
|
||||
expect(within(dialog).getByRole('status')).toHaveTextContent('找到 2 项,请从下拉列表选择业务内容')
|
||||
fireEvent.focus(business)
|
||||
fireEvent.click(within(screen.getByRole('listbox', { name: '业务内容选项' })).getByRole('option', { name: /订单核实 B/ }))
|
||||
fireEvent.blur(business, { relatedTarget: start })
|
||||
fireEvent.change(start, { target: { value: '10:00' } })
|
||||
fireEvent.blur(start)
|
||||
expect(business).toHaveValue('订单核实 B')
|
||||
expect(within(dialog).getByText('业务分类').parentElement!.querySelector('input')).toHaveValue('日常')
|
||||
})
|
||||
|
||||
it('将操作列放在最左侧,并固定业务分类之前的四个字段', async () => {
|
||||
records = [record]
|
||||
render(<EmployeeHoursHoursView canEdit canDownload={false} />)
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
BarChart3,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Download,
|
||||
GripHorizontal,
|
||||
Paperclip,
|
||||
@ -28,6 +29,7 @@ import type {
|
||||
EmployeeHoursRanking,
|
||||
EmployeeHoursRankingMetric,
|
||||
} from '@/lib/employee-hours'
|
||||
import { calculateEmployeeHoursDuration } from '@/lib/employee-hours'
|
||||
import { EmployeeHoursView } from './EmployeeHoursView'
|
||||
import { EmployeeHoursPermissionButton } from './EmployeeHoursAccessPanels'
|
||||
|
||||
@ -102,16 +104,6 @@ function formatDateTime(value: string) {
|
||||
return match ? `${Number(match[1])}/${Number(match[2])}/${Number(match[3])} ${match[4]}` : value
|
||||
}
|
||||
|
||||
function calculateDuration(startTime: string, endTime: string) {
|
||||
if (!startTime || !endTime) return { minutes: null, hours: null, estimatedScore: null }
|
||||
const start = new Date(startTime).getTime()
|
||||
const end = new Date(endTime).getTime()
|
||||
const minutes = Math.round((end - start) / 60_000)
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) return { minutes: null, hours: null, estimatedScore: null }
|
||||
const hours = Number((minutes / 60).toFixed(2))
|
||||
return { minutes, hours, estimatedScore: Number((hours * 0.52).toFixed(4)) }
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
@ -166,6 +158,48 @@ function FormField({ label, children }: { label: string; children: React.ReactNo
|
||||
|
||||
const INPUT_CLASS = 'mt-1 h-9 w-full rounded-[5px] border border-zinc-300 bg-white px-2 text-sm text-zinc-900 outline-none focus:border-zinc-800 disabled:bg-zinc-50 disabled:text-zinc-500'
|
||||
const READONLY_CLASS = 'mt-1 h-9 w-full rounded-[5px] border border-zinc-200 bg-zinc-50 px-2 text-sm text-zinc-600 outline-none'
|
||||
const COMPLETE_DATE_TIME = /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d$/
|
||||
|
||||
function normalizeClock(value: string) {
|
||||
const raw = value.trim()
|
||||
const colon = /^(\d{1,2}):(\d{1,2})$/.exec(raw)
|
||||
const digits = /^\d{1,4}$/.test(raw) ? raw : ''
|
||||
if (!colon && !digits) return null
|
||||
const hour = Number(colon ? colon[1] : digits.length <= 2 ? digits : digits.slice(0, -2))
|
||||
const minute = Number(colon ? colon[2] : digits.length <= 2 ? 0 : digits.slice(-2))
|
||||
if (hour > 23 || minute > 59) return null
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function DateTimeField({ label, defaultDate, value, onChange }: {
|
||||
label: string
|
||||
defaultDate: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
const [dateOverride, setDateOverride] = useState<string | null>(null)
|
||||
const [savedDate = '', time = ''] = value.split('T')
|
||||
const selectedDate = (value ? savedDate : dateOverride) || defaultDate
|
||||
const invalidTime = time !== '' && !COMPLETE_DATE_TIME.test(`${selectedDate}T${time}`)
|
||||
|
||||
return <FormField label={label}>
|
||||
<div className="mt-1 flex min-w-0 gap-1">
|
||||
<input type="date" aria-label={`${label}日期`} value={selectedDate} onChange={(event) => {
|
||||
const nextDate = event.target.value
|
||||
setDateOverride(nextDate)
|
||||
onChange(time && nextDate ? `${nextDate}T${time}` : '')
|
||||
}} className={`${INPUT_CLASS} mt-0 min-w-0 flex-[1.4] px-1.5 text-xs`} />
|
||||
<input type="text" aria-label={label} inputMode="numeric" autoComplete="off" maxLength={5} placeholder="时:分" value={time} onChange={(event) => {
|
||||
const nextTime = event.target.value.trim()
|
||||
onChange(nextTime && selectedDate ? `${selectedDate}T${nextTime}` : '')
|
||||
}} onBlur={() => {
|
||||
const normalized = normalizeClock(time)
|
||||
if (normalized && selectedDate) onChange(`${selectedDate}T${normalized}`)
|
||||
}} aria-invalid={invalidTime} className={`${INPUT_CLASS} mt-0 min-w-0 flex-1 text-center tabular-nums`} />
|
||||
</div>
|
||||
{invalidTime ? <span className="mt-1 block text-[11px] text-amber-700">请输入有效时间,如 09:30(24 小时制)</span> : null}
|
||||
</FormField>
|
||||
}
|
||||
|
||||
type SearchableSelectOption = {
|
||||
value: string
|
||||
@ -215,7 +249,13 @@ function SearchableSelect({
|
||||
<div
|
||||
className="relative"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false)
|
||||
if (event.currentTarget.contains(event.relatedTarget)) return
|
||||
if (!value && normalizedQuery) {
|
||||
const exactOptions = filteredOptions.filter((option) => option.label.trim().toLocaleLowerCase('zh-CN') === normalizedQuery)
|
||||
const match = exactOptions.length === 1 ? exactOptions[0] : filteredOptions.length === 1 ? filteredOptions[0] : null
|
||||
if (match) choose(match)
|
||||
}
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="relative mt-1">
|
||||
@ -226,11 +266,11 @@ function SearchableSelect({
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={open}
|
||||
aria-activedescendant={open && filteredOptions[currentActiveIndex] ? `${listboxId}-${filteredOptions[currentActiveIndex].value}` : undefined}
|
||||
value={open ? query : selectedLabel}
|
||||
value={value && !open ? selectedLabel || query : query}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
onFocus={() => {
|
||||
setQuery(selectedLabel)
|
||||
if (value) setQuery(selectedLabel)
|
||||
setOpen(true)
|
||||
setActiveIndex(0)
|
||||
}}
|
||||
@ -252,6 +292,7 @@ function SearchableSelect({
|
||||
event.preventDefault()
|
||||
choose(filteredOptions[currentActiveIndex])
|
||||
} else if (event.key === 'Escape') {
|
||||
setQuery(selectedLabel)
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
@ -263,7 +304,7 @@ function SearchableSelect({
|
||||
tabIndex={-1}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setQuery(selectedLabel)
|
||||
if (value) setQuery(selectedLabel)
|
||||
setActiveIndex(0)
|
||||
setOpen((current) => !current)
|
||||
}}
|
||||
@ -301,6 +342,7 @@ function SearchableSelect({
|
||||
{!filteredOptions.length ? <div className="px-3 py-3 text-xs text-zinc-400">{emptyMessage}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!open && !value && normalizedQuery ? <span className="mt-1 block text-[11px] text-amber-700" role="status">{filteredOptions.length ? `找到 ${filteredOptions.length} 项,请从下拉列表选择业务内容` : emptyMessage}</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -383,7 +425,7 @@ export function EmployeeHoursHoursView({
|
||||
)
|
||||
const selectedRule = rules.find((rule) => rule.id === draft.scoreRuleId) ?? null
|
||||
const matchedEmployee: EmployeeHoursEmployee | undefined = employees.find((employee) => employee.id === draft.employeeId)
|
||||
const duration = calculateDuration(draft.startTime, draft.endTime)
|
||||
const duration = calculateEmployeeHoursDuration(draft.startTime, draft.endTime)
|
||||
const total = recordPage?.total ?? 0
|
||||
const totals = recordPage?.totals
|
||||
const summaryValues: Record<string, string> = {
|
||||
@ -488,13 +530,60 @@ export function EmployeeHoursHoursView({
|
||||
setNotice('')
|
||||
}
|
||||
|
||||
function openEdit(record: EmployeeHoursRecord) {
|
||||
const rule = rules.find((value) => value.businessContent === record.businessContent
|
||||
function matchingRule(record: EmployeeHoursRecord) {
|
||||
return rules.find((value) => value.businessContent === record.businessContent
|
||||
&& value.businessCategory === record.businessCategory
|
||||
&& value.workFrequency === record.workFrequency
|
||||
&& value.initiativeType === record.businessSource)
|
||||
const employee = employees.find((value) => value.nickname === record.nickname && value.department === record.department)
|
||||
}
|
||||
|
||||
function matchingEmployee(record: EmployeeHoursRecord) {
|
||||
return employees.find((value) => value.nickname === record.nickname && value.department === record.department)
|
||||
?? employees.find((value) => value.nickname === record.nickname)
|
||||
}
|
||||
|
||||
async function copyRecord(record: EmployeeHoursRecord) {
|
||||
if (!canEdit || saving || (accessScope !== 'ALL' && record.nickname !== currentUserName)) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
const { record: copied } = await readResponse<{ record: EmployeeHoursRecord }>(await fetch('/api/supply-chain/management/employee-hours/records/copy', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: record.id }),
|
||||
}))
|
||||
setRecordPage((current) => {
|
||||
if (!current) return current
|
||||
const sourceIndex = current.records.findIndex((row) => row.id === record.id)
|
||||
if (sourceIndex < 0) return current
|
||||
const nextRecords = [...current.records]
|
||||
nextRecords.splice(sourceIndex + 1, 0, copied)
|
||||
return {
|
||||
...current,
|
||||
// 即使原行是当前页最后一行,也要在它下方立即展示副本。
|
||||
records: nextRecords,
|
||||
total: current.total + 1,
|
||||
totals: {
|
||||
minutes: current.totals.minutes + copied.minutes,
|
||||
hours: current.totals.hours + copied.hours,
|
||||
estimatedScore: current.totals.estimatedScore + copied.estimatedScore,
|
||||
approvedScore: current.totals.approvedScore + (copied.approvedScore ?? 0),
|
||||
},
|
||||
}
|
||||
})
|
||||
setNotice('已复制为新记录,可在新行点击“编辑”修改')
|
||||
if (rankingOpen) await loadRanking(rankingMetric)
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '员工工时记录复制失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(record: EmployeeHoursRecord) {
|
||||
const rule = matchingRule(record)
|
||||
const employee = matchingEmployee(record)
|
||||
setEditingId(record.id)
|
||||
setDraft({
|
||||
date: record.date,
|
||||
@ -671,7 +760,7 @@ export function EmployeeHoursHoursView({
|
||||
<tbody>
|
||||
{records.map((record) => (
|
||||
<tr key={record.id} className="group h-10 border-b border-zinc-100 last:border-0 hover:bg-zinc-50/70">
|
||||
{canEdit ? <td className="sticky left-0 z-10 border-r border-zinc-200 bg-white px-2 text-center shadow-[3px_0_6px_-4px_rgba(0,0,0,0.2)] group-hover:bg-zinc-50">{accessScope === 'ALL' || record.nickname === currentUserName ? <div className="flex items-center justify-center gap-1"><button type="button" aria-label={`编辑 ${record.nickname} ${record.businessContent}`} title="编辑" disabled={saving} onClick={() => openEdit(record)} className="inline-flex size-7 items-center justify-center rounded-[4px] text-zinc-500 hover:bg-zinc-100 hover:text-zinc-800 disabled:cursor-not-allowed disabled:opacity-40"><Pencil size={14} /></button><button type="button" aria-label={`删除 ${record.nickname} ${record.businessContent}`} title="删除" disabled={saving} onClick={() => void deleteRecord(record)} className="inline-flex size-7 items-center justify-center rounded-[4px] text-zinc-500 hover:bg-red-50 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-40"><Trash2 size={14} /></button></div> : null}</td> : null}
|
||||
{canEdit ? <td className="sticky left-0 z-10 border-r border-zinc-200 bg-white px-2 text-center shadow-[3px_0_6px_-4px_rgba(0,0,0,0.2)] group-hover:bg-zinc-50">{accessScope === 'ALL' || record.nickname === currentUserName ? <div className="flex items-center justify-center gap-1"><button type="button" aria-label={`复制 ${record.nickname} ${record.businessContent}`} title="复制并新增一行" disabled={saving} onClick={() => void copyRecord(record)} className="inline-flex size-7 items-center justify-center rounded-[4px] text-zinc-500 hover:bg-zinc-100 hover:text-zinc-800 disabled:cursor-not-allowed disabled:opacity-40"><Copy size={14} /></button><button type="button" aria-label={`编辑 ${record.nickname} ${record.businessContent}`} title="编辑" disabled={saving} onClick={() => openEdit(record)} className="inline-flex size-7 items-center justify-center rounded-[4px] text-zinc-500 hover:bg-zinc-100 hover:text-zinc-800 disabled:cursor-not-allowed disabled:opacity-40"><Pencil size={14} /></button><button type="button" aria-label={`删除 ${record.nickname} ${record.businessContent}`} title="删除" disabled={saving} onClick={() => void deleteRecord(record)} className="inline-flex size-7 items-center justify-center rounded-[4px] text-zinc-500 hover:bg-red-50 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-40"><Trash2 size={14} /></button></div> : null}</td> : null}
|
||||
{HOURS_COLUMNS.map((column, index) => {
|
||||
const value = column.key === 'attachment'
|
||||
? record.attachment?.name ?? ''
|
||||
@ -697,7 +786,7 @@ export function EmployeeHoursHoursView({
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 items-center justify-between px-1 py-1 text-[11px] text-zinc-400">
|
||||
<span>显示 {total === 0 ? 0 : (page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} 条,共 {total} 条</span>
|
||||
<span>显示 {total === 0 ? 0 : (page - 1) * pageSize + 1} - {Math.min((page - 1) * pageSize + records.length, total)} 条,共 {total} 条</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" aria-label="上一页" disabled={page <= 1 || loading} onClick={() => setPage((value) => Math.max(1, value - 1))} className="h-7 rounded-[4px] border border-zinc-200 bg-white px-2 text-zinc-500 disabled:text-zinc-300">‹</button>
|
||||
<span className="inline-flex h-7 min-w-7 items-center justify-center rounded-[4px] bg-zinc-800 px-2 text-white">{page} / {totalPages}</span>
|
||||
@ -712,24 +801,29 @@ export function EmployeeHoursHoursView({
|
||||
<section role="dialog" aria-modal="true" aria-labelledby="employee-hours-editor-title" className="max-h-[92vh] w-full max-w-5xl overflow-auto rounded-[9px] bg-white shadow-xl">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-zinc-200 bg-white px-5 py-4"><div><h2 id="employee-hours-editor-title" className="text-base font-semibold text-zinc-900">{editingId ? '编辑员工工时' : '新增员工工时'}</h2><p className="mt-1 text-xs text-zinc-500">分类、频率、来源和核定积分由业务内容配置自动匹配;时长与预估积分自动计算。</p></div><button type="button" aria-label="关闭" onClick={closeEditor} className="inline-flex size-8 items-center justify-center rounded border border-zinc-200 text-zinc-500 hover:bg-zinc-50"><X size={15} /></button></div>
|
||||
<div className="grid gap-x-4 gap-y-3 p-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<FormField label="日期"><input type="date" value={draft.date} onChange={(event) => setDraft((value) => ({ ...value, date: event.target.value }))} className={INPUT_CLASS} /></FormField>
|
||||
<FormField label="日期"><input type="date" value={draft.date} onChange={(event) => setDraft((value) => {
|
||||
const nextDate = event.target.value
|
||||
const followWorkDate = (time: string) => time.startsWith(`${value.date}T`) && nextDate
|
||||
? `${nextDate}${time.slice(value.date.length)}` : time
|
||||
return { ...value, date: nextDate, startTime: followWorkDate(value.startTime), endTime: followWorkDate(value.endTime) }
|
||||
})} className={INPUT_CLASS} /></FormField>
|
||||
<FormField label="花名"><input aria-label="花名" readOnly value={draft.nickname || currentUserName} placeholder="根据登录账号自动带出" className={READONLY_CLASS} />{!draft.employeeId ? <span className="mt-1 block text-[11px] text-amber-700">当前登录账号未在人员信息配置中维护花名。</span> : null}</FormField>
|
||||
<FormField label="业务内容"><SearchableSelect ariaLabel="业务内容" listboxId="employee-hours-business-options" value={draft.scoreRuleId} options={ruleOptions} placeholder="输入业务内容或分类关键词" emptyMessage="未找到匹配业务内容" onChange={(scoreRuleId) => setDraft((value) => ({ ...value, scoreRuleId }))} />{!rules.length ? <span className="mt-1 block text-[11px] text-amber-700">请先进入“业务配置”新增业务内容规则。</span> : null}</FormField>
|
||||
<FormField label="业务分类"><input readOnly value={selectedRule?.businessCategory ?? ''} className={READONLY_CLASS} /></FormField>
|
||||
<FormField label="工作频率"><input readOnly value={selectedRule?.workFrequency ?? ''} className={READONLY_CLASS} /></FormField>
|
||||
<FormField label="业务来源"><input readOnly value={selectedRule?.initiativeType ?? ''} className={READONLY_CLASS} /></FormField>
|
||||
<FormField label="开始时间"><input type="datetime-local" value={draft.startTime} onChange={(event) => setDraft((value) => ({ ...value, startTime: event.target.value }))} className={INPUT_CLASS} /></FormField>
|
||||
<FormField label="结束时间"><input type="datetime-local" value={draft.endTime} onChange={(event) => setDraft((value) => ({ ...value, endTime: event.target.value }))} className={INPUT_CLASS} /></FormField>
|
||||
<FormField label="分钟"><input readOnly value={duration.minutes ?? ''} className={READONLY_CLASS} /></FormField>
|
||||
<FormField label="小时"><input readOnly value={duration.hours === null ? '' : duration.hours.toFixed(2)} className={READONLY_CLASS} /></FormField>
|
||||
<FormField label="预估积分"><input readOnly value={duration.estimatedScore === null ? '' : duration.estimatedScore.toFixed(4)} className={READONLY_CLASS} /></FormField>
|
||||
<DateTimeField label="开始时间" defaultDate={draft.date} value={draft.startTime} onChange={(startTime) => setDraft((value) => ({ ...value, startTime }))} />
|
||||
<DateTimeField label="结束时间" defaultDate={draft.date} value={draft.endTime} onChange={(endTime) => setDraft((value) => ({ ...value, endTime }))} />
|
||||
<FormField label="分钟"><input readOnly value={duration?.minutes ?? ''} className={READONLY_CLASS} /></FormField>
|
||||
<FormField label="小时"><input readOnly value={duration ? duration.hours.toFixed(2) : ''} className={READONLY_CLASS} /></FormField>
|
||||
<FormField label="预估积分"><input readOnly value={duration ? duration.estimatedScore.toFixed(4) : ''} className={READONLY_CLASS} /></FormField>
|
||||
<FormField label="核定积分"><input readOnly value={selectedRule?.approvedScore === null || selectedRule?.approvedScore === undefined ? '' : selectedRule.approvedScore} className={READONLY_CLASS} /></FormField>
|
||||
<FormField label="部门"><input aria-label="部门" readOnly value={matchedEmployee?.department ?? ''} placeholder={draft.employeeId ? '该员工尚未在人员信息配置中维护部门' : '选择花名后自动带出'} className={READONLY_CLASS} /></FormField>
|
||||
<FormField label="业务内容补充说明"><textarea value={draft.businessNote} onChange={(event) => setDraft((value) => ({ ...value, businessNote: event.target.value }))} rows={3} className="mt-1 min-h-20 w-full resize-y rounded-[5px] border border-zinc-300 bg-white px-2 py-2 text-sm text-zinc-900 outline-none focus:border-zinc-800 lg:col-span-3" /></FormField>
|
||||
<FormField label="附件"><div className="mt-1 flex min-h-20 flex-col justify-between gap-2 rounded-[5px] border border-dashed border-zinc-300 bg-zinc-50 p-2"><input ref={attachmentRef} type="file" accept="image/*,.pdf,.txt,.csv,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip" className="hidden" onChange={(event) => { const file = event.target.files?.[0]; if (file) void uploadAttachment(file) }} />{draft.attachment ? <a href={draft.attachment.url} target="_blank" rel="noreferrer" title={draft.attachment.name} className="flex min-w-0 items-center gap-1 text-xs text-zinc-700 hover:text-zinc-950"><Paperclip size={13} className="shrink-0" /><span className="truncate">{draft.attachment.name}</span></a> : <span className="text-[11px] text-zinc-400">非必填,可上传文件或图片</span>}<div className="flex gap-1"><button type="button" disabled={uploadingAttachment || saving} onClick={() => attachmentRef.current?.click()} className="inline-flex h-7 items-center gap-1 rounded border border-zinc-300 bg-white px-2 text-[11px] text-zinc-700 hover:bg-zinc-100 disabled:opacity-50"><Paperclip size={12} />{uploadingAttachment ? '上传中…' : draft.attachment ? '更换' : '上传'}</button>{draft.attachment ? <button type="button" disabled={uploadingAttachment || saving} onClick={() => setDraft((value) => ({ ...value, attachment: null }))} className="h-7 rounded border border-red-200 bg-white px-2 text-[11px] text-red-600 hover:bg-red-50 disabled:opacity-50">移除</button> : null}</div></div></FormField>
|
||||
</div>
|
||||
{draft.startTime && draft.endTime && !duration.minutes ? <div className="mx-5 mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">结束时间必须晚于开始时间,请检查填写。</div> : null}
|
||||
<div className="flex justify-between gap-2 border-t border-zinc-200 bg-zinc-50 px-5 py-3"><span className="self-center text-[11px] text-zinc-500">工时小时数保留两位小数;预估积分按小时 × 0.52 自动计算。</span><div className="flex gap-2"><button type="button" disabled={saving || uploadingAttachment} onClick={closeEditor} className="h-8 rounded border border-zinc-300 bg-white px-3 text-xs text-zinc-600 hover:bg-zinc-50 disabled:opacity-50">取消</button><button type="button" disabled={saving || uploadingAttachment || !draft.date || !draft.employeeId || !matchedEmployee?.department || !draft.scoreRuleId || !duration.minutes} onClick={() => void saveRecord()} className="inline-flex h-8 items-center gap-1.5 rounded bg-zinc-900 px-3 text-xs font-medium text-white hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"><Check size={14} />{saving ? '保存中…' : '保存'}</button></div></div>
|
||||
{COMPLETE_DATE_TIME.test(draft.startTime) && COMPLETE_DATE_TIME.test(draft.endTime) && !duration ? <div className="mx-5 mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">结束时间必须晚于开始时间,请检查填写。</div> : null}
|
||||
<div className="flex justify-between gap-2 border-t border-zinc-200 bg-zinc-50 px-5 py-3"><span className="self-center text-[11px] text-zinc-500">完整覆盖 12:00–13:00 时,分钟扣 60、小时扣 1;预估积分按扣减后的小时 × 0.52。</span><div className="flex gap-2"><button type="button" disabled={saving || uploadingAttachment} onClick={closeEditor} className="h-8 rounded border border-zinc-300 bg-white px-3 text-xs text-zinc-600 hover:bg-zinc-50 disabled:opacity-50">取消</button><button type="button" disabled={saving || uploadingAttachment || !draft.date || !draft.employeeId || !matchedEmployee?.department || !draft.scoreRuleId || !duration} onClick={() => void saveRecord()} className="inline-flex h-8 items-center gap-1.5 rounded bg-zinc-900 px-3 text-xs font-medium text-white hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"><Check size={14} />{saving ? '保存中…' : '保存'}</button></div></div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
21
furniture-3d-system/src/lib/employee-hours.test.ts
Normal file
21
furniture-3d-system/src/lib/employee-hours.test.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { calculateEmployeeHoursDuration } from './employee-hours'
|
||||
|
||||
describe('员工工时午休扣减', () => {
|
||||
it.each([
|
||||
['2026-09-29T09:00', '2026-09-29T18:00', 540, 480, 8, 4.16, 1],
|
||||
['2026-09-29T12:00', '2026-09-29T13:00', 60, 0, 0, 0, 1],
|
||||
['2026-09-29T11:30', '2026-09-29T12:30', 60, 60, 1, 0.52, 0],
|
||||
['2026-09-29T12:30', '2026-09-29T13:30', 60, 60, 1, 0.52, 0],
|
||||
['2026-09-29T13:00', '2026-09-29T14:00', 60, 60, 1, 0.52, 0],
|
||||
['2026-09-29T11:00', '2026-09-29T13:00', 120, 60, 1, 0.52, 1],
|
||||
['2026-09-29T11:00', '2026-09-30T13:00', 1560, 1440, 24, 12.48, 2],
|
||||
])('%s 到 %s 的分钟和小时独立扣减', (start, end, rawMinutes, minutes, hours, estimatedScore, lunchDays) => {
|
||||
expect(calculateEmployeeHoursDuration(start, end)).toEqual({ rawMinutes, minutes, hours, estimatedScore, lunchDays })
|
||||
})
|
||||
|
||||
it('开始时间不早于结束时间或输入非法时不计算', () => {
|
||||
expect(calculateEmployeeHoursDuration('2026-09-29T13:00', '2026-09-29T12:00')).toBeNull()
|
||||
expect(calculateEmployeeHoursDuration('2026-09-29T11:00', '2026-09-29T11:00')).toBeNull()
|
||||
expect(calculateEmployeeHoursDuration('2026-02-30T11:00', '2026-02-30T13:00')).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -145,3 +145,44 @@ export const EMPLOYEE_HOURS_IMPORT_HEADERS = [
|
||||
'业务来源',
|
||||
'核定积分',
|
||||
] as const
|
||||
|
||||
const MINUTE_MS = 60_000
|
||||
const DAY_MS = 24 * 60 * MINUTE_MS
|
||||
const LUNCH_START_MINUTE = 12 * 60
|
||||
const LUNCH_DURATION_MS = 60 * MINUTE_MS
|
||||
const DATE_TIME_INPUT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
|
||||
|
||||
function wallClockMilliseconds(value: string) {
|
||||
const match = DATE_TIME_INPUT.exec(value)
|
||||
if (!match) return null
|
||||
const [, year, month, day, hour, minute] = match.map(Number)
|
||||
const milliseconds = Date.UTC(year, month - 1, day, hour, minute)
|
||||
const parsed = new Date(milliseconds)
|
||||
if (
|
||||
parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1
|
||||
|| parsed.getUTCDate() !== day || parsed.getUTCHours() !== hour || parsed.getUTCMinutes() !== minute
|
||||
) return null
|
||||
return milliseconds
|
||||
}
|
||||
|
||||
/** 页面预览和保存共用的工时口径;分钟、小时按业务要求分别扣减午休。 */
|
||||
export function calculateEmployeeHoursDuration(startTime: string, endTime: string) {
|
||||
const start = wallClockMilliseconds(startTime)
|
||||
const end = wallClockMilliseconds(endTime)
|
||||
if (start === null || end === null || end <= start) return null
|
||||
|
||||
const rawMinutes = Math.round((end - start) / MINUTE_MS)
|
||||
const startDay = Math.floor(start / DAY_MS) * DAY_MS
|
||||
const firstLunchStart = startDay + LUNCH_START_MINUTE * MINUTE_MS + (start > startDay + LUNCH_START_MINUTE * MINUTE_MS ? DAY_MS : 0)
|
||||
// 完整跨越 12:00–13:00 才扣减;跨日记录逐日计算,部分重叠不扣。
|
||||
const lunchDays = Math.max(0, Math.floor((end - LUNCH_DURATION_MS - firstLunchStart) / DAY_MS) + 1)
|
||||
const minutes = Math.max(0, rawMinutes - lunchDays * 60)
|
||||
const hours = Math.max(0, Number((rawMinutes / 60 - lunchDays).toFixed(2)))
|
||||
return {
|
||||
rawMinutes,
|
||||
minutes,
|
||||
hours,
|
||||
estimatedScore: Number((hours * 0.52).toFixed(4)),
|
||||
lunchDays,
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,6 +137,18 @@ describe('EmployeeHoursRecordService employee mapping', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('覆盖完整午休时创建与导入均扣除 60 分钟并重新计算积分', async () => {
|
||||
const input = { date: '2026-09-24', employeeId: employee.id, nickname: employee.nickname, scoreRuleId: rule.id, startTime: '2026-09-24T09:00', endTime: '2026-09-24T18:00' }
|
||||
await employeeHoursRecordService.create({ id: 'user-1', name: '管理员' }, input)
|
||||
expect(recordRepository.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
minutes: 480, hours: new Prisma.Decimal('8.00'), estimatedScore: new Prisma.Decimal('4.1600'),
|
||||
}))
|
||||
await employeeHoursRecordService.import({ id: 'user-1', name: '管理员' }, [{ ...input, businessContent: rule.secondary }])
|
||||
expect(recordRepository.createMany).toHaveBeenCalledWith([expect.objectContaining({
|
||||
minutes: 480, hours: new Prisma.Decimal('8.00'), estimatedScore: new Prisma.Decimal('4.1600'),
|
||||
})])
|
||||
})
|
||||
|
||||
it('拒绝不在人员信息配置中的员工 ID', async () => {
|
||||
await expect(employeeHoursRecordService.create(
|
||||
{ id: 'user-1', name: '管理员' },
|
||||
@ -182,6 +194,48 @@ describe('EmployeeHoursRecordService employee mapping', () => {
|
||||
expect(recordRepository.softDeleteMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('复制完整记录快照与附件,保留目标分支的业务配置且仅记录当前操作人', async () => {
|
||||
const source = { ...savedRecord, attachment: { name: '证明.pdf', url: '/api/storage/object?key=proof', mimeType: 'application/pdf', size: 128 } }
|
||||
recordRepository.findById.mockResolvedValue(source)
|
||||
recordRepository.create.mockResolvedValue({ ...source, id: 'record-copy', createdById: 'user-2', updatedById: 'user-2' })
|
||||
const result = await employeeHoursRecordService.copy(
|
||||
{ id: 'user-2', name: '管理员' }, source.id,
|
||||
{ level: 'ALL', nickname: '管理员', department: '' },
|
||||
)
|
||||
expect(recordRepository.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
workDate: source.workDate,
|
||||
nickname: source.nickname,
|
||||
businessContent: source.businessContent,
|
||||
businessNote: source.businessNote,
|
||||
businessCategory: source.businessCategory,
|
||||
workFrequency: source.workFrequency,
|
||||
businessSource: source.businessSource,
|
||||
startTime: source.startTime,
|
||||
endTime: source.endTime,
|
||||
minutes: source.minutes,
|
||||
hours: source.hours,
|
||||
estimatedScore: source.estimatedScore,
|
||||
approvedScore: source.approvedScore,
|
||||
department: source.department,
|
||||
attachment: source.attachment,
|
||||
createdById: 'user-2',
|
||||
updatedById: 'user-2',
|
||||
}))
|
||||
expect(result).toMatchObject({ id: 'record-copy', attachment: source.attachment })
|
||||
expect(ruleRepository.findRuleById).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('不允许复制不存在的记录或无权编辑的他人记录', async () => {
|
||||
recordRepository.findById.mockResolvedValueOnce(null).mockResolvedValueOnce(savedRecord)
|
||||
await expect(employeeHoursRecordService.copy({ id: 'user-2', name: '花名乙' }, 'missing'))
|
||||
.rejects.toThrow('员工工时记录不存在')
|
||||
await expect(employeeHoursRecordService.copy(
|
||||
{ id: 'user-2', name: '花名乙' }, savedRecord.id,
|
||||
{ level: 'DEPARTMENT', nickname: '花名乙', department: employee.department },
|
||||
)).rejects.toThrow('无权访问')
|
||||
expect(recordRepository.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('批量导入时也从人员信息配置解析花名和部门', async () => {
|
||||
await employeeHoursRecordService.import(
|
||||
{ id: 'user-1', name: '管理员' },
|
||||
|
||||
@ -8,13 +8,13 @@ import type {
|
||||
EmployeeHoursRanking,
|
||||
EmployeeHoursRankingMetric,
|
||||
} from '@/lib/employee-hours'
|
||||
import { calculateEmployeeHoursDuration } from '@/lib/employee-hours'
|
||||
import { normalizeObjectStorageUrlForPersistence } from '@/lib/storage'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { employeeHoursConfigRepository } from '@/server/repositories/employee-hours-config.repository'
|
||||
import { employeeHoursRecordRepository } from '@/server/repositories/employee-hours-record.repository'
|
||||
import { assertEmployeeHoursRecordAccess, type EmployeeHoursScope } from './employee-hours-access.service'
|
||||
|
||||
const ESTIMATED_SCORE_PER_HOUR = 0.52
|
||||
const DATE_PATTERN = /^(\d{4})-(\d{1,2})-(\d{1,2})$/
|
||||
const DATE_TIME_PATTERN = /^(\d{4})-(\d{1,2})-(\d{1,2})[T ](\d{1,2}):(\d{2})$/
|
||||
|
||||
@ -64,13 +64,6 @@ function normalizeDateTime(value: unknown) {
|
||||
return `${yearPart}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function localDateTimeMilliseconds(value: string) {
|
||||
const match = DATE_TIME_PATTERN.exec(value)
|
||||
if (!match) return Number.NaN
|
||||
const [, year, month, day, hour, minute] = match.map(Number) as unknown as [string, number, number, number, number, number]
|
||||
return Date.UTC(year, month - 1, day, hour, minute)
|
||||
}
|
||||
|
||||
function toDate(value: Date) {
|
||||
return `${value.getUTCFullYear()}-${String(value.getUTCMonth() + 1).padStart(2, '0')}-${String(value.getUTCDate()).padStart(2, '0')}`
|
||||
}
|
||||
@ -265,12 +258,10 @@ class EmployeeHoursRecordService {
|
||||
const startTime = normalizeDateTime(input.startTime)
|
||||
const endTime = normalizeDateTime(input.endTime)
|
||||
if (!startTime || !endTime) throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '开始时间和结束时间格式不正确,请使用 YYYY/M/D HH:mm')
|
||||
const elapsed = localDateTimeMilliseconds(endTime) - localDateTimeMilliseconds(startTime)
|
||||
const minutes = Math.round(elapsed / 60_000)
|
||||
if (!Number.isFinite(elapsed) || minutes <= 0) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '结束时间必须晚于开始时间')
|
||||
if (minutes > 60 * 24 * 14) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单条工时不能超过14天')
|
||||
const hours = Number((minutes / 60).toFixed(2))
|
||||
const estimatedScore = Number((hours * ESTIMATED_SCORE_PER_HOUR).toFixed(4))
|
||||
const duration = calculateEmployeeHoursDuration(startTime, endTime)
|
||||
if (!duration) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '结束时间必须晚于开始时间')
|
||||
if (duration.rawMinutes > 60 * 24 * 14) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单条工时不能超过14天')
|
||||
const { minutes, hours, estimatedScore } = duration
|
||||
const approvedScore = scoreRule.unitScore === null ? null : new Prisma.Decimal(scoreRule.unitScore.toString())
|
||||
return {
|
||||
workDate: toDbDate(workDateText),
|
||||
@ -306,6 +297,32 @@ class EmployeeHoursRecordService {
|
||||
return toRecord(record)
|
||||
}
|
||||
|
||||
async copy(actor: Actor, id: string, scope?: EmployeeHoursScope) {
|
||||
const existing = await employeeHoursRecordRepository.findById(id)
|
||||
if (!existing) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '员工工时记录不存在')
|
||||
if (scope) assertEmployeeHoursRecordAccess(scope, existing, true)
|
||||
const record = await employeeHoursRecordRepository.create({
|
||||
workDate: existing.workDate,
|
||||
nickname: existing.nickname,
|
||||
businessContent: existing.businessContent,
|
||||
businessNote: existing.businessNote,
|
||||
businessCategory: existing.businessCategory,
|
||||
workFrequency: existing.workFrequency,
|
||||
businessSource: existing.businessSource,
|
||||
startTime: existing.startTime,
|
||||
endTime: existing.endTime,
|
||||
minutes: existing.minutes,
|
||||
hours: existing.hours,
|
||||
estimatedScore: existing.estimatedScore,
|
||||
approvedScore: existing.approvedScore,
|
||||
department: existing.department,
|
||||
attachment: existing.attachment === null ? Prisma.JsonNull : existing.attachment as Prisma.InputJsonValue,
|
||||
createdById: actor.id,
|
||||
updatedById: actor.id,
|
||||
})
|
||||
return toRecord(record)
|
||||
}
|
||||
|
||||
async update(actor: Actor, id: string, input: WriteInput, scope?: EmployeeHoursScope) {
|
||||
const existing = await employeeHoursRecordRepository.findById(id)
|
||||
if (!existing) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '员工工时记录不存在')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user