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 },
|
statusBreakdown: { approved: 1, revisionRequired: 1, pendingReview: 1, unknown: 0 },
|
||||||
readAt: '2026-08-14T02:00:00.000Z',
|
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: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'approved-v1',
|
id: 'approved-v1',
|
||||||
@ -363,7 +386,13 @@ describe('CoreMetricDetail', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { level: 1, name: '首轮通过' })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { level: 1, name: '首轮通过' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('heading', { level: 2, 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 的作品/)).toBeInTheDocument()
|
||||||
expect(screen.getByText('首轮通过率 = V1 审核通过作品数 ÷ 所有已通过作品数 × 100%')).toBeInTheDocument()
|
expect(screen.getByText('首轮通过率 = V1 审核通过作品数 ÷ 所有已通过作品数 × 100%')).toBeInTheDocument()
|
||||||
expect(screen.getByText('当前口径:1 ÷ 2 = 50%;共读取 3 个作品')).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'>) {
|
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 (
|
return (
|
||||||
<section aria-labelledby="first-pass-list-title" className="mt-10">
|
<div className="mt-10">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
<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>
|
<div>
|
||||||
<h2 id="first-pass-list-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">所有作品明细</h2>
|
<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">
|
<p className="mt-1 text-sm leading-6 text-slate-600">
|
||||||
@ -264,7 +320,8 @@ function FirstPassProjectList({ metric, query, firstPassPage }: Required<Pick<Co
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -45,31 +45,38 @@ describe('department dashboard first-pass review details', () => {
|
|||||||
;(prisma.workReviewProject.count as jest.Mock).mockImplementation(({ where }) => (
|
;(prisma.workReviewProject.count as jest.Mock).mockImplementation(({ where }) => (
|
||||||
JSON.stringify(where).includes('"status":"approved"') ? Promise.resolve(5) : Promise.resolve(1)
|
JSON.stringify(where).includes('"status":"approved"') ? Promise.resolve(5) : Promise.resolve(1)
|
||||||
))
|
))
|
||||||
;(prisma.workReviewProject.findMany as jest.Mock).mockResolvedValue([{
|
;(prisma.workReviewProject.findMany as jest.Mock)
|
||||||
id: 'project-1',
|
.mockResolvedValueOnce([{
|
||||||
title: '品牌主视觉',
|
id: 'project-1',
|
||||||
brand: null,
|
title: '品牌主视觉',
|
||||||
description: '第一版视觉作品',
|
brand: null,
|
||||||
status: 'revision_required',
|
description: '第一版视觉作品',
|
||||||
latestVersion: 2,
|
status: 'revision_required',
|
||||||
submitterName: '提交人',
|
latestVersion: 2,
|
||||||
managerName: '主管',
|
submitterName: '提交人',
|
||||||
finalReviewerName: '元泽',
|
managerName: '主管',
|
||||||
workDate: new Date('2026-08-28T00:00:00.000Z'),
|
finalReviewerName: '元泽',
|
||||||
createdAt: new Date('2026-08-28T01:00:00.000Z'),
|
workDate: new Date('2026-08-28T00:00:00.000Z'),
|
||||||
updatedAt: new Date('2026-08-30T01:00:00.000Z'),
|
createdAt: new Date('2026-08-28T01:00:00.000Z'),
|
||||||
versions: [{
|
updatedAt: new Date('2026-08-30T01:00:00.000Z'),
|
||||||
status: 'approved',
|
versions: [{
|
||||||
submittedAt: new Date('2026-08-28T01:00:00.000Z'),
|
status: 'approved',
|
||||||
completedAt: new Date('2026-08-28T03:00:00.000Z'),
|
submittedAt: new Date('2026-08-28T01:00:00.000Z'),
|
||||||
reviews: [{
|
completedAt: new Date('2026-08-28T03:00:00.000Z'),
|
||||||
reviewerRole: 'manager',
|
reviews: [{
|
||||||
reviewerName: '主管',
|
reviewerRole: 'manager',
|
||||||
decision: 'approved',
|
reviewerName: '主管',
|
||||||
reviewedAt: new Date('2026-08-28T02:00:00.000Z'),
|
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 () => {
|
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({
|
expect(prisma.workReviewProject.findMany).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||||
select: expect.objectContaining({ imageUrls: true }),
|
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({
|
expect(result.summary).toMatchObject({
|
||||||
totalUploads: 3,
|
totalUploads: 3,
|
||||||
@ -145,6 +169,29 @@ describe('department dashboard first-pass review details', () => {
|
|||||||
firstPassApproved: 2,
|
firstPassApproved: 2,
|
||||||
rate: 40,
|
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([{
|
expect(result.items).toEqual([{
|
||||||
id: 'project-1',
|
id: 'project-1',
|
||||||
title: '品牌主视觉',
|
title: '品牌主视觉',
|
||||||
|
|||||||
@ -23,6 +23,20 @@ export type FirstPassReviewSummary = {
|
|||||||
syncRowsChecked?: number
|
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 {
|
export function normalizeFirstPassReviewStatus(status: string): FirstPassReviewStatus {
|
||||||
if (status === 'approved' || status === 'revision_required' || status === 'pending_review') {
|
if (status === 'approved' || status === 'revision_required' || status === 'pending_review') {
|
||||||
return status
|
return status
|
||||||
@ -71,3 +85,45 @@ export function buildFirstPassReviewSummary(
|
|||||||
readAt: readAt.toISOString(),
|
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,
|
type NormalizedWorkReviewAnnotation,
|
||||||
} from '@/lib/work-review-policy'
|
} from '@/lib/work-review-policy'
|
||||||
import {
|
import {
|
||||||
|
buildFirstPassSubmitterRates,
|
||||||
buildFirstPassReviewSummary,
|
buildFirstPassReviewSummary,
|
||||||
normalizeFirstPassReviewStatus,
|
normalizeFirstPassReviewStatus,
|
||||||
type FirstPassReviewStatus,
|
type FirstPassReviewStatus,
|
||||||
type FirstPassReviewSummary,
|
type FirstPassReviewSummary,
|
||||||
|
type FirstPassSubmitterRate,
|
||||||
} from '@/lib/work-review-metrics'
|
} from '@/lib/work-review-metrics'
|
||||||
import { buildDepartmentDashboardEmployeeWhere } from '@/lib/department-dashboard-scope'
|
import { buildDepartmentDashboardEmployeeWhere } from '@/lib/department-dashboard-scope'
|
||||||
|
|
||||||
@ -604,6 +606,7 @@ export type FirstPassReviewProjectListItem = {
|
|||||||
|
|
||||||
export type FirstPassReviewProjectPage = {
|
export type FirstPassReviewProjectPage = {
|
||||||
summary: FirstPassReviewSummary
|
summary: FirstPassReviewSummary
|
||||||
|
submitterRates: FirstPassSubmitterRate[]
|
||||||
items: FirstPassReviewProjectListItem[]
|
items: FirstPassReviewProjectListItem[]
|
||||||
pagination: {
|
pagination: {
|
||||||
page: number
|
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.
|
// Searching and detail-only V1 filters must not change the headline rate.
|
||||||
// The denominator is every approved project in the complete department scope.
|
// The denominator is every approved project in the complete department scope.
|
||||||
getFirstPassReviewSummaryForProjectWhere(scopeWhere),
|
getFirstPassReviewSummaryForProjectWhere(scopeWhere),
|
||||||
@ -747,10 +750,27 @@ export async function getFirstPassReviewProjects(
|
|||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: 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 {
|
return {
|
||||||
summary,
|
summary,
|
||||||
|
submitterRates: buildFirstPassSubmitterRates(submitterProjects.map((project) => ({
|
||||||
|
submitterName: project.submitterName,
|
||||||
|
currentStatus: project.status,
|
||||||
|
firstVersionStatus: project.versions[0]?.status || null,
|
||||||
|
}))),
|
||||||
items: projects.map((project) => {
|
items: projects.map((project) => {
|
||||||
const firstVersion = project.versions[0]
|
const firstVersion = project.versions[0]
|
||||||
return {
|
return {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user