merge: release BOM costing recovery
This commit is contained in:
commit
16b22dcd40
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ArrowRight, Megaphone } from 'lucide-react'
|
||||
import { ArrowRight, Megaphone, X } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import type { ReleaseNoteForUser } from '@/lib/release-notes'
|
||||
|
||||
@ -17,6 +17,9 @@ type ReleaseNotesResponse = {
|
||||
export function ReleaseNotesDialog({ userId, suppressed = false }: ReleaseNotesDialogProps) {
|
||||
const router = useRouter()
|
||||
const [unreadNotes, setUnreadNotes] = useState<ReleaseNoteForUser[]>([])
|
||||
const [dismissedForUserId, setDismissedForUserId] = useState<string | null>(null)
|
||||
const currentUserId = userId ?? null
|
||||
const dismissed = currentUserId !== null && dismissedForUserId === currentUserId
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || suppressed) {
|
||||
@ -39,54 +42,78 @@ export function ReleaseNotesDialog({ userId, suppressed = false }: ReleaseNotesD
|
||||
return () => controller.abort()
|
||||
}, [suppressed, userId])
|
||||
|
||||
if (!userId || suppressed || unreadNotes.length === 0) return null
|
||||
useEffect(() => {
|
||||
if (dismissed || unreadNotes.length === 0) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setDismissedForUserId(currentUserId)
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [currentUserId, dismissed, unreadNotes.length])
|
||||
|
||||
if (!userId || suppressed || dismissed || unreadNotes.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/35 p-4 sm:p-6">
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="release-notice-title"
|
||||
className="w-full max-w-[500px] overflow-hidden rounded-[8px] border border-black/[0.08] bg-white shadow-[0_24px_64px_rgba(15,23,42,0.24)]"
|
||||
>
|
||||
<header className="flex items-start gap-3 border-b border-black/[0.08] px-5 py-4 sm:px-6">
|
||||
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px] bg-[#eaf1f3] text-[#365d66]">
|
||||
<Megaphone className="h-[18px] w-[18px]" strokeWidth={1.8} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-[#667174]">系统更新</p>
|
||||
<h2 id="release-notice-title" className="mt-0.5 text-lg font-semibold text-[#252b2d]">
|
||||
有 {unreadNotes.length} 条更新待阅读
|
||||
</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-3 px-5 py-5 sm:px-6">
|
||||
{unreadNotes.map((note) => (
|
||||
<article key={note.id} className="border-l-2 border-[#7ba3aa] pl-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[#778184]">
|
||||
<time>{new Date(note.publishedAt).toLocaleDateString('zh-CN')}</time>
|
||||
<span className="rounded-[4px] bg-[#edf5f1] px-1.5 py-0.5 text-[#3b6a54]">
|
||||
{note.audienceLabel}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-1 text-sm font-semibold text-[#30383a]">{note.title}</h3>
|
||||
<p className="mt-1 text-sm leading-5 text-[#657073]">{note.summary}</p>
|
||||
</article>
|
||||
))}
|
||||
<aside
|
||||
aria-labelledby="release-notice-title"
|
||||
className="fixed bottom-4 right-4 z-[80] w-[min(24rem,calc(100vw-2rem))] overflow-hidden rounded-[12px] border border-black/[0.08] bg-white shadow-[0_16px_36px_rgba(15,23,42,0.18)]"
|
||||
>
|
||||
<header className="flex items-start gap-3 border-b border-black/[0.08] px-4 py-3.5">
|
||||
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] bg-[#eaf1f3] text-[#365d66]">
|
||||
<Megaphone className="h-4 w-4" strokeWidth={1.8} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-[#667174]">系统更新</p>
|
||||
<h2 id="release-notice-title" className="mt-0.5 text-sm font-semibold text-[#252b2d]">
|
||||
有 {unreadNotes.length} 条更新待阅读
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="暂不查看系统更新"
|
||||
onClick={() => setDismissedForUserId(currentUserId)}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] text-[#667174] transition hover:bg-[#f1f4f4] hover:text-[#30383a] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5b8791] focus-visible:ring-offset-2"
|
||||
>
|
||||
<X className="h-4 w-4" strokeWidth={1.8} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<footer className="flex justify-end border-t border-black/[0.08] px-5 py-4 sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/release-notes')}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-[6px] bg-[#2d565f] px-3.5 text-sm font-medium text-white transition hover:bg-[#254a52]"
|
||||
>
|
||||
查看更新
|
||||
<ArrowRight className="h-4 w-4" strokeWidth={1.8} />
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
<div className="max-h-[min(42vh,18rem)] space-y-3 overflow-y-auto px-4 py-4">
|
||||
{unreadNotes.map((note) => (
|
||||
<article key={note.id} className="border-l border-[#7ba3aa] pl-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[#778184]">
|
||||
<time>{new Date(note.publishedAt).toLocaleDateString('zh-CN')}</time>
|
||||
<span className="rounded-[4px] bg-[#edf5f1] px-1.5 py-0.5 text-[#3b6a54]">
|
||||
{note.audienceLabel}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-1 text-sm font-semibold text-[#30383a]">{note.title}</h3>
|
||||
<p className="mt-1 break-words text-sm leading-5 text-[#657073]">{note.summary}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-black/[0.08] px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissedForUserId(currentUserId)}
|
||||
className="inline-flex h-9 items-center rounded-[6px] px-3 text-sm font-medium text-[#526064] transition hover:bg-[#f1f4f4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5b8791] focus-visible:ring-offset-2"
|
||||
>
|
||||
暂不查看
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDismissedForUserId(currentUserId)
|
||||
router.push('/release-notes')
|
||||
}}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-[6px] bg-[#2d565f] px-3.5 text-sm font-medium text-white transition hover:bg-[#254a52] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5b8791] focus-visible:ring-offset-2"
|
||||
>
|
||||
查看更新
|
||||
<ArrowRight className="h-4 w-4" strokeWidth={1.8} />
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { ReleaseNotesDialog } from '../ReleaseNotesDialog'
|
||||
|
||||
const pushMock = jest.fn()
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
}))
|
||||
|
||||
const unreadNote = {
|
||||
id: 'release-1',
|
||||
title: 'BOM 核算已更新',
|
||||
summary: '现在可以直接查看自动形成的预估成本。',
|
||||
audienceLabel: '全体用户',
|
||||
publishedAt: '2026-08-04T00:00:00.000Z',
|
||||
acknowledged: false,
|
||||
}
|
||||
|
||||
describe('ReleaseNotesDialog', () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
pushMock.mockReset()
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: { notes: [unreadNote] },
|
||||
}),
|
||||
})) as typeof fetch
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('把未读更新作为可关闭提醒展示,不阻塞当前工作台', async () => {
|
||||
render(<ReleaseNotesDialog userId="user-1" />)
|
||||
|
||||
const reminder = await screen.findByRole('complementary')
|
||||
expect(reminder).toBeInTheDocument()
|
||||
expect(reminder).not.toHaveClass('inset-0')
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '暂不查看' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '暂不查看' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('支持用 Esc 暂时关闭提醒,并可跳转到更新页', async () => {
|
||||
render(<ReleaseNotesDialog userId="user-1" />)
|
||||
|
||||
expect(await screen.findByRole('complementary')).toBeInTheDocument()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
render(<ReleaseNotesDialog userId="user-2" />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看更新' }))
|
||||
expect(pushMock).toHaveBeenCalledWith('/release-notes')
|
||||
})
|
||||
})
|
||||
@ -139,4 +139,47 @@ describe('BOM 核算结果准备', () => {
|
||||
subtotal: 12,
|
||||
})
|
||||
})
|
||||
|
||||
it('资料库价格缺少计价所需尺寸时回退到可计算的模型备用估价', () => {
|
||||
const candidates = emptyCandidates()
|
||||
candidates.BOARD = [{
|
||||
libraryType: 'SUBSTRATE',
|
||||
recordId: 'rubberwood-area-price',
|
||||
code: 'RW-A',
|
||||
name: '橡胶木',
|
||||
spec: '18mm',
|
||||
unit: '㎡',
|
||||
unitPrice: 40,
|
||||
materialFamily: '橡胶木',
|
||||
pricingMode: 'AREA_SQM',
|
||||
}]
|
||||
const missingBoardDimensions: CompetitorAnalysisResultV1 = {
|
||||
...analysis,
|
||||
skus: analysis.skus.map((sku) => ({
|
||||
...sku,
|
||||
bomLines: sku.bomLines.map((line) =>
|
||||
line.category === 'BOARD'
|
||||
? {
|
||||
...line,
|
||||
lengthMm: undefined,
|
||||
widthMm: undefined,
|
||||
unit: '件',
|
||||
}
|
||||
: line,
|
||||
),
|
||||
})),
|
||||
}
|
||||
|
||||
const [sku] = prepareBomCostingAnalysis(missingBoardDimensions, candidates)
|
||||
const board = sku?.bomLines.find((line) => line.category === 'BOARD')
|
||||
|
||||
expect(sku?.estimatedCost).toBeGreaterThan(0)
|
||||
expect(board).toMatchObject({
|
||||
matchStatus: BomCostingMatchStatus.NEEDS_CONFIRMATION,
|
||||
matchedLibraryType: 'EXTERNAL_ANALYSIS_ESTIMATE',
|
||||
resolvedUnitPrice: 80,
|
||||
calculatedQuantity: 1,
|
||||
subtotal: 80,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -101,25 +101,34 @@ function prepareLine(
|
||||
? normalizePackagingKind(line.packagingKind)
|
||||
: null,
|
||||
})
|
||||
const matchedCandidate = libraryEstimate.candidate ?? externalEstimate
|
||||
const matchStatus = libraryEstimate.candidate
|
||||
const calculateWithCandidate = (
|
||||
candidate: BomCostingLibraryCandidate | null,
|
||||
) => calculateBomCostingLine({
|
||||
category: line.category,
|
||||
packagingKind: normalizePackagingKind(line.packagingKind),
|
||||
pricingMode: candidate?.pricingMode,
|
||||
lengthMm: line.lengthMm,
|
||||
widthMm: line.widthMm,
|
||||
heightMm: line.heightMm,
|
||||
quantity: line.quantity,
|
||||
unitPrice: candidate?.unitPrice,
|
||||
lossFactor,
|
||||
})
|
||||
const libraryCalculation = calculateWithCandidate(libraryEstimate.candidate)
|
||||
const canUseLibraryEstimate = libraryCalculation.subtotal !== null
|
||||
const matchedCandidate = canUseLibraryEstimate
|
||||
? libraryEstimate.candidate
|
||||
: externalEstimate
|
||||
const calculation = canUseLibraryEstimate
|
||||
? libraryCalculation
|
||||
: calculateWithCandidate(externalEstimate)
|
||||
const matchStatus = canUseLibraryEstimate && libraryEstimate.candidate
|
||||
? libraryEstimate.status
|
||||
: externalEstimate
|
||||
? line.category === 'PROCESSING' || line.category === 'LABOR'
|
||||
? 'MATCHED'
|
||||
: 'NEEDS_CONFIRMATION'
|
||||
: 'UNMATCHED'
|
||||
const calculation = calculateBomCostingLine({
|
||||
category: line.category,
|
||||
packagingKind: normalizePackagingKind(line.packagingKind),
|
||||
pricingMode: matchedCandidate?.pricingMode,
|
||||
lengthMm: line.lengthMm,
|
||||
widthMm: line.widthMm,
|
||||
heightMm: line.heightMm,
|
||||
quantity: line.quantity,
|
||||
unitPrice: matchedCandidate?.unitPrice,
|
||||
lossFactor,
|
||||
})
|
||||
|
||||
return {
|
||||
externalLineId: line.externalLineId,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user