feat(barcode-print): 顾家模板改名「顾家(6x4)」并修复默认选中乱码匹配

- 下拉选项「顾家」改为「顾家(6x4)」(模板本身为 60mm×40mm 纸张)
This commit is contained in:
WuYongJie 2026-09-27 15:32:06 +08:00
commit 52ab8578e5
22 changed files with 592 additions and 94 deletions

View File

@ -0,0 +1,2 @@
ALTER TABLE "EmployeeHoursRecord"
ADD COLUMN "attachment" JSONB;

View File

@ -3531,6 +3531,7 @@ model EmployeeHoursRecord {
estimatedScore Decimal @db.Decimal(12, 4)
approvedScore Decimal? @db.Decimal(12, 4)
department String @default("")
attachment Json?
createdById String
updatedById String

View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import { EMPLOYEE_HOURS_IMPORT_HEADERS } from '@/lib/employee-hours'
import { handleRequest } from '@/server/core/response'
import { assertEmployeeHoursAccess } from '@/server/services/employee-hours-access.service'
import { employeeHoursService } from '@/server/services/employee-hours.service'
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
await assertEmployeeHoursAccess(ctx.user, 'download')
const config = await employeeHoursService.getConfig()
const rows = config.rules.map((rule) => [
rule.businessContent,
rule.businessCategory,
rule.workFrequency,
rule.initiativeType,
rule.approvedScore ?? '',
])
const sheet = XLSX.utils.aoa_to_sheet([Array.from(EMPLOYEE_HOURS_IMPORT_HEADERS), ...rows])
sheet['!cols'] = EMPLOYEE_HOURS_IMPORT_HEADERS.map((header) => ({ wch: Math.max(16, header.length * 2 + 4) }))
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, sheet, '业务配置')
const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer
return new NextResponse(new Uint8Array(content), {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent('员工工时业务配置.xlsx')}`,
'Cache-Control': 'private, no-store, max-age=0',
'X-Content-Type-Options': 'nosniff',
},
})
}, { requireAuth: true })
}

View File

@ -0,0 +1,33 @@
import { randomUUID } from 'crypto'
import path from 'path'
import { NextRequest } from 'next/server'
import { uploadObject } from '@/lib/storage'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { assertEmployeeHoursAccess } from '@/server/services/employee-hours-access.service'
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024
const ALLOWED_EXTENSIONS = new Set([
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg',
'.pdf', '.txt', '.csv', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.zip',
])
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
await assertEmployeeHoursAccess(ctx.user, 'edit')
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要上传的附件', 400, ctx.requestId)
if (file.size <= 0) return jsonError(ErrorCode.VALIDATION_FAILED, '附件不能为空', 400, ctx.requestId)
if (file.size > MAX_ATTACHMENT_SIZE) return jsonError(ErrorCode.FILE_TOO_LARGE, '附件不能超过20MB', 400, ctx.requestId)
const extension = path.extname(file.name).toLowerCase()
if (!ALLOWED_EXTENSIONS.has(extension)) {
return jsonError(ErrorCode.FILE_TYPE_NOT_ALLOWED, '不支持该附件格式,请上传常见文件或图片', 400, ctx.requestId)
}
const mimeType = file.type || 'application/octet-stream'
const url = await uploadObject(file, `employee-hours/attachments/${randomUUID()}${extension}`)
return jsonSuccess({ name: file.name.slice(0, 255), url, mimeType, size: file.size }, 201, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -25,6 +25,7 @@ export async function GET(request: NextRequest) {
预估积分: record.estimatedScore,
核定积分: record.approvedScore ?? '',
部门: record.department,
附件: record.attachment?.url ?? '',
}))
const sheet = XLSX.utils.json_to_sheet(rows, { header: Array.from(EMPLOYEE_HOURS_RECORD_HEADERS) })
const workbook = XLSX.utils.book_new()

View File

@ -0,0 +1,39 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
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'
const filtersSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
businessCategory: z.string().trim().max(200).optional(),
workFrequency: z.string().trim().max(100).optional(),
businessSource: z.string().trim().max(100).optional(),
department: z.string().trim().max(200).optional(),
metric: z.enum(['hours', 'estimatedScore', 'approvedScore']).default('hours'),
}).superRefine((value, context) => {
const from = value.dateFrom || value.date
const to = value.dateTo || value.date
if (from && to && from > to) context.addIssue({ code: z.ZodIssueCode.custom, path: ['dateTo'], message: '结束日期不能早于开始日期' })
})
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const scope = await assertEmployeeHoursAccess(ctx.user, 'view')
if (scope.level === 'SELF') return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '只有部门负责人或公司负责人可以查看员工工时排名', 403, ctx.requestId)
const parsed = filtersSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries()))
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '排名筛选条件不正确', 400, ctx.requestId)
return jsonSuccess(await employeeHoursRecordService.ranking({
date: parsed.data.date || undefined,
dateFrom: parsed.data.dateFrom || undefined,
dateTo: parsed.data.dateTo || undefined,
businessCategory: parsed.data.businessCategory || undefined,
workFrequency: parsed.data.workFrequency || undefined,
businessSource: parsed.data.businessSource || undefined,
department: parsed.data.department || undefined,
}, scope, parsed.data.metric), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -1,4 +1,4 @@
import { NextRequest } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { employeeHoursService } from '@/server/services/employee-hours.service'
@ -25,9 +25,14 @@ export async function POST(request: NextRequest) {
}
export async function PATCH(request: NextRequest) {
return handleRequest(request, async (ctx) => {
return handleRequest(request, async (ctx): Promise<NextResponse> => {
await assertEmployeeHoursAccess(ctx.user, 'configure')
const body = objectBody(await request.json().catch(() => null))
if (Array.isArray(body.ids)) {
const ids = body.ids.filter((id): id is string => typeof id === 'string')
const changes = objectBody(body.changes)
return jsonSuccess(await employeeHoursService.updateRules(ids, changes), 200, ctx.requestId)
}
const id = String(body.id ?? '').trim()
if (!id) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少业务配置标识', 400, ctx.requestId)
const input = objectBody(body.rule ?? body)
@ -36,9 +41,13 @@ export async function PATCH(request: NextRequest) {
}
export async function DELETE(request: NextRequest) {
return handleRequest(request, async (ctx) => {
return handleRequest(request, async (ctx): Promise<NextResponse> => {
await assertEmployeeHoursAccess(ctx.user, 'configure')
const body = objectBody(await request.json().catch(() => null))
if (Array.isArray(body.ids)) {
const ids = body.ids.filter((id): id is string => typeof id === 'string')
return jsonSuccess(await employeeHoursService.deleteRules(ids), 200, ctx.requestId)
}
const id = String(body.id ?? '').trim()
if (!id) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少业务配置标识', 400, ctx.requestId)
return jsonSuccess(await employeeHoursService.deleteRule(id), 200, ctx.requestId)

View File

@ -10,13 +10,13 @@ export async function GET(request: NextRequest) {
const sheet = XLSX.utils.aoa_to_sheet([Array.from(EMPLOYEE_HOURS_IMPORT_HEADERS)])
sheet['!cols'] = EMPLOYEE_HOURS_IMPORT_HEADERS.map((header) => ({ wch: Math.max(16, header.length * 2 + 4) }))
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, sheet, '员工工时统计')
XLSX.utils.book_append_sheet(workbook, sheet, '业务配置')
const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer
return new NextResponse(new Uint8Array(content), {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent('员工工时统计导入模板.xlsx')}`,
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent('员工工时业务配置导入模板.xlsx')}`,
'Cache-Control': 'private, no-store, max-age=0',
'X-Content-Type-Options': 'nosniff',
},

View File

@ -40,7 +40,7 @@ describe('BarcodePrintTemplateDialog', () => {
mode="barcode"
templates={[
{ title: '条码打印_第一套编码(数字开头)&一次性采买条码' },
{ title: '顾家' },
{ title: '顾家(6x4)' },
]}
rowSummary="CS0002 / CS000201A / WPH-SZ015713N1170V01"
onClose={jest.fn()}
@ -48,12 +48,12 @@ describe('BarcodePrintTemplateDialog', () => {
/>,
)
await actor.selectOptions(screen.getByLabelText('打印模板'), '顾家')
await actor.selectOptions(screen.getByLabelText('打印模板'), '顾家(6x4)')
await actor.type(screen.getByPlaceholderText('可不填'), 'PC01')
await actor.click(screen.getByRole('button', { name: '确定' }))
expect(onSubmit).toHaveBeenCalledWith({
templateTitle: '顾家',
templateTitle: '顾家(6x4)',
batchNumber: 'PC01',
editMode: undefined,
})

View File

@ -47,7 +47,7 @@ export function BarcodePrintTemplateDialog({
const [errorMessage, setErrorMessage] = useState("");
const title = mode === "barcode" ? "请选择打印模板" : "请选择合格证模板";
const selectedTemplate = templates.find((template) => template.title === templateTitle);
const showBatchNumber = mode === "barcode" && templateTitle === "顾家";
const showBatchNumber = mode === "barcode" && templateTitle === "顾家(6x4)";
useEffect(() => {
if (!isOpen) return;

View File

@ -30,7 +30,7 @@ describe("Gujia barcode template", () => {
} as BarcodePrintRow;
try {
await printBarcodeRow(row, { templateTitle: "顾家" });
await printBarcodeRow(row, { templateTitle: "顾家(6x4)" });
} finally {
window.getCLodop = originalGetCLodop;
}

View File

@ -221,7 +221,7 @@ export function getDefaultBarcodePrintTemplateTitle(row: BarcodePrintRow) {
const variant = getDefaultBarcodeTemplateVariant(row);
if (variant === "gujia") {
return BARCODE_PRINT_TEMPLATES.find((template) => template.title === "椤惧")?.title
return BARCODE_PRINT_TEMPLATES.find((template) => template.title === "顾家(6x4)")?.title
?? BARCODE_PRINT_TEMPLATES[0]?.title
?? "";
}
@ -429,7 +429,7 @@ const BARCODE_PRINT_TEMPLATES: BarcodePrintTemplate[] = [
},
},
{
title: "顾家",
title: "顾家(6x4)",
printFunc(record, lodop, options) {
const barcodeParts = splitGujiaBarcodeParts(record);
@ -541,7 +541,7 @@ export const CERTIFICATE_PRINT_TEMPLATE_OPTIONS = CERTIFICATE_PRINT_TEMPLATES.ma
function chooseBarcodeTemplate(row: BarcodePrintRow) {
if (row.supplierName.includes("顾家")) {
return BARCODE_PRINT_TEMPLATES.find((template) => template.title === "顾家") ?? BARCODE_PRINT_TEMPLATES[0];
return BARCODE_PRINT_TEMPLATES.find((template) => template.title === "顾家(6x4)") ?? BARCODE_PRINT_TEMPLATES[0];
}
return BARCODE_PRINT_TEMPLATES[0];

View File

@ -63,7 +63,7 @@ describe("third barcode template with size column", () => {
SECOND_TITLE,
THIRD_TITLE,
"外贸条码打印",
"顾家",
"顾家(6x4)",
]);
expect(new Set(titles).size).toBe(titles.length);
expect(getDefaultBarcodePrintTemplateTitle(createRow("CJ026601N1280V01"))).toBe(SECOND_TITLE);

View File

@ -23,6 +23,7 @@ const record: EmployeeHoursRecord = {
estimatedScore: 0.52,
approvedScore: 1,
department: '运营部',
attachment: null,
}
const originalFetch = global.fetch
@ -66,6 +67,7 @@ describe('员工工时业务操作列', () => {
const toolbar = container.querySelector('form')!
expect(within(toolbar).queryByRole('button', { name: /^(新增|编辑|删除)$/ })).not.toBeInTheDocument()
expect(within(toolbar).queryByRole('button', { name: '导入' })).not.toBeInTheDocument()
expect(screen.getAllByRole('columnheader')[0]).toHaveTextContent('业务操作')
const operations = screen.getByRole('columnheader', { name: /业务操作/ })
fireEvent.click(within(operations).getByRole('button', { name: '新增员工工时' }))
@ -102,15 +104,13 @@ describe('员工工时业务操作列', () => {
expect(screen.queryByRole('button', { name: /新增员工工时|编辑 花名甲|删除 花名甲/ })).not.toBeInTheDocument()
})
it('选择花名后显示其人员配置部门', async () => {
it('根据登录账号自动带出花名和人员配置部门', async () => {
employees = [{ id: 'owner-config-1', nickname: '花名甲', department: '运营部' }]
render(<EmployeeHoursHoursView canEdit canDownload={false} />)
render(<EmployeeHoursHoursView canEdit canDownload={false} currentUserName="花名甲" />)
await screen.findByText('暂无员工工时记录')
fireEvent.click(screen.getByRole('button', { name: '新增员工工时' }))
const nickname = screen.getByRole('combobox', { name: '花名' })
fireEvent.focus(nickname)
fireEvent.change(nickname, { target: { value: '花名甲' } })
fireEvent.click(screen.getByRole('option', { name: /花名甲/ }))
expect(screen.queryByRole('combobox', { name: '花名' })).not.toBeInTheDocument()
expect(screen.getByRole('textbox', { name: '花名' })).toHaveValue('花名甲')
expect(screen.getByRole('textbox', { name: '部门' })).toHaveValue('运营部')
})

View File

@ -3,11 +3,12 @@
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react'
import {
AlertCircle,
BarChart3,
Check,
ChevronDown,
Download,
FilePlus2,
GripHorizontal,
Paperclip,
Pencil,
Plus,
RefreshCw,
@ -15,15 +16,17 @@ import {
Settings2,
SlidersHorizontal,
Trash2,
Upload,
X,
} from 'lucide-react'
import type {
EmployeeHoursConfig,
EmployeeHoursAttachment,
EmployeeHoursEmployee,
EmployeeHoursRecord,
EmployeeHoursRecordFilters,
EmployeeHoursRecordPage,
EmployeeHoursRanking,
EmployeeHoursRankingMetric,
} from '@/lib/employee-hours'
import { EmployeeHoursView } from './EmployeeHoursView'
import { EmployeeHoursPermissionButton } from './EmployeeHoursAccessPanels'
@ -38,6 +41,7 @@ type RecordDraft = {
businessNote: string
startTime: string
endTime: string
attachment: EmployeeHoursAttachment | null
}
const HOURS_COLUMNS: HoursColumn[] = [
@ -55,6 +59,7 @@ const HOURS_COLUMNS: HoursColumn[] = [
{ key: 'estimatedScore', label: '预估积分', width: 112 },
{ key: 'approvedScore', label: '核定积分', width: 112 },
{ key: 'department', label: '部门', width: 180 },
{ key: 'attachment', label: '附件', width: 180 },
]
const NUMERIC_HOUR_COLUMNS = new Set(['minutes', 'hours', 'estimatedScore', 'approvedScore'])
@ -86,7 +91,7 @@ function todayLocal() {
}
function newDraft(): RecordDraft {
return { date: todayLocal(), employeeId: '', nickname: '', scoreRuleId: '', businessNote: '', startTime: '', endTime: '' }
return { date: todayLocal(), employeeId: '', nickname: '', scoreRuleId: '', businessNote: '', startTime: '', endTime: '', attachment: null }
}
async function readResponse<T>(response: Response) {
@ -323,15 +328,19 @@ export function EmployeeHoursHoursView({
const [pageSize, setPageSize] = useState(50)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [rankingOpen, setRankingOpen] = useState(false)
const [rankingLoading, setRankingLoading] = useState(false)
const [ranking, setRanking] = useState<EmployeeHoursRanking | null>(null)
const [rankingMetric, setRankingMetric] = useState<EmployeeHoursRankingMetric>('hours')
const [editorOpen, setEditorOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [draft, setDraft] = useState<RecordDraft>(newDraft())
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [uploadingAttachment, setUploadingAttachment] = useState(false)
const contentRef = useRef<HTMLElement>(null)
const resizeStartRef = useRef<{ clientY: number; height: number } | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const attachmentRef = useRef<HTMLInputElement>(null)
const loadConfig = useCallback(async () => {
const next = await readResponse<EmployeeHoursConfig>(await fetch('/api/supply-chain/management/employee-hours', { cache: 'no-store' }))
@ -376,12 +385,6 @@ export function EmployeeHoursHoursView({
approvedScore: (totals?.approvedScore ?? 0).toFixed(4),
}
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const employeeOptions = useMemo<SearchableSelectOption[]>(() => employees.map((employee) => ({
value: employee.id,
label: employee.nickname,
description: employee.department || '部门未配置',
keywords: employee.department,
})), [employees])
const ruleOptions = useMemo<SearchableSelectOption[]>(() => rules.map((rule) => ({
value: rule.id,
label: rule.businessContent,
@ -426,9 +429,28 @@ export function EmployeeHoursHoursView({
setPage(1)
}
async function loadRanking(metric: EmployeeHoursRankingMetric = rankingMetric) {
if (accessScope === 'SELF') return
setRankingLoading(true)
setError('')
const params = new URLSearchParams({ metric })
Object.entries(appliedFilters).forEach(([key, value]) => { if (value && key !== 'nickname') params.set(key, value) })
try {
const next = await readResponse<EmployeeHoursRanking>(await fetch(`/api/supply-chain/management/employee-hours/records/ranking?${params}`, { cache: 'no-store' }))
setRanking(next)
setRankingMetric(metric)
setRankingOpen(true)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '员工工时排名加载失败')
} finally {
setRankingLoading(false)
}
}
function openCreate() {
const employee = employees.find((value) => value.nickname === currentUserName)
setEditingId(null)
setDraft(newDraft())
setDraft({ ...newDraft(), employeeId: employee?.id ?? '', nickname: currentUserName })
setEditorOpen(true)
setError('')
setNotice('')
@ -450,6 +472,7 @@ export function EmployeeHoursHoursView({
businessNote: record.businessNote,
startTime: record.startTime,
endTime: record.endTime,
attachment: record.attachment,
})
setEditorOpen(true)
setError('')
@ -462,11 +485,28 @@ export function EmployeeHoursHoursView({
setDraft(newDraft())
}
async function uploadAttachment(file: File) {
setUploadingAttachment(true)
setError('')
try {
const form = new FormData()
form.append('file', file)
const attachment = await readResponse<EmployeeHoursAttachment>(await fetch('/api/supply-chain/management/employee-hours/records/attachment', { method: 'POST', body: form }))
setDraft((value) => ({ ...value, attachment }))
} catch (cause) {
setError(cause instanceof Error ? cause.message : '附件上传失败')
} finally {
setUploadingAttachment(false)
if (attachmentRef.current) attachmentRef.current.value = ''
}
}
async function saveRecord() {
if (!canEdit || saving) return
setSaving(true)
setError('')
setNotice('')
const wasEditing = Boolean(editingId)
try {
await readResponse(await fetch('/api/supply-chain/management/employee-hours/records', {
method: editingId ? 'PATCH' : 'POST',
@ -475,7 +515,7 @@ export function EmployeeHoursHoursView({
}))
closeEditor()
await loadRecords(appliedFilters, page, pageSize)
setNotice(editingId ? '员工工时记录已更新' : '员工工时记录已新增')
setNotice(wasEditing ? '员工工时记录已更新' : '员工工时记录已新增')
} catch (cause) {
setError(cause instanceof Error ? cause.message : '员工工时记录保存失败')
} finally {
@ -502,25 +542,6 @@ export function EmployeeHoursHoursView({
}
}
async function importFile(file: File) {
setSaving(true)
setError('')
setNotice('')
try {
const form = new FormData()
form.append('file', file)
const result = await readResponse<{ importedCount: number }>(await fetch('/api/supply-chain/management/employee-hours/records/import', { method: 'POST', body: form }))
await loadRecords(appliedFilters, page, pageSize)
setNotice(`导入完成:新增 ${result.importedCount} 条员工工时记录`)
setImportOpen(false)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '员工工时记录导入失败')
} finally {
setSaving(false)
if (fileRef.current) fileRef.current.value = ''
}
}
if (showBusinessConfig) {
return <EmployeeHoursView canEdit={canConfigure} canDownload={canConfigure && canDownload} onBack={() => setShowBusinessConfig(false)} />
}
@ -562,10 +583,7 @@ export function EmployeeHoursHoursView({
Object.entries(appliedFilters).forEach(([key, value]) => { if (value) params.set(key, value) })
window.location.href = `/api/supply-chain/management/employee-hours/records/export?${params}`
}}>导出</PreviewButton> : null}
{canEdit ? <>
<PreviewButton icon={<FilePlus2 size={13} />} disabled={saving} onClick={() => setImportOpen(true)}>导入</PreviewButton>
<input ref={fileRef} type="file" accept=".xlsx,.xls" className="hidden" onChange={(event) => { const file = event.target.files?.[0]; if (file) void importFile(file) }} />
</> : null}
{accessScope !== 'SELF' ? <PreviewButton icon={<BarChart3 size={13} />} disabled={rankingLoading} onClick={() => rankingOpen ? setRankingOpen(false) : void loadRanking()}>排名分析</PreviewButton> : null}
{canManagePermissions ? <EmployeeHoursPermissionButton /> : null}
{canConfigure ? <button type="button" onClick={() => setShowBusinessConfig(true)} className="inline-flex h-8 items-center gap-1.5 rounded-[4px] border border-zinc-800 bg-zinc-900 px-2.5 text-xs font-medium text-white hover:bg-zinc-700"><Settings2 size={13} />业务配置</button> : null}
</div>
@ -574,6 +592,29 @@ export function EmployeeHoursHoursView({
{error ? <div role="alert" className="mx-3 mt-2 flex shrink-0 items-start gap-2 rounded-[5px] border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700"><AlertCircle size={14} className="mt-0.5 shrink-0" />{error}</div> : null}
{notice ? <div role="status" className="mx-3 mt-2 flex shrink-0 items-center gap-2 rounded-[5px] border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-700"><Check size={14} />{notice}</div> : null}
{rankingOpen && accessScope !== 'SELF' ? <section aria-label="员工工时排名分析" className="mx-3 mt-2 shrink-0 overflow-hidden rounded-[6px] border border-zinc-200 bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-200 bg-zinc-50 px-3 py-2.5">
<div><h2 className="text-sm font-semibold text-zinc-900">员工工时排名分析</h2><p className="mt-0.5 text-[11px] text-zinc-500">统计范围:{accessScope === 'ALL' ? '全公司' : '本部门'},数据跟随当前筛选条件</p></div>
<div className="flex items-center gap-1 rounded-[5px] border border-zinc-200 bg-white p-0.5" role="group" aria-label="排名指标">
{([['hours', '工时'], ['estimatedScore', '预估积分'], ['approvedScore', '核定积分']] as const).map(([metric, label]) => <button key={metric} type="button" disabled={rankingLoading} onClick={() => void loadRanking(metric)} className={`h-7 rounded-[4px] px-2.5 text-xs ${rankingMetric === metric ? 'bg-zinc-900 text-white' : 'text-zinc-600 hover:bg-zinc-100'}`}>{label}</button>)}
</div>
</div>
<div className="grid grid-cols-2 gap-px border-b border-zinc-200 bg-zinc-200 sm:grid-cols-5">
{[
['员工数', ranking?.totals.employees.toLocaleString('zh-CN') ?? '0'],
['记录数', ranking?.totals.recordCount.toLocaleString('zh-CN') ?? '0'],
['总工时', `${(ranking?.totals.hours ?? 0).toFixed(2)} 小时`],
['预估积分', (ranking?.totals.estimatedScore ?? 0).toFixed(4)],
['核定积分', (ranking?.totals.approvedScore ?? 0).toFixed(4)],
].map(([label, value]) => <div key={label} className="bg-white px-3 py-2.5"><div className="text-[11px] text-zinc-500">{label}</div><div className="mt-1 text-base font-semibold tabular-nums text-zinc-900">{value}</div></div>)}
</div>
<div className="max-h-[360px] overflow-auto">
<table className="w-full min-w-[760px] border-collapse text-xs"><thead className="sticky top-0 z-10 bg-white text-zinc-500"><tr className="border-b border-zinc-200"><th className="w-16 px-3 py-2 text-center font-medium">排名</th><th className="px-3 py-2 text-left font-medium">花名</th><th className="px-3 py-2 text-left font-medium">部门</th><th className="px-3 py-2 text-right font-medium">记录数</th><th className="px-3 py-2 text-right font-medium">分钟</th><th className="px-3 py-2 text-right font-medium">小时</th><th className="px-3 py-2 text-right font-medium">预估积分</th><th className="px-3 py-2 text-right font-medium">核定积分</th></tr></thead><tbody>{ranking?.rows.map((row) => <tr key={`${row.nickname}-${row.department}`} className="border-b border-zinc-100 last:border-0 hover:bg-zinc-50"><td className="px-3 py-2 text-center font-semibold tabular-nums text-zinc-500">{row.rank}</td><td className="px-3 py-2 text-left font-medium text-zinc-900">{row.nickname}</td><td className="px-3 py-2 text-left text-zinc-600">{row.department || '—'}</td><td className="px-3 py-2 text-right tabular-nums text-zinc-700">{row.recordCount.toLocaleString('zh-CN')}</td><td className="px-3 py-2 text-right tabular-nums text-zinc-700">{row.minutes.toLocaleString('zh-CN')}</td><td className="px-3 py-2 text-right tabular-nums text-zinc-900">{row.hours.toFixed(2)}</td><td className="px-3 py-2 text-right tabular-nums text-zinc-700">{row.estimatedScore.toFixed(4)}</td><td className="px-3 py-2 text-right tabular-nums text-zinc-700">{row.approvedScore.toFixed(4)}</td></tr>)}</tbody></table>
{!rankingLoading && !ranking?.rows.length ? <div className="px-4 py-10 text-center text-xs text-zinc-400">当前筛选范围暂无员工工时数据</div> : null}
{rankingLoading ? <div className="px-4 py-10 text-center text-xs text-zinc-400">正在计算排名…</div> : null}
</div>
</section> : null}
<section ref={contentRef} className="flex min-h-0 flex-1 flex-col overflow-hidden p-3">
<div className="shrink-0 overflow-auto rounded-[6px] border border-zinc-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04)]" style={{ height: tableHeight }}>
<table className="table-fixed border-collapse text-xs" style={{ width: TABLE_WIDTH + (canEdit ? ACTION_COLUMN_WIDTH : 0), minWidth: TABLE_WIDTH + (canEdit ? ACTION_COLUMN_WIDTH : 0) }}>
@ -600,10 +641,15 @@ export function EmployeeHoursHoursView({
<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}
{HOURS_COLUMNS.map((column, index) => {
const value = column.key === 'startTime' || column.key === 'endTime'
const value = column.key === 'attachment'
? record.attachment?.name ?? ''
: column.key === 'startTime' || column.key === 'endTime'
? formatDateTime(record[column.key])
: record[column.key as keyof EmployeeHoursRecord]
return <td key={column.key} title={String(value ?? '')} style={index < FROZEN_COLUMN_OFFSETS.length ? { left: FROZEN_COLUMN_OFFSETS[index] + (canEdit ? ACTION_COLUMN_WIDTH : 0) } : undefined} className={`overflow-hidden text-ellipsis whitespace-nowrap border-r border-zinc-100 px-2 ${NUMERIC_HOUR_COLUMNS.has(column.key) ? 'text-right' : 'text-left'} text-zinc-700 last:border-r-0 ${index < FROZEN_COLUMN_OFFSETS.length ? 'bg-white group-hover:bg-zinc-50 min-[1100px]:sticky min-[1100px]:z-10' : ''} ${index === FROZEN_COLUMN_OFFSETS.length - 1 ? 'min-[1100px]:shadow-[3px_0_6px_-4px_rgba(0,0,0,0.2)]' : ''}`}>{value === null || value === '' ? '—' : value}</td>
const content = column.key === 'attachment' && record.attachment
? <a href={record.attachment.url} target="_blank" rel="noreferrer" className="inline-flex max-w-full items-center gap-1 truncate text-zinc-700 hover:text-zinc-950" title={record.attachment.name}><Paperclip size={12} className="shrink-0" />{record.attachment.name}</a>
: value === null || value === '' ? '—' : String(value)
return <td key={column.key} title={String(value ?? '')} style={index < FROZEN_COLUMN_OFFSETS.length ? { left: FROZEN_COLUMN_OFFSETS[index] + (canEdit ? ACTION_COLUMN_WIDTH : 0) } : undefined} className={`overflow-hidden text-ellipsis whitespace-nowrap border-r border-zinc-100 px-2 ${NUMERIC_HOUR_COLUMNS.has(column.key) ? 'text-right' : 'text-left'} text-zinc-700 last:border-r-0 ${index < FROZEN_COLUMN_OFFSETS.length ? 'bg-white group-hover:bg-zinc-50 min-[1100px]:sticky min-[1100px]:z-10' : ''} ${index === FROZEN_COLUMN_OFFSETS.length - 1 ? 'min-[1100px]:shadow-[3px_0_6px_-4px_rgba(0,0,0,0.2)]' : ''}`}>{content}</td>
})}
</tr>
))}
@ -635,7 +681,7 @@ export function EmployeeHoursHoursView({
<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="花名"><SearchableSelect ariaLabel="花名" listboxId="employee-hours-employee-options" value={draft.employeeId} options={employeeOptions} placeholder="输入花名或部门关键词" emptyMessage="未找到匹配员工" onChange={(employeeId) => { const employee = employees.find((value) => value.id === employeeId); setDraft((value) => ({ ...value, employeeId, nickname: employee?.nickname ?? '' })) }} />{!employees.length ? <span className="mt-1 block text-[11px] text-amber-700">人员信息配置中暂无可选员工。</span> : null}</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>
@ -648,36 +694,14 @@ export function EmployeeHoursHoursView({
<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} 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 || !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>
<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>
</section>
</div>
) : null}
{importOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) setImportOpen(false) }}>
<section role="dialog" aria-modal="true" aria-labelledby="employee-hours-import-title" className="w-full max-w-md rounded-[9px] bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-zinc-200 px-5 py-4">
<div>
<h2 id="employee-hours-import-title" className="text-base font-semibold text-zinc-900">导入员工工时</h2>
<p className="mt-1 text-xs text-zinc-500">请按模板填写后导入,系统会自动计算工时和预估积分。</p>
</div>
<button type="button" aria-label="关闭导入面板" onClick={() => setImportOpen(false)} 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="space-y-3 px-5 py-4">
<div className="rounded-[6px] border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-600">
必填字段:日期、花名、业务内容、开始时间、结束时间。业务分类、工作频率和业务来源用于匹配业务配置,可在表格中填写以消除同名业务内容歧义。
</div>
<div className="flex flex-wrap gap-2">
{canDownload ? <button type="button" onClick={() => { window.location.href = '/api/supply-chain/management/employee-hours/records/template' }} className="inline-flex h-9 items-center gap-1.5 rounded-[6px] border border-zinc-300 bg-white px-3 text-xs font-medium text-zinc-700 hover:bg-zinc-50"><Download size={14} />下载模板</button> : null}
<button type="button" disabled={saving} onClick={() => fileRef.current?.click()} className="inline-flex h-9 items-center gap-1.5 rounded-[6px] bg-zinc-900 px-3 text-xs font-medium text-white hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"><Upload size={14} />选择 Excel 文件</button>
</div>
<p className="text-[11px] text-zinc-400">支持 .xlsx、.xls,单次最多导入 5000 行。</p>
</div>
</section>
</div>
) : null}
</main>
)
}

View File

@ -13,6 +13,12 @@ import {
} from '@/lib/employee-hours'
type Draft = Omit<EmployeeHoursRule, 'id' | 'sortOrder'>
type BatchChanges = {
businessCategory: string
workFrequency: string
initiativeType: string
approvedScore: string
}
const EMPTY_DRAFT: Draft = {
businessContent: '',
@ -22,6 +28,13 @@ const EMPTY_DRAFT: Draft = {
approvedScore: null,
}
const EMPTY_BATCH_CHANGES: BatchChanges = {
businessCategory: '',
workFrequency: '',
initiativeType: '',
approvedScore: '',
}
async function readResponse<T>(response: Response) {
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '请求失败,请稍后重试')
@ -146,6 +159,10 @@ export function EmployeeHoursView({
const [editorOpen, setEditorOpen] = useState(false)
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT)
const [showOptions, setShowOptions] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [batchEditOpen, setBatchEditOpen] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [batchChanges, setBatchChanges] = useState<BatchChanges>(EMPTY_BATCH_CHANGES)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const fileRef = useRef<HTMLInputElement>(null)
@ -160,9 +177,16 @@ export function EmployeeHoursView({
useEffect(() => { void load() }, [])
useEffect(() => {
const ids = new Set(config?.rules.map((rule) => rule.id) ?? [])
setSelectedIds((current) => new Set([...current].filter((id) => ids.has(id))))
}, [config?.rules])
const options = config?.options
const canCreate = Boolean(draft.businessContent.trim() && draft.businessCategory && draft.workFrequency && draft.initiativeType)
const editingRule = useMemo(() => config?.rules.find((rule) => rule.id === editingId) ?? null, [config?.rules, editingId])
const rules = config?.rules ?? []
const allSelected = rules.length > 0 && selectedIds.size === rules.length
function resetEditor() { setEditingId(null); setDraft(EMPTY_DRAFT); setEditorOpen(false) }
@ -189,12 +213,53 @@ export function EmployeeHoursView({
} catch (cause) { setError(cause instanceof Error ? cause.message : '业务配置删除失败') } finally { setSaving(false) }
}
function toggleSelected(id: string) {
setSelectedIds((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleAllSelected() {
setSelectedIds(allSelected ? new Set() : new Set(rules.map((rule) => rule.id)))
}
async function deleteSelectedRules() {
if (!canEdit || saving || !selectedIds.size || !window.confirm(`确定删除选中的 ${selectedIds.size} 条业务配置?已有工时记录不会删除。`)) return
setSaving(true); setError(''); setNotice('')
try {
const result = await readResponse<{ deletedCount: number }>(await fetch('/api/supply-chain/management/employee-hours', {
method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ids: [...selectedIds] }),
}))
setSelectedIds(new Set()); await load(); setNotice(`已删除 ${result.deletedCount} 条业务配置`)
} catch (cause) { setError(cause instanceof Error ? cause.message : '批量删除失败') } finally { setSaving(false) }
}
async function saveBatchEdit() {
if (!canEdit || saving || !selectedIds.size) return
const changes: Record<string, unknown> = {}
if (batchChanges.businessCategory) changes.businessCategory = batchChanges.businessCategory
if (batchChanges.workFrequency) changes.workFrequency = batchChanges.workFrequency
if (batchChanges.initiativeType) changes.initiativeType = batchChanges.initiativeType
if (batchChanges.approvedScore !== '') changes.approvedScore = batchChanges.approvedScore
if (!Object.keys(changes).length) { setError('请至少设置一个批量修改字段'); return }
setSaving(true); setError(''); setNotice('')
try {
const result = await readResponse<{ updatedCount: number }>(await fetch('/api/supply-chain/management/employee-hours', {
method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ids: [...selectedIds], changes }),
}))
setBatchEditOpen(false); setBatchChanges(EMPTY_BATCH_CHANGES); setSelectedIds(new Set()); await load(); setNotice(`已更新 ${result.updatedCount} 条业务配置`)
} catch (cause) { setError(cause instanceof Error ? cause.message : '批量编辑失败') } finally { setSaving(false) }
}
async function importFile(file: File) {
setSaving(true); setError(''); setNotice('')
try {
const form = new FormData(); form.append('file', file)
const result = await readResponse<{ importedCount: number; createdCount: number; updatedCount: number }>(await fetch('/api/supply-chain/management/employee-hours/import', { method: 'POST', body: form }))
await load(); setNotice(`导入完成:${result.importedCount} 行,新增 ${result.createdCount} 行,更新 ${result.updatedCount} 行`)
await load(); setImportOpen(false); setNotice(`导入完成:${result.importedCount} 行,新增 ${result.createdCount} 行,更新 ${result.updatedCount} 行`)
} catch (cause) { setError(cause instanceof Error ? cause.message : '批量导入失败') } finally { setSaving(false); if (fileRef.current) fileRef.current.value = '' }
}
@ -208,8 +273,9 @@ export function EmployeeHoursView({
<div><h1 className="text-lg font-semibold text-zinc-950">业务配置</h1><p className="mt-1 text-xs text-zinc-500">维护业务内容、分类、工作频率、业务来源和核定积分</p></div>
</div>
<div className="flex flex-wrap items-center gap-2">
{canEdit ? <><button type="button" onClick={() => { resetEditor(); setEditorOpen(true); setError(''); setNotice('') }} className="inline-flex h-9 items-center gap-1.5 rounded-[6px] bg-zinc-900 px-3 text-xs font-medium text-white hover:bg-zinc-700"><Plus size={14} />新增业务</button><button type="button" onClick={() => fileRef.current?.click()} disabled={saving} className="inline-flex h-9 items-center gap-1.5 rounded-[6px] border border-zinc-300 bg-white px-3 text-xs font-medium text-zinc-700 hover:bg-zinc-50 disabled:opacity-50"><Upload size={14} />批量导入</button><input ref={fileRef} type="file" accept=".xlsx,.xls" className="hidden" onChange={(event) => { const file = event.target.files?.[0]; if (file) void importFile(file) }} /></> : null}
{canDownload ? <button type="button" onClick={() => { window.location.href = '/api/supply-chain/management/employee-hours/template' }} className="inline-flex h-9 items-center gap-1.5 rounded-[6px] border border-zinc-300 bg-white px-3 text-xs font-medium text-zinc-700 hover:bg-zinc-50"><Download size={14} />下载模板</button> : null}
{canEdit ? <><button type="button" onClick={() => { resetEditor(); setEditorOpen(true); setError(''); setNotice('') }} className="inline-flex h-9 items-center gap-1.5 rounded-[6px] bg-zinc-900 px-3 text-xs font-medium text-white hover:bg-zinc-700"><Plus size={14} />新增业务</button><button type="button" onClick={() => setImportOpen(true)} disabled={saving} className="inline-flex h-9 items-center gap-1.5 rounded-[6px] border border-zinc-300 bg-white px-3 text-xs font-medium text-zinc-700 hover:bg-zinc-50 disabled:opacity-50"><Upload size={14} />批量导入</button></> : null}
{canDownload ? <button type="button" onClick={() => { window.location.href = '/api/supply-chain/management/employee-hours/export' }} className="inline-flex h-9 items-center gap-1.5 rounded-[6px] border border-zinc-300 bg-white px-3 text-xs font-medium text-zinc-700 hover:bg-zinc-50"><Download size={14} />导出</button> : null}
{selectedIds.size > 0 && canEdit ? <><button type="button" onClick={() => { setBatchChanges(EMPTY_BATCH_CHANGES); setBatchEditOpen(true) }} disabled={saving} className="inline-flex h-9 items-center gap-1.5 rounded-[6px] border border-zinc-300 bg-white px-3 text-xs font-medium text-zinc-700 hover:bg-zinc-50 disabled:opacity-50"><Pencil size={14} />批量编辑 ({selectedIds.size})</button><button type="button" onClick={() => void deleteSelectedRules()} disabled={saving} className="inline-flex h-9 items-center gap-1.5 rounded-[6px] border border-red-200 bg-white px-3 text-xs font-medium text-red-700 hover:bg-red-50 disabled:opacity-50"><Trash2 size={14} />批量删除 ({selectedIds.size})</button></> : null}
<button type="button" onClick={() => setShowOptions((value) => !value)} className={`inline-flex h-9 items-center gap-1.5 rounded-[6px] border px-3 text-xs font-medium ${showOptions ? 'border-zinc-900 bg-zinc-100 text-zinc-900' : 'border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-50'}`}><Settings2 size={14} />选项配置</button>
<button type="button" aria-label="刷新" title="刷新" onClick={() => void load()} className="inline-flex size-9 items-center justify-center rounded-[6px] border border-zinc-300 bg-white text-zinc-600 hover:bg-zinc-50"><RefreshCw size={14} /></button>
</div>
@ -234,12 +300,39 @@ export function EmployeeHoursView({
</div>
) : null}
<div className="min-h-[360px] overflow-hidden rounded-[8px] border border-zinc-200 bg-white">
<div className="overflow-auto"><table className="w-full min-w-[920px] border-collapse text-xs"><thead className="bg-zinc-50 text-zinc-700"><tr>{EMPLOYEE_HOURS_IMPORT_HEADERS.map((header) => <th key={header} className={`border-b border-zinc-200 px-3 py-3 font-semibold ${header === '核定积分' ? 'text-right' : 'text-left'}`}>{header}</th>)}{canEdit ? <th className="sticky right-0 border-b border-zinc-200 bg-zinc-50 px-3 py-3 text-center font-semibold">操作</th> : null}</tr></thead><tbody>
{(config?.rules ?? []).map((rule) => <tr key={rule.id} className="border-b border-zinc-100 last:border-0 hover:bg-zinc-50/70"><td className="max-w-[300px] px-3 py-3 text-left text-zinc-900">{rule.businessContent}</td><td className="px-3 py-3 text-left text-zinc-700">{rule.businessCategory}</td><td className="px-3 py-3 text-left text-zinc-700">{rule.workFrequency}</td><td className="px-3 py-3 text-left text-zinc-700">{rule.initiativeType}</td><td className="px-3 py-3 text-right tabular-nums text-zinc-900">{rule.approvedScore ?? '—'}</td>{canEdit ? <td className="sticky right-0 bg-white px-3 py-2"><div className="flex justify-center gap-1"><button type="button" aria-label={`编辑业务配置 ${rule.businessContent}`} title="编辑" onClick={() => { setEditingId(rule.id); setEditorOpen(true); setDraft(draftFromRule(rule)); setError(''); setNotice('') }} className="inline-flex size-8 items-center justify-center rounded-[6px] border border-zinc-300 text-zinc-600 hover:bg-zinc-50"><Pencil size={14} /></button><button type="button" aria-label={`删除业务配置 ${rule.businessContent}`} title="删除" onClick={() => void deleteRule(rule)} className="inline-flex size-8 items-center justify-center rounded-[6px] border border-red-200 text-red-600 hover:bg-red-50"><Trash2 size={14} /></button></div></td> : null}</tr>)}
{(config?.rules ?? []).length === 0 ? <tr><td colSpan={canEdit ? 6 : 5} className="px-4 py-16 text-center text-sm text-zinc-500">暂无业务配置,请新增或导入维护</td></tr> : null}
</tbody></table></div>
<div className="min-h-[360px] max-h-[calc(100vh-260px)] min-w-0 overflow-auto rounded-[8px] border border-zinc-200 bg-white">
<table className="w-full min-w-[1120px] border-collapse text-xs"><thead className="sticky top-0 z-20 bg-zinc-50 text-zinc-700"><tr>{canEdit ? <th className="sticky left-0 z-10 border-b border-r border-zinc-200 bg-zinc-50 px-3 py-3 text-center font-semibold"><input type="checkbox" aria-label="全选业务配置" checked={allSelected} onChange={toggleAllSelected} /></th> : null}{EMPLOYEE_HOURS_IMPORT_HEADERS.map((header) => <th key={header} className={`border-b border-zinc-200 px-3 py-3 font-semibold ${header === '核定积分' ? 'text-right' : 'text-left'}`}>{header}</th>)}{canEdit ? <th className="sticky right-0 border-b border-l border-zinc-200 bg-zinc-50 px-3 py-3 text-center font-semibold">操作</th> : null}</tr></thead><tbody>
{rules.map((rule) => <tr key={rule.id} className="border-b border-zinc-100 last:border-0 hover:bg-zinc-50/70">{canEdit ? <td className="sticky left-0 z-[1] border-r border-zinc-200 bg-white px-3 py-3 text-center"><input type="checkbox" aria-label={`选择业务配置 ${rule.businessContent}`} checked={selectedIds.has(rule.id)} onChange={() => toggleSelected(rule.id)} /></td> : null}<td className="max-w-[300px] px-3 py-3 text-left text-zinc-900">{rule.businessContent}</td><td className="px-3 py-3 text-left text-zinc-700">{rule.businessCategory}</td><td className="px-3 py-3 text-left text-zinc-700">{rule.workFrequency}</td><td className="px-3 py-3 text-left text-zinc-700">{rule.initiativeType}</td><td className="px-3 py-3 text-right tabular-nums text-zinc-900">{rule.approvedScore ?? '—'}</td>{canEdit ? <td className="sticky right-0 border-l border-zinc-200 bg-white px-3 py-2"><div className="flex justify-center gap-1"><button type="button" aria-label={`编辑业务配置 ${rule.businessContent}`} title="编辑" onClick={() => { setEditingId(rule.id); setEditorOpen(true); setDraft(draftFromRule(rule)); setError(''); setNotice('') }} className="inline-flex size-8 items-center justify-center rounded-[6px] border border-zinc-300 text-zinc-600 hover:bg-zinc-50"><Pencil size={14} /></button><button type="button" aria-label={`删除业务配置 ${rule.businessContent}`} title="删除" onClick={() => void deleteRule(rule)} className="inline-flex size-8 items-center justify-center rounded-[6px] border border-red-200 text-red-600 hover:bg-red-50"><Trash2 size={14} /></button></div></td> : null}</tr>)}
{rules.length === 0 ? <tr><td colSpan={canEdit ? 7 : 5} className="px-4 py-16 text-center text-sm text-zinc-500">暂无业务配置,请新增或导入维护</td></tr> : null}
</tbody></table>
</div>
{importOpen ? <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setImportOpen(false) }}>
<section role="dialog" aria-modal="true" aria-label="批量导入业务配置" className="w-full max-w-lg rounded-[8px] border border-zinc-200 bg-white p-5 shadow-xl">
<div className="flex items-center justify-between"><h2 className="text-base font-semibold text-zinc-900">批量导入业务配置</h2><button type="button" aria-label="关闭批量导入" title="关闭" onClick={() => setImportOpen(false)} className="inline-flex size-8 items-center justify-center rounded-[6px] border border-zinc-300 text-zinc-500 hover:bg-zinc-50"><X size={14} /></button></div>
<p className="mt-3 text-xs leading-5 text-zinc-500">请先下载业务配置导入模板,填写后选择 Excel 文件上传。相同业务内容、分类、频率和业务来源会更新原配置,否则新增配置。</p>
<div className="mt-4 flex flex-wrap gap-2">
{canDownload ? <button type="button" onClick={() => { window.location.href = '/api/supply-chain/management/employee-hours/template' }} className="inline-flex h-9 items-center gap-1.5 rounded-[6px] border border-zinc-300 bg-white px-3 text-xs font-medium text-zinc-700 hover:bg-zinc-50"><Download size={14} />下载导入模板</button> : null}
<button type="button" disabled={saving} onClick={() => fileRef.current?.click()} className="inline-flex h-9 items-center gap-1.5 rounded-[6px] bg-zinc-900 px-3 text-xs font-medium text-white hover:bg-zinc-700 disabled:opacity-50"><Upload size={14} />选择 Excel 文件</button>
<input ref={fileRef} type="file" accept=".xlsx,.xls" className="hidden" onChange={(event) => { const file = event.target.files?.[0]; if (file) void importFile(file) }} />
</div>
<p className="mt-3 text-[11px] text-zinc-400">支持 .xlsx、.xls,单个文件不超过 10MB。</p>
</section>
</div> : null}
{batchEditOpen ? <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) { setBatchChanges(EMPTY_BATCH_CHANGES); setBatchEditOpen(false) } }}>
<section role="dialog" aria-modal="true" aria-label="批量编辑业务配置" className="w-full max-w-xl rounded-[8px] border border-zinc-200 bg-white p-5 shadow-xl">
<div className="flex items-center justify-between"><h2 className="text-base font-semibold text-zinc-900">批量编辑业务配置</h2><button type="button" aria-label="关闭批量编辑" title="关闭" onClick={() => { setBatchChanges(EMPTY_BATCH_CHANGES); setBatchEditOpen(false) }} className="inline-flex size-8 items-center justify-center rounded-[6px] border border-zinc-300 text-zinc-500 hover:bg-zinc-50"><X size={14} /></button></div>
<p className="mt-2 text-xs text-zinc-500">已选择 {selectedIds.size} 条。未设置的字段保持原值。</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label className="text-xs text-zinc-600">统一业务分类<select value={batchChanges.businessCategory} onChange={(event) => setBatchChanges((value) => ({ ...value, businessCategory: event.target.value }))} className="mt-1 h-9 w-full rounded-[6px] border border-zinc-300 bg-white px-2 text-sm text-zinc-900 outline-none focus:border-zinc-800"><option value="">不修改</option>{(options?.BUSINESS_CATEGORY ?? []).map((option) => <option key={option.id} value={option.value}>{option.value}</option>)}</select></label>
<label className="text-xs text-zinc-600">统一工作频率<select value={batchChanges.workFrequency} onChange={(event) => setBatchChanges((value) => ({ ...value, workFrequency: event.target.value }))} className="mt-1 h-9 w-full rounded-[6px] border border-zinc-300 bg-white px-2 text-sm text-zinc-900 outline-none focus:border-zinc-800"><option value="">不修改</option>{(options?.WORK_FREQUENCY ?? []).map((option) => <option key={option.id} value={option.value}>{option.value}</option>)}</select></label>
<label className="text-xs text-zinc-600">统一业务来源<select value={batchChanges.initiativeType} onChange={(event) => setBatchChanges((value) => ({ ...value, initiativeType: event.target.value }))} className="mt-1 h-9 w-full rounded-[6px] border border-zinc-300 bg-white px-2 text-sm text-zinc-900 outline-none focus:border-zinc-800"><option value="">不修改</option>{(options?.INITIATIVE_TYPE ?? []).map((option) => <option key={option.id} value={option.value}>{option.value}</option>)}</select></label>
<label className="text-xs text-zinc-600">统一核定积分<input type="number" min="0" step="0.01" placeholder="不修改" value={batchChanges.approvedScore} onChange={(event) => setBatchChanges((value) => ({ ...value, approvedScore: event.target.value }))} className="mt-1 h-9 w-full rounded-[6px] border border-zinc-300 px-2 text-sm text-zinc-900 outline-none focus:border-zinc-800" /></label>
</div>
<div className="mt-5 flex justify-end gap-2"><button type="button" onClick={() => { setBatchChanges(EMPTY_BATCH_CHANGES); setBatchEditOpen(false) }} className="h-8 rounded-[6px] border border-zinc-300 px-3 text-xs text-zinc-600 hover:bg-zinc-50">取消</button><button type="button" disabled={saving} onClick={() => void saveBatchEdit()} className="inline-flex h-8 items-center gap-1.5 rounded-[6px] bg-zinc-900 px-3 text-xs font-medium text-white hover:bg-zinc-700 disabled:opacity-50"><Check size={14} />保存修改</button></div>
</section>
</div> : null}
<p className="text-[11px] text-zinc-400">导入模板字段:{EMPLOYEE_HOURS_IMPORT_HEADERS.join('、')}。导入时同一业务内容、分类、频率和业务来源会更新原配置,否则新增配置。</p>
</div>
)

View File

@ -50,6 +50,14 @@ export type EmployeeHoursRecord = {
estimatedScore: number
approvedScore: number | null
department: string
attachment: EmployeeHoursAttachment | null
}
export type EmployeeHoursAttachment = {
name: string
url: string
mimeType: string
size: number
}
export type EmployeeHoursEmployee = {
@ -86,6 +94,32 @@ export type EmployeeHoursRecordPage = {
total: number
}
export type EmployeeHoursRankingMetric = 'hours' | 'estimatedScore' | 'approvedScore'
export type EmployeeHoursRankingRow = {
rank: number
nickname: string
department: string
recordCount: number
minutes: number
hours: number
estimatedScore: number
approvedScore: number
}
export type EmployeeHoursRanking = {
rows: EmployeeHoursRankingRow[]
totals: {
employees: number
recordCount: number
minutes: number
hours: number
estimatedScore: number
approvedScore: number
}
metric: EmployeeHoursRankingMetric
}
export const EMPLOYEE_HOURS_RECORD_HEADERS = [
'日期',
'花名',
@ -101,6 +135,7 @@ export const EMPLOYEE_HOURS_RECORD_HEADERS = [
'预估积分',
'核定积分',
'部门',
'附件',
] as const
export const EMPLOYEE_HOURS_IMPORT_HEADERS = [

View File

@ -20,6 +20,8 @@ export interface EmployeeHoursConfigRepository {
createRule(data: Prisma.EmployeeHoursScoreRuleCreateInput): Promise<EmployeeHoursScoreRule>
updateRule(id: string, data: Prisma.EmployeeHoursScoreRuleUpdateInput): Promise<EmployeeHoursScoreRule>
deactivateRule(id: string): Promise<EmployeeHoursScoreRule>
updateRules(ids: string[], data: Prisma.EmployeeHoursScoreRuleUpdateInput): Promise<void>
deactivateRules(ids: string[]): Promise<void>
}
class EmployeeHoursConfigRepositoryClass implements EmployeeHoursConfigRepository {
@ -98,6 +100,17 @@ class EmployeeHoursConfigRepositoryClass implements EmployeeHoursConfigRepositor
data: { isActive: false },
})
}
async updateRules(ids: string[], data: Prisma.EmployeeHoursScoreRuleUpdateInput): Promise<void> {
await prisma.$transaction(ids.map((id) => prisma.employeeHoursScoreRule.update({ where: { id }, data })))
}
async deactivateRules(ids: string[]): Promise<void> {
await prisma.$transaction(ids.map((id) => prisma.employeeHoursScoreRule.update({
where: { id },
data: { isActive: false },
})))
}
}
export const employeeHoursConfigRepository = new EmployeeHoursConfigRepositoryClass()

View File

@ -71,6 +71,21 @@ export class EmployeeHoursRecordRepository {
})
}
async findRanking(filters: EmployeeHoursRecordFilters = {}, scope?: EmployeeHoursScope) {
const where = this.buildWhere(filters, scope)
return prisma.employeeHoursRecord.groupBy({
by: ['nickname', 'department'],
where,
_count: { _all: true },
_sum: {
minutes: true,
hours: true,
estimatedScore: true,
approvedScore: true,
},
})
}
async findById(id: string) {
return prisma.employeeHoursRecord.findFirst({ where: { id, deletedAt: null } })
}

View File

@ -4,6 +4,7 @@ jest.mock('@/server/repositories/employee-hours-record.repository', () => ({
employeeHoursRecordRepository: {
findPage: jest.fn(),
findAll: jest.fn(),
findRanking: jest.fn(),
findById: jest.fn(),
findByIds: jest.fn(),
create: jest.fn(),
@ -213,3 +214,74 @@ describe('EmployeeHoursRecordService.list totals', () => {
expect(result.totals).toEqual({ minutes: 0, hours: 0, estimatedScore: 0, approvedScore: 0 })
})
})
describe('EmployeeHoursRecordService.ranking', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('按所选指标排序并汇总员工工时与积分', async () => {
recordRepository.findRanking.mockResolvedValue([
{
nickname: '青山',
department: '供应链中心',
_count: { _all: 2 },
_sum: {
minutes: 120,
hours: new Prisma.Decimal('2.00'),
estimatedScore: new Prisma.Decimal('1.0400'),
approvedScore: new Prisma.Decimal('3.0000'),
},
},
{
nickname: '花名乙',
department: '供应链中心',
_count: { _all: 1 },
_sum: {
minutes: 180,
hours: new Prisma.Decimal('3.00'),
estimatedScore: new Prisma.Decimal('1.5600'),
approvedScore: new Prisma.Decimal('2.0000'),
},
},
] as never)
const result = await employeeHoursRecordService.ranking(
{ dateFrom: '2026-09-01', dateTo: '2026-09-30', department: '供应链中心' },
{ level: 'DEPARTMENT', nickname: '青山', department: '供应链中心' },
'approvedScore',
)
expect(recordRepository.findRanking).toHaveBeenCalledWith(
expect.objectContaining({ dateFrom: '2026-09-01', dateTo: '2026-09-30', department: '供应链中心' }),
{ level: 'DEPARTMENT', nickname: '青山', department: '供应链中心' },
)
expect(result.metric).toBe('approvedScore')
expect(result.rows.map((row) => row.nickname)).toEqual(['青山', '花名乙'])
expect(result.rows.map((row) => row.rank)).toEqual([1, 2])
expect(result.totals).toEqual({
employees: 2,
recordCount: 3,
minutes: 300,
hours: 5,
estimatedScore: 2.6,
approvedScore: 5,
})
})
it('将不支持的指标降级为工时排序', async () => {
recordRepository.findRanking.mockResolvedValue([
{
nickname: '花名乙',
department: '供应链中心',
_count: { _all: 1 },
_sum: { minutes: 60, hours: new Prisma.Decimal('1.00'), estimatedScore: null, approvedScore: null },
},
] as never)
const result = await employeeHoursRecordService.ranking({}, undefined, 'unsupported' as never)
expect(result.metric).toBe('hours')
expect(result.rows[0]).toMatchObject({ nickname: '花名乙', hours: 1, estimatedScore: 0, approvedScore: 0 })
})
})

View File

@ -1,9 +1,12 @@
import { Prisma, type EmployeeHoursRecord as DbEmployeeHoursRecord, type EmployeeHoursScoreRule } from '@prisma/client'
import type {
EmployeeHoursEmployee,
EmployeeHoursAttachment,
EmployeeHoursRecord,
EmployeeHoursRecordFilters,
EmployeeHoursRecordPage,
EmployeeHoursRanking,
EmployeeHoursRankingMetric,
} from '@/lib/employee-hours'
import { AppError, ErrorCode } from '@/server/core/errors'
import { employeeHoursConfigRepository } from '@/server/repositories/employee-hours-config.repository'
@ -75,6 +78,33 @@ function toNumber(value: Prisma.Decimal | null | undefined) {
return value === null || value === undefined ? null : Number(value.toString())
}
function toAttachment(value: Prisma.JsonValue | null | undefined): EmployeeHoursAttachment | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const candidate = value as Record<string, unknown>
const name = text(candidate.name)
const url = text(candidate.url)
const mimeType = text(candidate.mimeType)
const size = Number(candidate.size)
if (!name || !url || !mimeType || !Number.isInteger(size) || size < 0) return null
return { name, url, mimeType, size }
}
function normalizeAttachment(value: unknown): EmployeeHoursAttachment | null {
if (value === undefined || value === null || value === '') return null
if (typeof value !== 'object' || Array.isArray(value)) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '附件信息格式不正确')
}
const attachment = toAttachment(value as Prisma.JsonValue)
if (!attachment) throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '附件信息不完整')
if (attachment.name.length > 255 || attachment.mimeType.length > 120 || attachment.size > 20 * 1024 * 1024) {
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '附件信息超出限制')
}
if (!attachment.url.startsWith('/api/storage/object?key=')) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '附件地址不受支持')
}
return attachment
}
function toRecord(record: DbEmployeeHoursRecord): EmployeeHoursRecord {
return {
id: record.id,
@ -92,6 +122,7 @@ function toRecord(record: DbEmployeeHoursRecord): EmployeeHoursRecord {
estimatedScore: toNumber(record.estimatedScore) ?? 0,
approvedScore: toNumber(record.approvedScore),
department: record.department,
attachment: toAttachment(record.attachment),
}
}
@ -153,6 +184,41 @@ class EmployeeHoursRecordService {
return (await employeeHoursRecordRepository.findAll(normalizeRecordFilters(filters), scope)).map(toRecord)
}
async ranking(
filters: EmployeeHoursRecordFilters = {},
scope?: EmployeeHoursScope,
metric: EmployeeHoursRankingMetric = 'hours',
): Promise<EmployeeHoursRanking> {
const normalizedMetric = ['hours', 'estimatedScore', 'approvedScore'].includes(metric) ? metric : 'hours'
const groups = await employeeHoursRecordRepository.findRanking(normalizeRecordFilters(filters), scope)
const rows = groups.map((group) => ({
rank: 0,
nickname: group.nickname,
department: group.department,
recordCount: group._count._all,
minutes: group._sum.minutes ?? 0,
hours: toNumber(group._sum.hours) ?? 0,
estimatedScore: toNumber(group._sum.estimatedScore) ?? 0,
approvedScore: toNumber(group._sum.approvedScore) ?? 0,
})).sort((left, right) => {
const difference = right[normalizedMetric] - left[normalizedMetric]
return difference || right.hours - left.hours || left.nickname.localeCompare(right.nickname, 'zh-CN')
})
rows.forEach((row, index) => { row.rank = index + 1 })
return {
rows,
totals: {
employees: rows.length,
recordCount: rows.reduce((sum, row) => sum + row.recordCount, 0),
minutes: rows.reduce((sum, row) => sum + row.minutes, 0),
hours: rows.reduce((sum, row) => sum + row.hours, 0),
estimatedScore: rows.reduce((sum, row) => sum + row.estimatedScore, 0),
approvedScore: rows.reduce((sum, row) => sum + row.approvedScore, 0),
},
metric: normalizedMetric,
}
}
private async getRule(id: unknown) {
const ruleId = text(id)
const rule = ruleId ? await employeeHoursConfigRepository.findRuleById(ruleId) : null
@ -219,6 +285,9 @@ class EmployeeHoursRecordService {
estimatedScore: new Prisma.Decimal(estimatedScore.toFixed(4)),
approvedScore,
department: employee.department,
attachment: Object.prototype.hasOwnProperty.call(input, 'attachment')
? normalizeAttachment(input.attachment)
: undefined,
updatedById: actor.id,
}
}
@ -229,8 +298,9 @@ class EmployeeHoursRecordService {
if (scope) assertEmployeeHoursRecordAccess(scope, data, true)
const record = await employeeHoursRecordRepository.create({
...data,
attachment: data.attachment ? data.attachment as Prisma.InputJsonValue : Prisma.JsonNull,
createdById: actor.id,
})
} as Prisma.EmployeeHoursRecordUncheckedCreateInput)
return toRecord(record)
}
@ -241,7 +311,12 @@ class EmployeeHoursRecordService {
const employees = await employeeHoursRecordRepository.findActiveEmployees()
const data = await this.normalizeInput(input, actor, employees)
if (scope) assertEmployeeHoursRecordAccess(scope, data, true)
const record = await employeeHoursRecordRepository.update(id, data)
const record = await employeeHoursRecordRepository.update(id, {
...data,
...(data.attachment === undefined
? {}
: { attachment: data.attachment ? data.attachment as Prisma.InputJsonValue : Prisma.JsonNull }),
} as Prisma.EmployeeHoursRecordUncheckedUpdateInput)
return toRecord(record)
}
@ -272,8 +347,9 @@ class EmployeeHoursRecordService {
if (scope) assertEmployeeHoursRecordAccess(scope, data, true)
prepared.push({
...data,
attachment: data.attachment ? data.attachment as Prisma.InputJsonValue : Prisma.JsonNull,
createdById: actor.id,
})
} as Prisma.EmployeeHoursRecordCreateManyInput)
} catch (cause) {
const message = cause instanceof Error ? cause.message : '字段校验失败'
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `第 ${index + 2} 行:${message}`)

View File

@ -130,6 +130,57 @@ class EmployeeHoursService {
return { id }
}
async deleteRules(ids: string[]) {
const uniqueIds = [...new Set(ids.map((id) => text(id)).filter(Boolean))]
if (!uniqueIds.length) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要删除的业务配置')
if (uniqueIds.length > 1000) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多删除1000条业务配置')
const rules = await this.configRepository.findRules()
const foundIds = new Set(rules.map((rule) => rule.id))
const missing = uniqueIds.find((id) => !foundIds.has(id))
if (missing) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '部分业务配置不存在或已删除,请刷新后重试')
await this.configRepository.deactivateRules(uniqueIds)
return { ids: uniqueIds, deletedCount: uniqueIds.length }
}
async updateRules(ids: string[], changes: Record<string, unknown>) {
const uniqueIds = [...new Set(ids.map((id) => text(id)).filter(Boolean))]
if (!uniqueIds.length) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要编辑的业务配置')
if (uniqueIds.length > 1000) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多编辑1000条业务配置')
const currentRules = await this.configRepository.findRules()
const selected = currentRules.filter((rule) => uniqueIds.includes(rule.id))
if (selected.length !== uniqueIds.length) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '部分业务配置不存在或已删除,请刷新后重试')
const nextCategory = changes.businessCategory === undefined ? undefined : text(changes.businessCategory)
const nextFrequency = changes.workFrequency === undefined ? undefined : text(changes.workFrequency)
const nextSource = changes.initiativeType === undefined ? undefined : text(changes.initiativeType)
if (nextCategory !== undefined) await this.assertConfiguredOption('BUSINESS_CATEGORY', nextCategory)
if (nextFrequency !== undefined) await this.assertConfiguredOption('WORK_FREQUENCY', nextFrequency)
if (nextSource !== undefined) await this.assertConfiguredOption('INITIATIVE_TYPE', nextSource)
const nextScore = changes.approvedScore === undefined ? undefined : score(changes.approvedScore)
const selectedIds = new Set(uniqueIds)
const keys = new Set<string>()
for (const rule of currentRules) {
if (selectedIds.has(rule.id)) continue
keys.add(`${rule.secondary}\u0000${rule.primary}\u0000${rule.workFrequency}\u0000${rule.initiativeType}`)
}
for (const rule of selected) {
const key = `${rule.secondary}\u0000${nextCategory ?? rule.primary}\u0000${nextFrequency ?? rule.workFrequency}\u0000${nextSource ?? rule.initiativeType}`
if (keys.has(key)) throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '批量修改后会造成重复业务配置,请调整选择范围或字段')
keys.add(key)
}
const updateData: Prisma.EmployeeHoursScoreRuleUpdateInput = {}
if (nextCategory !== undefined) updateData.primary = nextCategory
if (nextFrequency !== undefined) updateData.workFrequency = nextFrequency
if (nextSource !== undefined) updateData.initiativeType = nextSource
if (nextScore !== undefined) updateData.unitScore = nextScore === null ? null : new Prisma.Decimal(nextScore)
if (!Object.keys(updateData).length) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请至少设置一个批量修改字段')
await this.configRepository.updateRules(uniqueIds, updateData)
return { ids: uniqueIds, updatedCount: uniqueIds.length }
}
async createOption(input: Record<string, unknown>) {
const type = optionType(input.type)
const value = text(input.value)