feat(barcode-print): 顾家模板改名「顾家(6x4)」并修复默认选中乱码匹配
- 下拉选项「顾家」改为「顾家(6x4)」(模板本身为 60mm×40mm 纸张)
This commit is contained in:
commit
52ab8578e5
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "EmployeeHoursRecord"
|
||||
ADD COLUMN "attachment" JSONB;
|
||||
@ -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
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -30,7 +30,7 @@ describe("Gujia barcode template", () => {
|
||||
} as BarcodePrintRow;
|
||||
|
||||
try {
|
||||
await printBarcodeRow(row, { templateTitle: "顾家" });
|
||||
await printBarcodeRow(row, { templateTitle: "顾家(6x4)" });
|
||||
} finally {
|
||||
window.getCLodop = originalGetCLodop;
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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('运营部')
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 } })
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}`)
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user