feat: show submitter first-pass rates
This commit is contained in:
parent
3bc36d141e
commit
ad9c07414a
@ -306,6 +306,29 @@ describe('CoreMetricDetail', () => {
|
||||
statusBreakdown: { approved: 1, revisionRequired: 1, pendingReview: 1, unknown: 0 },
|
||||
readAt: '2026-08-14T02:00:00.000Z',
|
||||
},
|
||||
submitterRates: [
|
||||
{
|
||||
submitterName: '林一',
|
||||
totalUploads: 1,
|
||||
totalApproved: 1,
|
||||
firstPassApproved: 1,
|
||||
rate: 100,
|
||||
},
|
||||
{
|
||||
submitterName: '林二',
|
||||
totalUploads: 1,
|
||||
totalApproved: 1,
|
||||
firstPassApproved: 0,
|
||||
rate: 0,
|
||||
},
|
||||
{
|
||||
submitterName: '林三',
|
||||
totalUploads: 1,
|
||||
totalApproved: 0,
|
||||
firstPassApproved: 0,
|
||||
rate: null,
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: 'approved-v1',
|
||||
@ -363,7 +386,13 @@ describe('CoreMetricDetail', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: '首轮通过' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 2, name: '各提交人的首轮通过率' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 2, name: '所有作品明细' })).toBeInTheDocument()
|
||||
const submitterTable = screen.getByRole('table', { name: '各提交人的首轮通过率,按通过率从高到低排列' })
|
||||
expect(within(submitterTable).getAllByRole('rowheader').map((cell) => cell.textContent)).toEqual(['林一', '林二', '林三'])
|
||||
expect(within(submitterTable).getByText('100%')).toBeInTheDocument()
|
||||
expect(within(submitterTable).getByText('0%')).toBeInTheDocument()
|
||||
expect(within(submitterTable).getByText('—')).toBeInTheDocument()
|
||||
expect(screen.getByText(/全部已上传且包含 V1 的作品/)).toBeInTheDocument()
|
||||
expect(screen.getByText('首轮通过率 = V1 审核通过作品数 ÷ 所有已通过作品数 × 100%')).toBeInTheDocument()
|
||||
expect(screen.getByText('当前口径:1 ÷ 2 = 50%;共读取 3 个作品')).toBeInTheDocument()
|
||||
|
||||
@ -115,11 +115,67 @@ function FirstPassStatusBadge({ status }: { status: FirstPassReviewProjectListIt
|
||||
}
|
||||
|
||||
function FirstPassProjectList({ metric, query, firstPassPage }: Required<Pick<CoreMetricDetailProps, 'metric' | 'firstPassPage'>> & Pick<CoreMetricDetailProps, 'query'>) {
|
||||
const { items, pagination, summary } = firstPassPage
|
||||
const { items, pagination, summary, submitterRates } = firstPassPage
|
||||
|
||||
return (
|
||||
<section aria-labelledby="first-pass-list-title" className="mt-10">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="mt-10">
|
||||
<section aria-labelledby="first-pass-submitter-rates-title">
|
||||
<h2 id="first-pass-submitter-rates-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">各提交人的首轮通过率</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-600">
|
||||
按提交人汇总 V1 审核通过作品数 ÷ 当前已通过作品数,按通过率从高到低排列。
|
||||
</p>
|
||||
|
||||
{submitterRates.length === 0 ? (
|
||||
<div role="status" className="mt-5 rounded-lg border border-[#e2e8f0] bg-white px-5 py-10 text-center text-sm text-slate-600">
|
||||
暂无可统计的提交人数据。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-5 hidden overflow-x-auto rounded-lg border border-[#e2e8f0] bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9] focus-visible:ring-offset-2 md:block" tabIndex={0} aria-label="各提交人首轮通过率横向滚动表格">
|
||||
<table className="min-w-[720px] w-full table-fixed text-left text-sm">
|
||||
<caption className="sr-only">各提交人的首轮通过率,按通过率从高到低排列</caption>
|
||||
<thead className="bg-slate-50 text-xs font-bold text-slate-500">
|
||||
<tr>
|
||||
<th scope="col" className="w-[32%] px-5 py-3.5">提交人</th>
|
||||
<th scope="col" className="w-[20%] px-4 py-3.5">首轮通过率</th>
|
||||
<th scope="col" className="w-[16%] px-4 py-3.5">V1 通过</th>
|
||||
<th scope="col" className="w-[16%] px-4 py-3.5">当前已通过</th>
|
||||
<th scope="col" className="w-[16%] px-5 py-3.5">V1 作品数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{submitterRates.map((submitter) => (
|
||||
<tr key={submitter.submitterName} className="transition-colors hover:bg-slate-50">
|
||||
<th scope="row" className="break-words px-5 py-4 font-bold text-slate-950">{submitter.submitterName}</th>
|
||||
<td className="px-4 py-4 text-lg font-bold tabular-nums text-[#176fc1]">{formatRate(submitter.rate)}</td>
|
||||
<td className="px-4 py-4 tabular-nums text-slate-700">{submitter.firstPassApproved}</td>
|
||||
<td className="px-4 py-4 tabular-nums text-slate-700">{submitter.totalApproved}</td>
|
||||
<td className="px-5 py-4 tabular-nums text-slate-700">{submitter.totalUploads}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ul className="mt-5 divide-y divide-slate-200 overflow-hidden rounded-lg border border-[#e2e8f0] bg-white md:hidden" aria-label="各提交人首轮通过率列表">
|
||||
{submitterRates.map((submitter) => (
|
||||
<li key={submitter.submitterName} className="p-5">
|
||||
<div className="flex min-w-0 items-start justify-between gap-4">
|
||||
<h3 className="min-w-0 break-words font-bold text-slate-950">{submitter.submitterName}</h3>
|
||||
<p className="shrink-0 text-lg font-bold tabular-nums text-[#176fc1]">{formatRate(submitter.rate)}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-xs tabular-nums leading-5 text-slate-500">
|
||||
V1 通过 {submitter.firstPassApproved} · 当前已通过 {submitter.totalApproved} · V1 作品 {submitter.totalUploads}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="first-pass-list-title" className="mt-10">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 id="first-pass-list-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">所有作品明细</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">
|
||||
@ -264,7 +320,8 @@ function FirstPassProjectList({ metric, query, firstPassPage }: Required<Pick<Co
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -45,31 +45,38 @@ describe('department dashboard first-pass review details', () => {
|
||||
;(prisma.workReviewProject.count as jest.Mock).mockImplementation(({ where }) => (
|
||||
JSON.stringify(where).includes('"status":"approved"') ? Promise.resolve(5) : Promise.resolve(1)
|
||||
))
|
||||
;(prisma.workReviewProject.findMany as jest.Mock).mockResolvedValue([{
|
||||
id: 'project-1',
|
||||
title: '品牌主视觉',
|
||||
brand: null,
|
||||
description: '第一版视觉作品',
|
||||
status: 'revision_required',
|
||||
latestVersion: 2,
|
||||
submitterName: '提交人',
|
||||
managerName: '主管',
|
||||
finalReviewerName: '元泽',
|
||||
workDate: new Date('2026-08-28T00:00:00.000Z'),
|
||||
createdAt: new Date('2026-08-28T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-30T01:00:00.000Z'),
|
||||
versions: [{
|
||||
status: 'approved',
|
||||
submittedAt: new Date('2026-08-28T01:00:00.000Z'),
|
||||
completedAt: new Date('2026-08-28T03:00:00.000Z'),
|
||||
reviews: [{
|
||||
reviewerRole: 'manager',
|
||||
reviewerName: '主管',
|
||||
decision: 'approved',
|
||||
reviewedAt: new Date('2026-08-28T02:00:00.000Z'),
|
||||
;(prisma.workReviewProject.findMany as jest.Mock)
|
||||
.mockResolvedValueOnce([{
|
||||
id: 'project-1',
|
||||
title: '品牌主视觉',
|
||||
brand: null,
|
||||
description: '第一版视觉作品',
|
||||
status: 'revision_required',
|
||||
latestVersion: 2,
|
||||
submitterName: '提交人',
|
||||
managerName: '主管',
|
||||
finalReviewerName: '元泽',
|
||||
workDate: new Date('2026-08-28T00:00:00.000Z'),
|
||||
createdAt: new Date('2026-08-28T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-30T01:00:00.000Z'),
|
||||
versions: [{
|
||||
status: 'approved',
|
||||
submittedAt: new Date('2026-08-28T01:00:00.000Z'),
|
||||
completedAt: new Date('2026-08-28T03:00:00.000Z'),
|
||||
reviews: [{
|
||||
reviewerRole: 'manager',
|
||||
reviewerName: '主管',
|
||||
decision: 'approved',
|
||||
reviewedAt: new Date('2026-08-28T02:00:00.000Z'),
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}])
|
||||
}])
|
||||
.mockResolvedValueOnce([
|
||||
{ submitterName: '林二', status: 'approved', versions: [{ status: 'revision_required' }] },
|
||||
{ submitterName: '林一', status: 'approved', versions: [{ status: 'approved' }] },
|
||||
{ submitterName: '林一', status: 'approved', versions: [] },
|
||||
{ submitterName: '提交人', status: 'revision_required', versions: [{ status: 'approved' }] },
|
||||
])
|
||||
})
|
||||
|
||||
test('summary keeps the full brand-center V1 population while search narrows only detail rows', async () => {
|
||||
@ -138,6 +145,23 @@ describe('department dashboard first-pass review details', () => {
|
||||
expect(prisma.workReviewProject.findMany).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
select: expect.objectContaining({ imageUrls: true }),
|
||||
}))
|
||||
expect(prisma.workReviewProject.findMany).toHaveBeenNthCalledWith(2, {
|
||||
where: {
|
||||
AND: [
|
||||
{},
|
||||
{ versions: { some: { version: 1 } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
submitterName: true,
|
||||
status: true,
|
||||
versions: {
|
||||
where: { version: 1 },
|
||||
select: { status: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.summary).toMatchObject({
|
||||
totalUploads: 3,
|
||||
@ -145,6 +169,29 @@ describe('department dashboard first-pass review details', () => {
|
||||
firstPassApproved: 2,
|
||||
rate: 40,
|
||||
})
|
||||
expect(result.submitterRates).toEqual([
|
||||
{
|
||||
submitterName: '林一',
|
||||
totalUploads: 1,
|
||||
totalApproved: 2,
|
||||
firstPassApproved: 1,
|
||||
rate: 50,
|
||||
},
|
||||
{
|
||||
submitterName: '林二',
|
||||
totalUploads: 1,
|
||||
totalApproved: 1,
|
||||
firstPassApproved: 0,
|
||||
rate: 0,
|
||||
},
|
||||
{
|
||||
submitterName: '提交人',
|
||||
totalUploads: 1,
|
||||
totalApproved: 0,
|
||||
firstPassApproved: 1,
|
||||
rate: null,
|
||||
},
|
||||
])
|
||||
expect(result.items).toEqual([{
|
||||
id: 'project-1',
|
||||
title: '品牌主视觉',
|
||||
|
||||
@ -23,6 +23,20 @@ export type FirstPassReviewSummary = {
|
||||
syncRowsChecked?: number
|
||||
}
|
||||
|
||||
export type FirstPassSubmitterRate = {
|
||||
submitterName: string
|
||||
totalUploads: number
|
||||
totalApproved: number
|
||||
firstPassApproved: number
|
||||
rate: number | null
|
||||
}
|
||||
|
||||
export type FirstPassSubmitterProject = {
|
||||
submitterName?: string | null
|
||||
currentStatus: string
|
||||
firstVersionStatus?: string | null
|
||||
}
|
||||
|
||||
export function normalizeFirstPassReviewStatus(status: string): FirstPassReviewStatus {
|
||||
if (status === 'approved' || status === 'revision_required' || status === 'pending_review') {
|
||||
return status
|
||||
@ -71,3 +85,45 @@ export function buildFirstPassReviewSummary(
|
||||
readAt: readAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按提交人复用总指标口径:V1 审核通过作品数 / 当前已通过作品数。
|
||||
* 调用方应只传入包含 V1 的作品,与页面明细的统计范围保持一致。
|
||||
*/
|
||||
export function buildFirstPassSubmitterRates(
|
||||
projects: FirstPassSubmitterProject[],
|
||||
): FirstPassSubmitterRate[] {
|
||||
const ratesBySubmitter = new Map<string, Omit<FirstPassSubmitterRate, 'rate'>>()
|
||||
|
||||
for (const project of projects) {
|
||||
const submitterName = project.submitterName?.trim() || '未填写提交人'
|
||||
const current = ratesBySubmitter.get(submitterName) || {
|
||||
submitterName,
|
||||
totalUploads: 0,
|
||||
totalApproved: 0,
|
||||
firstPassApproved: 0,
|
||||
}
|
||||
|
||||
if (project.firstVersionStatus) {
|
||||
current.totalUploads += 1
|
||||
if (normalizeFirstPassReviewStatus(project.firstVersionStatus) === 'approved') {
|
||||
current.firstPassApproved += 1
|
||||
}
|
||||
}
|
||||
if (project.currentStatus === 'approved') current.totalApproved += 1
|
||||
ratesBySubmitter.set(submitterName, current)
|
||||
}
|
||||
|
||||
return Array.from(ratesBySubmitter.values(), (item) => ({
|
||||
...item,
|
||||
rate: item.totalApproved === 0
|
||||
? null
|
||||
: Math.round((item.firstPassApproved / item.totalApproved) * 1000) / 10,
|
||||
})).sort((left, right) => {
|
||||
if (left.rate === null && right.rate !== null) return 1
|
||||
if (left.rate !== null && right.rate === null) return -1
|
||||
if (left.rate !== right.rate) return (right.rate || 0) - (left.rate || 0)
|
||||
if (left.totalApproved !== right.totalApproved) return right.totalApproved - left.totalApproved
|
||||
return left.submitterName.localeCompare(right.submitterName, 'zh-CN')
|
||||
})
|
||||
}
|
||||
|
||||
@ -57,10 +57,12 @@ import {
|
||||
type NormalizedWorkReviewAnnotation,
|
||||
} from '@/lib/work-review-policy'
|
||||
import {
|
||||
buildFirstPassSubmitterRates,
|
||||
buildFirstPassReviewSummary,
|
||||
normalizeFirstPassReviewStatus,
|
||||
type FirstPassReviewStatus,
|
||||
type FirstPassReviewSummary,
|
||||
type FirstPassSubmitterRate,
|
||||
} from '@/lib/work-review-metrics'
|
||||
import { buildDepartmentDashboardEmployeeWhere } from '@/lib/department-dashboard-scope'
|
||||
|
||||
@ -604,6 +606,7 @@ export type FirstPassReviewProjectListItem = {
|
||||
|
||||
export type FirstPassReviewProjectPage = {
|
||||
summary: FirstPassReviewSummary
|
||||
submitterRates: FirstPassSubmitterRate[]
|
||||
items: FirstPassReviewProjectListItem[]
|
||||
pagination: {
|
||||
page: number
|
||||
@ -704,7 +707,7 @@ export async function getFirstPassReviewProjects(
|
||||
],
|
||||
}
|
||||
}
|
||||
const [summary, total, projects] = await Promise.all([
|
||||
const [summary, total, projects, submitterProjects] = await Promise.all([
|
||||
// Searching and detail-only V1 filters must not change the headline rate.
|
||||
// The denominator is every approved project in the complete department scope.
|
||||
getFirstPassReviewSummaryForProjectWhere(scopeWhere),
|
||||
@ -747,10 +750,27 @@ export async function getFirstPassReviewProjects(
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.workReviewProject.findMany({
|
||||
where: { AND: [scopeWhere, { versions: { some: { version: 1 } } }] },
|
||||
select: {
|
||||
submitterName: true,
|
||||
status: true,
|
||||
versions: {
|
||||
where: { version: 1 },
|
||||
select: { status: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
summary,
|
||||
submitterRates: buildFirstPassSubmitterRates(submitterProjects.map((project) => ({
|
||||
submitterName: project.submitterName,
|
||||
currentStatus: project.status,
|
||||
firstVersionStatus: project.versions[0]?.status || null,
|
||||
}))),
|
||||
items: projects.map((project) => {
|
||||
const firstVersion = project.versions[0]
|
||||
return {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user