feat(intake): restrict formal product workspace access
This commit is contained in:
parent
e8e83bc531
commit
08093bb748
@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getAuthTokenCookieOptions } from '@/server/auth/auth-cookie'
|
||||
import { resolveWorkspaceRedirectForUser } from '@/lib/workspace-route-permissions'
|
||||
import {
|
||||
dingtalkAuthService,
|
||||
resolveDingtalkCallbackOrigin,
|
||||
@ -30,7 +31,11 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const redirectPath = await dingtalkAuthService.resolveRedirectPath(state)
|
||||
const result = await dingtalkAuthService.authenticate(code)
|
||||
const finalPath = result.isFirstLogin ? '/first-login' : redirectPath
|
||||
const finalPath = result.isFirstLogin
|
||||
? '/first-login'
|
||||
: result.user
|
||||
? resolveWorkspaceRedirectForUser(redirectPath, result.user)
|
||||
: redirectPath
|
||||
const appOrigin = resolveDingtalkCallbackOrigin(request.nextUrl.origin)
|
||||
const response = NextResponse.redirect(new URL(finalPath, appOrigin))
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { getAuthTokenCookieOptions } from '@/server/auth/auth-cookie'
|
||||
import { authService, type LoginInput } from '@/server/services/auth.service'
|
||||
import { resolvePostLoginRedirect } from '@/server/services/auth-redirect.service'
|
||||
import { resolveWorkspaceRedirectForUser } from '@/lib/workspace-route-permissions'
|
||||
|
||||
const WINDOW_MS = 60 * 1000
|
||||
const MAX_ATTEMPTS_PER_WINDOW = 20
|
||||
@ -105,9 +106,10 @@ export async function POST(request: NextRequest) {
|
||||
password: body.password,
|
||||
})
|
||||
clearFailure(key)
|
||||
const resolvedRedirectPath = await resolvePostLoginRedirect(body.redirectPath)
|
||||
const redirectPath = result.isFirstLogin
|
||||
? '/first-login'
|
||||
: await resolvePostLoginRedirect(body.redirectPath)
|
||||
: resolveWorkspaceRedirectForUser(resolvedRedirectPath, result.user)
|
||||
|
||||
const response = jsonSuccess(
|
||||
{
|
||||
|
||||
@ -13,6 +13,18 @@ export default function AccessoryLibraryPanel() {
|
||||
changedEventName={ACCESSORY_LIBRARY_CHANGED_EVENT}
|
||||
entityName="配件"
|
||||
queryNamePlaceholder="例如:六角螺丝 / 平角合"
|
||||
extraFields={[
|
||||
{
|
||||
key: 'color',
|
||||
label: '颜色',
|
||||
placeholder: '例如:黑色 / 银色 / 胡桃色',
|
||||
},
|
||||
{
|
||||
key: 'material',
|
||||
label: '材质',
|
||||
placeholder: '例如:铁 / 不锈钢 / 塑料',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import AccessoryLibraryPanel from '@/app/intake/AccessoryLibraryPanel'
|
||||
import PackagingMaterialLibraryPanel from '@/app/intake/PackagingMaterialLibraryPanel'
|
||||
import { ACCESSORY_LIBRARY_STORAGE_KEY } from '@/lib/accessory-library'
|
||||
|
||||
describe('alphabet library panels', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('adds color and material fields to the accessory library only', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增当前字母' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增配件' })
|
||||
|
||||
expect(within(dialog).getByLabelText('颜色')).toBeInTheDocument()
|
||||
expect(within(dialog).getByLabelText('材质')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText(/配件名称/), {
|
||||
target: { value: '六角螺丝' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('颜色'), {
|
||||
target: { value: '黑色' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('材质'), {
|
||||
target: { value: '铁' },
|
||||
})
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('黑色')).toBeInTheDocument()
|
||||
expect(screen.getByText('铁')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const savedItems = JSON.parse(
|
||||
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
|
||||
)
|
||||
expect(savedItems[0]).toMatchObject({
|
||||
name: '六角螺丝',
|
||||
color: '黑色',
|
||||
material: '铁',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the packaging material library on its original fields', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<PackagingMaterialLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增当前字母' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增包材' })
|
||||
|
||||
expect(within(dialog).getByLabelText(/包材名称/)).toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('颜色')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('材质')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -21,6 +21,8 @@ type AlphabetLibraryItem = {
|
||||
letter: AlphabetLibraryLetter
|
||||
code: string
|
||||
name: string
|
||||
color?: string
|
||||
material?: string
|
||||
price: string
|
||||
imageUrl: string
|
||||
imageName: string
|
||||
@ -31,16 +33,27 @@ type AlphabetLibraryDraft = {
|
||||
letter: AlphabetLibraryLetter
|
||||
code: string
|
||||
name: string
|
||||
color: string
|
||||
material: string
|
||||
price: string
|
||||
imageUrl: string
|
||||
imageName: string
|
||||
}
|
||||
|
||||
type AlphabetLibraryExtraFieldKey = 'color' | 'material'
|
||||
|
||||
type AlphabetLibraryExtraFieldDefinition = {
|
||||
key: AlphabetLibraryExtraFieldKey
|
||||
label: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
interface AlphabetLibraryPanelProps {
|
||||
storageKey: string
|
||||
entityName: string
|
||||
queryNamePlaceholder?: string
|
||||
changedEventName?: string
|
||||
extraFields?: AlphabetLibraryExtraFieldDefinition[]
|
||||
}
|
||||
|
||||
function createItemId() {
|
||||
@ -75,6 +88,8 @@ function readLibraryItems(storageKey: string) {
|
||||
ALPHABET_LIBRARY_LETTERS.includes(item.letter as AlphabetLibraryLetter) &&
|
||||
typeof item.code === 'string' &&
|
||||
typeof item.name === 'string' &&
|
||||
(item.color === undefined || typeof item.color === 'string') &&
|
||||
(item.material === undefined || typeof item.material === 'string') &&
|
||||
typeof item.price === 'string' &&
|
||||
typeof item.imageUrl === 'string' &&
|
||||
typeof item.imageName === 'string' &&
|
||||
@ -124,6 +139,8 @@ function createDraft(items: AlphabetLibraryItem[], letter: AlphabetLibraryLetter
|
||||
letter,
|
||||
code: buildNextLibraryCode(items, letter),
|
||||
name: '',
|
||||
color: '',
|
||||
material: '',
|
||||
price: '',
|
||||
imageUrl: '',
|
||||
imageName: '',
|
||||
@ -135,6 +152,7 @@ export default function AlphabetLibraryPanel({
|
||||
entityName,
|
||||
queryNamePlaceholder,
|
||||
changedEventName,
|
||||
extraFields = [],
|
||||
}: AlphabetLibraryPanelProps) {
|
||||
const [items, setItems] = useState<AlphabetLibraryItem[]>(() => readLibraryItems(storageKey))
|
||||
const [selectedLetter, setSelectedLetter] = useState<AlphabetLibraryLetter>('A')
|
||||
@ -211,6 +229,13 @@ export default function AlphabetLibraryPanel({
|
||||
}, [groupedItems, hasActiveQuery, selectedLetter])
|
||||
|
||||
const currentLetterItems = groupedItems[activeLetter]
|
||||
const libraryGridTemplateColumns = extraFields.length
|
||||
? `130px minmax(180px,1.2fr) repeat(${extraFields.length}, minmax(110px,0.75fr)) 150px 180px`
|
||||
: '130px minmax(0,1.3fr) 150px 180px'
|
||||
const libraryGridMinWidth = extraFields.length ? 840 : undefined
|
||||
const libraryFieldSummary = ['编号', '名称', ...extraFields.map((field) => field.label), '图片', '价格'].join(
|
||||
'、',
|
||||
)
|
||||
|
||||
function applySearch() {
|
||||
setAppliedQueryCode(queryCode)
|
||||
@ -230,6 +255,8 @@ export default function AlphabetLibraryPanel({
|
||||
letter: item.letter,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
color: item.color ?? '',
|
||||
material: item.material ?? '',
|
||||
price: item.price,
|
||||
imageUrl: item.imageUrl,
|
||||
imageName: item.imageName,
|
||||
@ -289,6 +316,8 @@ export default function AlphabetLibraryPanel({
|
||||
|
||||
const normalizedCode = draft.code.trim().toUpperCase()
|
||||
const normalizedName = draft.name.trim()
|
||||
const normalizedColor = draft.color.trim()
|
||||
const normalizedMaterial = draft.material.trim()
|
||||
const normalizedPrice = draft.price.trim()
|
||||
|
||||
if (!normalizedCode) {
|
||||
@ -316,6 +345,8 @@ export default function AlphabetLibraryPanel({
|
||||
letter: draft.letter,
|
||||
code: normalizedCode,
|
||||
name: normalizedName,
|
||||
color: normalizedColor,
|
||||
material: normalizedMaterial,
|
||||
price: normalizedPrice,
|
||||
imageUrl: draft.imageUrl,
|
||||
imageName: draft.imageName,
|
||||
@ -452,7 +483,7 @@ export default function AlphabetLibraryPanel({
|
||||
{activeLetter} 组{entityName}
|
||||
</h4>
|
||||
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">
|
||||
当前显示 {currentLetterItems.length} 条数据,字段固定为编号、名称、图片、价格。
|
||||
当前显示 {currentLetterItems.length} 条数据,字段为{libraryFieldSummary}。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@ -467,20 +498,34 @@ export default function AlphabetLibraryPanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[130px_minmax(0,1.3fr)_150px_180px] gap-3 rounded-apple-lg bg-apple-bg-secondary/70 px-3 py-2 text-xs font-apple-semibold text-apple-label-secondary">
|
||||
<span>编号</span>
|
||||
<span>名称</span>
|
||||
<span>图片</span>
|
||||
<span>价格</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto pb-1">
|
||||
<div
|
||||
className="grid gap-3 rounded-apple-lg bg-apple-bg-secondary/70 px-3 py-2 text-xs font-apple-semibold text-apple-label-secondary"
|
||||
style={{
|
||||
gridTemplateColumns: libraryGridTemplateColumns,
|
||||
minWidth: libraryGridMinWidth,
|
||||
}}
|
||||
>
|
||||
<span>编号</span>
|
||||
<span>名称</span>
|
||||
{extraFields.map((field) => (
|
||||
<span key={field.key}>{field.label}</span>
|
||||
))}
|
||||
<span>图片</span>
|
||||
<span>价格</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
{currentLetterItems.length ? (
|
||||
currentLetterItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid grid-cols-[130px_minmax(0,1.3fr)_150px_180px] gap-3 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/30 px-3 py-3"
|
||||
>
|
||||
<div className="mt-3 space-y-3">
|
||||
{currentLetterItems.length ? (
|
||||
currentLetterItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid gap-3 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/30 px-3 py-3"
|
||||
style={{
|
||||
gridTemplateColumns: libraryGridTemplateColumns,
|
||||
minWidth: libraryGridMinWidth,
|
||||
}}
|
||||
>
|
||||
<div className="rounded-apple-md bg-apple-bg-primary px-3 py-2">
|
||||
<p className="text-sm font-apple-semibold text-apple-label-primary">{item.code}</p>
|
||||
<p className="mt-1 text-[11px] text-apple-label-secondary">{item.letter} 组</p>
|
||||
@ -493,6 +538,21 @@ export default function AlphabetLibraryPanel({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{extraFields.map((field) => (
|
||||
<div
|
||||
key={`${item.id}-${field.key}`}
|
||||
className="rounded-apple-md bg-apple-bg-primary px-3 py-2"
|
||||
>
|
||||
<p
|
||||
className={`text-sm font-apple-medium ${
|
||||
item[field.key] ? 'text-apple-label-primary' : 'text-amber-700'
|
||||
}`}
|
||||
>
|
||||
{item[field.key] || `待补${field.label}`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="rounded-apple-md bg-apple-bg-primary px-3 py-2">
|
||||
{item.imageUrl ? (
|
||||
<button
|
||||
@ -548,20 +608,21 @@ export default function AlphabetLibraryPanel({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/20 px-6 py-12 text-center">
|
||||
<p className="text-sm font-apple-medium text-apple-label-primary">
|
||||
{hasActiveQuery ? '当前筛选条件下没有结果' : `${activeLetter} 组还没有${entityName}`}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-5 text-apple-label-secondary">
|
||||
{hasActiveQuery
|
||||
? '你可以换查询词继续查。'
|
||||
: '点击“新增当前字母”即可往这个字母下面继续补资料。'}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/20 px-6 py-12 text-center">
|
||||
<p className="text-sm font-apple-medium text-apple-label-primary">
|
||||
{hasActiveQuery ? '当前筛选条件下没有结果' : `${activeLetter} 组还没有${entityName}`}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-5 text-apple-label-secondary">
|
||||
{hasActiveQuery
|
||||
? '你可以换查询词继续查。'
|
||||
: '点击“新增当前字母”即可往这个字母下面继续补资料。'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -653,6 +714,22 @@ export default function AlphabetLibraryPanel({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{extraFields.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{extraFields.map((field) => (
|
||||
<Input
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
value={draft[field.key]}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, [field.key]: event.target.value })
|
||||
}
|
||||
placeholder={field.placeholder ?? `请输入${field.label}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/30 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
getNonIntegratedDocumentWorkspaceCategoryLabel,
|
||||
NON_INTEGRATED_BRAND_PREFIX_MAP,
|
||||
NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES,
|
||||
NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES,
|
||||
NON_INTEGRATED_STORAGE_KEY,
|
||||
} from './non-integrated/nonIntegratedStorage'
|
||||
import {
|
||||
@ -469,6 +470,41 @@ function createPatentApprovedProduct(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function createApprovedWorkflowStateBefore(targetCategory: string) {
|
||||
const targetIndex = NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES.findIndex(
|
||||
(category) => category === targetCategory,
|
||||
)
|
||||
const hiddenListCategories = new Set(['立项评审', '检测报告'])
|
||||
const readyCategories = NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES.slice(
|
||||
0,
|
||||
targetIndex < 0 ? 0 : targetIndex,
|
||||
).filter((category) => !hiddenListCategories.has(category))
|
||||
const uploadFiles = readyCategories
|
||||
.filter((category) =>
|
||||
NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES.includes(
|
||||
category as (typeof NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES)[number],
|
||||
),
|
||||
)
|
||||
.map((category, index) => ({
|
||||
id: `ready-file-${index + 1}`,
|
||||
name: `ready-${index + 1}.pdf`,
|
||||
category,
|
||||
dataUrl: 'data:application/pdf;base64,ready',
|
||||
mimeType: 'application/pdf',
|
||||
}))
|
||||
|
||||
return {
|
||||
uploadFiles,
|
||||
documentWorkspaceConfirmations: createDefaultDocumentWorkspaceConfirmations(),
|
||||
documentWorkspaceApprovedAt: Object.fromEntries(
|
||||
readyCategories.map((category, index) => [
|
||||
category,
|
||||
`2026-05-01T00:${String(index).padStart(2, '0')}:00.000Z`,
|
||||
]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
describe('IntakeClient', () => {
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers()
|
||||
@ -490,7 +526,7 @@ describe('IntakeClient', () => {
|
||||
const rightPanel = screen.getByTestId('right-panel')
|
||||
expect(within(rightPanel).getByRole('button', { name: '产品开发' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: /产品开发进度/ })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: 'BOM资料' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: 'BOM资料库' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: '原材料资料库' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: '板材资料库' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: '钢架资料库' })).toBeInTheDocument()
|
||||
@ -1266,6 +1302,59 @@ describe('IntakeClient', () => {
|
||||
expect(within(targetRow as HTMLElement).getByDisplayValue('APP-TARGET')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('limits purchasing users to the target product patent record from detail', async () => {
|
||||
const actor = userEvent.setup()
|
||||
const products = [
|
||||
createPatentApprovedProduct({
|
||||
id: 'target-patent-workspace',
|
||||
spu: 'TARGET-SPU',
|
||||
purchaser: 'Purchasing A',
|
||||
purchaseOwner: 'Purchasing A',
|
||||
patentApplication: createPatentApplicationContent({
|
||||
patentName: 'Target Patent',
|
||||
applicationNo: 'APP-TARGET',
|
||||
patentNo: 'PAT-TARGET',
|
||||
}),
|
||||
}),
|
||||
createPatentApprovedProduct({
|
||||
id: 'other-patent-workspace',
|
||||
spu: 'OTHER-SPU',
|
||||
patentApplication: createPatentApplicationContent({
|
||||
patentName: 'Other Patent',
|
||||
applicationNo: 'APP-OTHER',
|
||||
patentNo: 'PAT-OTHER',
|
||||
}),
|
||||
}),
|
||||
]
|
||||
window.localStorage.setItem(
|
||||
NON_INTEGRATED_STORAGE_KEY,
|
||||
JSON.stringify(products),
|
||||
)
|
||||
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
initialActiveSection: 'pending',
|
||||
initialNonIntegratedProducts: products,
|
||||
userRole: 'USER',
|
||||
userName: 'Purchasing A',
|
||||
userBusinessRoles: ['PURCHASING'],
|
||||
userDepartmentPaths: ['供应链中心 / 采购部'],
|
||||
})
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '打开TARGET-SPU工作页' }))
|
||||
await actor.click(
|
||||
within(screen.getByTestId('embedded-non-integrated-detail')).getAllByRole(
|
||||
'button',
|
||||
)[1],
|
||||
)
|
||||
|
||||
expect(screen.getByRole('tab', { name: '产品专利明细' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('筛选SPU')).toHaveValue('TARGET-SPU')
|
||||
expect(screen.getAllByDisplayValue('PAT-TARGET').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByDisplayValue('OTHER-SPU')).not.toBeInTheDocument()
|
||||
expect(screen.queryByDisplayValue('PAT-OTHER')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows running total days and node elapsed days in the product progress table', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2026-04-29T00:00:00.000Z') })
|
||||
const actor = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
|
||||
@ -1415,7 +1504,7 @@ describe('IntakeClient', () => {
|
||||
|
||||
const rightPanel = screen.getByTestId('right-panel')
|
||||
|
||||
await actor.click(within(rightPanel).getByRole('button', { name: 'BOM资料' }))
|
||||
await actor.click(within(rightPanel).getByRole('button', { name: 'BOM资料库' }))
|
||||
|
||||
expect(screen.getByText('material-library-copy')).toBeInTheDocument()
|
||||
|
||||
@ -1670,6 +1759,26 @@ describe('IntakeClient', () => {
|
||||
expect(screen.getByRole('button', { name: '新增产品' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides design controlled product data entry points from purchasing users', () => {
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
userRole: 'USER',
|
||||
userBusinessRoles: ['PURCHASING'],
|
||||
userDepartment: {
|
||||
id: 'purchase-department',
|
||||
name: '采购部',
|
||||
fullPath: '供应链中心 / 采购部',
|
||||
},
|
||||
userDepartmentPaths: ['供应链中心 / 采购部'],
|
||||
})
|
||||
|
||||
const rightPanel = screen.getByTestId('right-panel')
|
||||
expect(within(rightPanel).getByRole('button', { name: '产品开发明细' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).queryByRole('button', { name: 'BOM资料库' })).not.toBeInTheDocument()
|
||||
expect(within(rightPanel).queryByRole('button', { name: '产品专利' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新增产品' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the series development list with the same product detail capabilities', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
@ -1880,7 +1989,7 @@ describe('IntakeClient', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides unrelated products from non-matching designers in the created scope', async () => {
|
||||
it('hides unrelated products from non-matching designers in the responsible scope', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
window.localStorage.setItem(
|
||||
@ -1896,7 +2005,7 @@ describe('IntakeClient', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByText('DNZ0001')).toBeInTheDocument()
|
||||
await actor.click(screen.getByRole('button', { name: '我创建的' }))
|
||||
await actor.click(screen.getByRole('button', { name: '我负责的' }))
|
||||
|
||||
expect(screen.queryByText('DNZ0001')).not.toBeInTheDocument()
|
||||
})
|
||||
@ -1960,7 +2069,7 @@ describe('IntakeClient', () => {
|
||||
expect(actionCell?.querySelectorAll('button')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('filters created products by the current DingTalk account name', async () => {
|
||||
it('filters responsible products by the current DingTalk account name', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
window.localStorage.setItem(
|
||||
@ -1970,13 +2079,21 @@ describe('IntakeClient', () => {
|
||||
id: 'caishen-created',
|
||||
spu: 'CS0001',
|
||||
createdBy: '财神',
|
||||
designer: '',
|
||||
designer: '财神',
|
||||
openingReference: {
|
||||
...createEmptyNonIntegratedOpeningReference(),
|
||||
designer: '财神',
|
||||
},
|
||||
}),
|
||||
createNonIntegratedProduct({
|
||||
id: 'xuxiake-created',
|
||||
spu: 'XXK0001',
|
||||
createdBy: '徐霞客',
|
||||
designer: '',
|
||||
designer: '徐霞客',
|
||||
openingReference: {
|
||||
...createEmptyNonIntegratedOpeningReference(),
|
||||
designer: '徐霞客',
|
||||
},
|
||||
}),
|
||||
]),
|
||||
)
|
||||
@ -1988,12 +2105,163 @@ describe('IntakeClient', () => {
|
||||
userName: '财神',
|
||||
})
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '我创建的' }))
|
||||
await actor.click(screen.getByRole('button', { name: '我负责的' }))
|
||||
|
||||
expect(screen.getByText('CS0001')).toBeInTheDocument()
|
||||
expect(screen.queryByText('XXK0001')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters responsible products by purchase owner for purchasing users', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
window.localStorage.setItem(
|
||||
NON_INTEGRATED_STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
createNonIntegratedProduct({
|
||||
id: 'purchase-owner-a',
|
||||
spu: 'BUY0001',
|
||||
purchaser: 'Purchasing A',
|
||||
purchaseOwner: 'Purchasing A',
|
||||
}),
|
||||
createNonIntegratedProduct({
|
||||
id: 'purchase-owner-b',
|
||||
spu: 'BUY0002',
|
||||
purchaser: 'Purchasing B',
|
||||
purchaseOwner: 'Purchasing B',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
userRole: 'USER',
|
||||
userName: 'Purchasing A',
|
||||
userBusinessRoles: ['PURCHASING'],
|
||||
userDepartmentPaths: ['供应链中心 > 采购部'],
|
||||
})
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '我负责的' }))
|
||||
|
||||
expect(screen.getByText('BUY0001')).toBeInTheDocument()
|
||||
expect(screen.queryByText('BUY0002')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters responsible products by space designer for space visual users', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
window.localStorage.setItem(
|
||||
NON_INTEGRATED_STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
createNonIntegratedProduct({
|
||||
id: 'space-owner-a',
|
||||
spu: 'SPACE0001',
|
||||
visualPlanning: 'Space A',
|
||||
visualDirector: 'Space A',
|
||||
designer: 'Designer A',
|
||||
}),
|
||||
createNonIntegratedProduct({
|
||||
id: 'space-owner-b',
|
||||
spu: 'SPACE0002',
|
||||
visualPlanning: 'Space B',
|
||||
visualDirector: 'Space B',
|
||||
designer: 'Space A',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
userRole: 'USER',
|
||||
userName: 'Space A',
|
||||
userBusinessRoles: ['DESIGN'],
|
||||
userDepartmentPaths: ['品牌中心 > 空间视觉部'],
|
||||
})
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '我负责的' }))
|
||||
|
||||
expect(screen.getByText('SPACE0001')).toBeInTheDocument()
|
||||
expect(screen.queryByText('SPACE0002')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters responsible products by operation owner for operation users', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
window.localStorage.setItem(
|
||||
NON_INTEGRATED_STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
createNonIntegratedProduct({
|
||||
id: 'operation-owner-a',
|
||||
spu: 'OPS0001',
|
||||
spuOwner: 'Operation A',
|
||||
operationsOwner: 'Operation A',
|
||||
operator: 'Operation A',
|
||||
}),
|
||||
createNonIntegratedProduct({
|
||||
id: 'operation-owner-b',
|
||||
spu: 'OPS0002',
|
||||
designer: 'Operation A',
|
||||
spuOwner: 'Operation B',
|
||||
operationsOwner: 'Operation B',
|
||||
operator: 'Operation B',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
userRole: 'USER',
|
||||
userName: 'Operation A',
|
||||
userBusinessRoles: [],
|
||||
userDepartmentPaths: ['运营中心 > 事业一部'],
|
||||
})
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '我负责的' }))
|
||||
|
||||
expect(screen.getByText('OPS0001')).toBeInTheDocument()
|
||||
expect(screen.queryByText('OPS0002')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows pending products only when the current node belongs to the user', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
window.localStorage.setItem(
|
||||
NON_INTEGRATED_STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
createNonIntegratedProduct({
|
||||
...createApprovedWorkflowStateBefore('产品定厂'),
|
||||
id: 'purchase-factory-pending',
|
||||
spu: 'BUY-PENDING',
|
||||
purchaser: 'Purchasing A',
|
||||
purchaseOwner: 'Purchasing A',
|
||||
}),
|
||||
createNonIntegratedProduct({
|
||||
id: 'purchase-owner-but-design-pending',
|
||||
spu: 'BUY-NOT-YET',
|
||||
purchaser: 'Purchasing A',
|
||||
purchaseOwner: 'Purchasing A',
|
||||
designer: 'Designer A',
|
||||
openingReference: {
|
||||
...createEmptyNonIntegratedOpeningReference(),
|
||||
designer: 'Designer A',
|
||||
},
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
renderSelector({
|
||||
eligibleSkuList: [],
|
||||
userRole: 'USER',
|
||||
userName: 'Purchasing A',
|
||||
userBusinessRoles: ['PURCHASING'],
|
||||
userDepartmentPaths: ['供应链中心 > 采购部'],
|
||||
})
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '待处理' }))
|
||||
|
||||
expect(screen.getByText('BUY-PENDING')).toBeInTheDocument()
|
||||
expect(screen.queryByText('BUY-NOT-YET')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets planning users open products for opening-reference editing only', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
@ -2930,7 +3198,7 @@ describe('IntakeClient', () => {
|
||||
'aria-pressed',
|
||||
'true',
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '我创建的' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '我负责的' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '待处理' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '待处理的' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('DNZ0001')).toBeInTheDocument()
|
||||
|
||||
@ -84,8 +84,10 @@ import {
|
||||
import {
|
||||
canEditAnyNonIntegratedWorkspace,
|
||||
canEditNonIntegratedProductIdentity,
|
||||
canEditNonIntegratedWorkspace,
|
||||
canDeleteNonIntegratedProductByIntakePermission,
|
||||
isIntakeNonIntegratedDesignDepartmentUser,
|
||||
isIntakeNonIntegratedPricingGroupUser,
|
||||
isIntakeNonIntegratedQualityGroupUser,
|
||||
type IntakeNonIntegratedPermissionUser,
|
||||
} from "@/lib/non-integrated-permissions";
|
||||
import {
|
||||
@ -191,6 +193,16 @@ function canManageSpuRuleLibrary(role: string): boolean {
|
||||
return role === "SUPER_ADMIN" || role === "ADMIN";
|
||||
}
|
||||
|
||||
function canAccessDesignControlledProductData(
|
||||
user: IntakeProductPermissionUser,
|
||||
): boolean {
|
||||
if (user.role === "SUPER_ADMIN" || user.role === "ADMIN") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isIntakeNonIntegratedDesignDepartmentUser(user);
|
||||
}
|
||||
|
||||
function canEditNonIntegratedProduct(
|
||||
product: NonIntegratedProductRecord,
|
||||
user: IntakeProductPermissionUser,
|
||||
@ -569,25 +581,90 @@ function getLegacyNonIntegratedProductOwnerNames(
|
||||
]);
|
||||
}
|
||||
|
||||
function isProductCreatedByCurrentUser(
|
||||
function matchesCurrentUserName(
|
||||
values: Array<string | null | undefined>,
|
||||
user: IntakeProductPermissionUser,
|
||||
) {
|
||||
const normalizedUserName = safeTrim(user.name).replace(/\s+/g, "").toLowerCase();
|
||||
if (!normalizedUserName) return false;
|
||||
|
||||
return values.some(
|
||||
(value) =>
|
||||
safeTrim(value).replace(/\s+/g, "").toLowerCase() === normalizedUserName,
|
||||
);
|
||||
}
|
||||
|
||||
function hasBusinessRole(user: IntakeProductPermissionUser, role: string) {
|
||||
return Boolean(user.businessRoles?.includes(role));
|
||||
}
|
||||
|
||||
function isAdminUser(user: IntakeProductPermissionUser) {
|
||||
return user.role === "SUPER_ADMIN" || user.role === "ADMIN";
|
||||
}
|
||||
|
||||
function getUserDepartmentText(user: IntakeProductPermissionUser) {
|
||||
return [
|
||||
user.department?.name,
|
||||
user.department?.fullPath,
|
||||
...(user.departmentPaths ?? []),
|
||||
]
|
||||
.map((value) => value?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function isSpaceDesignUser(user: IntakeProductPermissionUser) {
|
||||
const departmentText = getUserDepartmentText(user);
|
||||
|
||||
return hasBusinessRole(user, "DESIGN") && /空间|视觉/.test(departmentText);
|
||||
}
|
||||
|
||||
function isOperationsOwnerUser(user: IntakeProductPermissionUser) {
|
||||
return /运营/.test(getUserDepartmentText(user));
|
||||
}
|
||||
|
||||
function getCurrentUserResponsibleOwnerValues(
|
||||
product: NonIntegratedProductRecord,
|
||||
user: IntakeProductPermissionUser,
|
||||
) {
|
||||
const userId = user.id.trim();
|
||||
const userName = user.name.trim();
|
||||
const creator = product.createdBy?.trim() ?? "";
|
||||
const people = getNonIntegratedListPeople(product);
|
||||
const scopedValues: string[] = [];
|
||||
|
||||
if (
|
||||
creator &&
|
||||
((userId && creator === userId) || (userName && creator === userName))
|
||||
) {
|
||||
if (hasBusinessRole(user, "DESIGN")) {
|
||||
if (isSpaceDesignUser(user)) {
|
||||
scopedValues.push(people.spaceDesigner);
|
||||
} else {
|
||||
scopedValues.push(people.appearanceDesigner, people.structureDesigner);
|
||||
}
|
||||
}
|
||||
if (hasBusinessRole(user, "PLANNING")) {
|
||||
scopedValues.push(people.planningOwner);
|
||||
}
|
||||
if (hasBusinessRole(user, "PURCHASING")) {
|
||||
scopedValues.push(people.purchaseOwner);
|
||||
}
|
||||
if (isOperationsOwnerUser(user)) {
|
||||
scopedValues.push(product.spuOwner, product.operator, product.operationsOwner);
|
||||
}
|
||||
|
||||
if (scopedValues.length > 0) {
|
||||
return scopedValues;
|
||||
}
|
||||
|
||||
return getLegacyNonIntegratedProductOwnerNames(product);
|
||||
}
|
||||
|
||||
function isCurrentUserResponsibleForNonIntegratedProduct(
|
||||
product: NonIntegratedProductRecord,
|
||||
user: IntakeProductPermissionUser,
|
||||
) {
|
||||
if (isAdminUser(user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
!creator &&
|
||||
userName &&
|
||||
getLegacyNonIntegratedProductOwnerNames(product).includes(userName),
|
||||
return matchesCurrentUserName(
|
||||
getCurrentUserResponsibleOwnerValues(product, user),
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1283,6 +1360,30 @@ function readInitialIntakeWorkspaceState(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function isDesignControlledWorkspaceTab(tab: IntakeWorkspaceTab) {
|
||||
if (tab.kind === "newNonIntegratedProduct") return true;
|
||||
if (tab.kind === "bomLibrary") return true;
|
||||
if (tab.kind === "productPatentDashboard") return true;
|
||||
return tab.kind === "productPatentLedger" && !tab.target;
|
||||
}
|
||||
|
||||
function restrictWorkspaceStateForBusinessUser(
|
||||
state: InitialIntakeWorkspaceState,
|
||||
): InitialIntakeWorkspaceState {
|
||||
const tabs = state.tabs.filter((tab) => !isDesignControlledWorkspaceTab(tab));
|
||||
const safeTabs =
|
||||
tabs.length > 0 ? tabs : createDefaultIntakeWorkspaceState().tabs;
|
||||
const safeActiveTabId = safeTabs.some((tab) => tab.id === state.activeTabId)
|
||||
? state.activeTabId
|
||||
: (safeTabs[0]?.id ?? null);
|
||||
|
||||
return {
|
||||
tabs: safeTabs,
|
||||
activeTabId: safeActiveTabId,
|
||||
sequence: getMaxWorkspaceTabSequence(safeTabs),
|
||||
};
|
||||
}
|
||||
|
||||
function persistIntakeWorkspaceState(params: {
|
||||
activeTabId: string | null;
|
||||
shouldPersist: boolean;
|
||||
@ -1305,6 +1406,11 @@ function persistIntakeWorkspaceState(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function restrictSectionForBusinessUser(section: IntakeSection): IntakeSection {
|
||||
if (section === "bom" || section === "patents") return "pending";
|
||||
return section;
|
||||
}
|
||||
|
||||
function getWorkspaceTabInitialSection(tab: IntakeWorkspaceTab | null) {
|
||||
if (!tab) return "pending";
|
||||
if (
|
||||
@ -1388,7 +1494,7 @@ function resolveWorkspaceTabsForOpen(
|
||||
function getBomLibraryLabel(library: BomLibraryKey) {
|
||||
return (
|
||||
BOM_LIBRARY_OPTIONS.find((option) => option.key === library)?.label ??
|
||||
"BOM资料"
|
||||
"BOM资料库"
|
||||
);
|
||||
}
|
||||
|
||||
@ -2257,6 +2363,81 @@ function getCurrentNodeCategoryForList(
|
||||
);
|
||||
}
|
||||
|
||||
function isCurrentUserResponsibleForCurrentNode(
|
||||
product: NonIntegratedProductRecord,
|
||||
user: IntakeProductPermissionUser,
|
||||
category: NonIntegratedDocumentStatusCategory,
|
||||
) {
|
||||
if (isAdminUser(user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const people = getNonIntegratedListPeople(product);
|
||||
|
||||
if (
|
||||
category === "开品参考" ||
|
||||
category === "设计卖点" ||
|
||||
category === "专利申请"
|
||||
) {
|
||||
return matchesCurrentUserName([people.appearanceDesigner], user);
|
||||
}
|
||||
|
||||
if (category === "产品模型") {
|
||||
return matchesCurrentUserName(
|
||||
[people.appearanceDesigner, people.structureDesigner],
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
if (category === "评审资料") {
|
||||
return matchesCurrentUserName(
|
||||
[people.appearanceDesigner, people.structureDesigner, people.purchaseOwner],
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
category === NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY ||
|
||||
category === "产品实拍图" ||
|
||||
category === "安装方式" ||
|
||||
category === "包装细节"
|
||||
) {
|
||||
return matchesCurrentUserName([people.structureDesigner], user);
|
||||
}
|
||||
|
||||
if (category === "企划布局") {
|
||||
return matchesCurrentUserName([people.spaceDesigner], user);
|
||||
}
|
||||
|
||||
if (category === "产品定厂") {
|
||||
return matchesCurrentUserName([people.purchaseOwner], user);
|
||||
}
|
||||
|
||||
if (category === "样包测量" || category === "产品核价") {
|
||||
return (
|
||||
matchesCurrentUserName([people.purchaseOwner], user) ||
|
||||
isIntakeNonIntegratedPricingGroupUser(user)
|
||||
);
|
||||
}
|
||||
|
||||
if (category === "成品寄样") {
|
||||
return matchesCurrentUserName(
|
||||
[people.planningOwner, people.purchaseOwner],
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
if (category === "产品限价") {
|
||||
return matchesCurrentUserName([people.planningOwner], user);
|
||||
}
|
||||
|
||||
if (category === "工厂质检") {
|
||||
return isIntakeNonIntegratedQualityGroupUser(user);
|
||||
}
|
||||
|
||||
return isCurrentUserResponsibleForNonIntegratedProduct(product, user);
|
||||
}
|
||||
|
||||
function isCurrentUserPendingNonIntegratedProduct(
|
||||
product: NonIntegratedProductRecord,
|
||||
user: IntakeProductPermissionUser,
|
||||
@ -2271,7 +2452,7 @@ function isCurrentUserPendingNonIntegratedProduct(
|
||||
);
|
||||
if (!currentCategory) return false;
|
||||
|
||||
return canEditNonIntegratedWorkspace(product, user, currentCategory);
|
||||
return isCurrentUserResponsibleForCurrentNode(product, user, currentCategory);
|
||||
}
|
||||
|
||||
function isDocumentWorkspaceApprovedForList(
|
||||
@ -3164,7 +3345,10 @@ function NonIntegratedPendingDetail({
|
||||
const quickScopeMatched =
|
||||
activeQuickScope === "all" ||
|
||||
(activeQuickScope === "created"
|
||||
? isProductCreatedByCurrentUser(product, currentUser)
|
||||
? isCurrentUserResponsibleForNonIntegratedProduct(
|
||||
product,
|
||||
currentUser,
|
||||
)
|
||||
: isCurrentUserPendingNonIntegratedProduct(
|
||||
product,
|
||||
currentUser,
|
||||
@ -3391,7 +3575,7 @@ function NonIntegratedPendingDetail({
|
||||
: productFilterFields;
|
||||
const quickScopes: Array<{ key: NonIntegratedQuickScope; label: string }> = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "created", label: "我创建的" },
|
||||
{ key: "created", label: "我负责的" },
|
||||
{ key: "pending", label: "待处理" },
|
||||
];
|
||||
const listColumnCount =
|
||||
@ -8626,6 +8810,7 @@ function FormalIntakeWorkbench({
|
||||
onOpenProductPatentLedger,
|
||||
onOpenProductPatentDashboard,
|
||||
onOpenSpuRuleLibraryWorkspace,
|
||||
showDesignControlledProductData,
|
||||
}: {
|
||||
activeSection: IntakeSection;
|
||||
onToggle: (section: Exclude<IntakeSection, null>) => void;
|
||||
@ -8641,6 +8826,7 @@ function FormalIntakeWorkbench({
|
||||
onOpenProductPatentLedger: () => void;
|
||||
onOpenProductPatentDashboard: () => void;
|
||||
onOpenSpuRuleLibraryWorkspace?: () => void;
|
||||
showDesignControlledProductData: boolean;
|
||||
}) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const pendingEntryOptions: { key: IntakeEntryTab; label: string }[] = [
|
||||
@ -8711,37 +8897,39 @@ function FormalIntakeWorkbench({
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection
|
||||
title="BOM资料"
|
||||
compact
|
||||
icon={
|
||||
<FormalSidebarIcon
|
||||
icon={FORMAL_SIDEBAR_GROUP_ICONS.bom}
|
||||
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
|
||||
/>
|
||||
}
|
||||
expanded={activeSection === "bom"}
|
||||
onToggle={() => onToggle("bom")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{BOM_LIBRARY_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onBomLibraryChange(option.key);
|
||||
}}
|
||||
className={`flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
|
||||
activeSection === "bom" && activeBomLibrary === option.key
|
||||
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
|
||||
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
|
||||
}`}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
{showDesignControlledProductData ? (
|
||||
<AccordionSection
|
||||
title="BOM资料库"
|
||||
compact
|
||||
icon={
|
||||
<FormalSidebarIcon
|
||||
icon={FORMAL_SIDEBAR_GROUP_ICONS.bom}
|
||||
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
|
||||
/>
|
||||
}
|
||||
expanded={activeSection === "bom"}
|
||||
onToggle={() => onToggle("bom")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{BOM_LIBRARY_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onBomLibraryChange(option.key);
|
||||
}}
|
||||
className={`flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
|
||||
activeSection === "bom" && activeBomLibrary === option.key
|
||||
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
|
||||
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
|
||||
}`}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
) : null}
|
||||
|
||||
<AccordionSection
|
||||
title="原材料资料库"
|
||||
@ -8813,41 +9001,43 @@ function FormalIntakeWorkbench({
|
||||
</button>
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection
|
||||
title="产品专利"
|
||||
compact
|
||||
icon={
|
||||
<FormalSidebarIcon
|
||||
icon={FORMAL_SIDEBAR_GROUP_ICONS.patents}
|
||||
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
|
||||
/>
|
||||
}
|
||||
expanded={activeSection === "patents"}
|
||||
onToggle={onOpenProductPatents}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenProductPatentLedger}
|
||||
className={`flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
|
||||
activeSection === "patents"
|
||||
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
|
||||
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
|
||||
}`}
|
||||
{showDesignControlledProductData ? (
|
||||
<AccordionSection
|
||||
title="产品专利"
|
||||
compact
|
||||
icon={
|
||||
<FormalSidebarIcon
|
||||
icon={FORMAL_SIDEBAR_GROUP_ICONS.patents}
|
||||
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
|
||||
/>
|
||||
}
|
||||
expanded={activeSection === "patents"}
|
||||
onToggle={onOpenProductPatents}
|
||||
>
|
||||
<span>产品专利明细</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenProductPatentDashboard}
|
||||
className={`mt-2 flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
|
||||
activeSection === "patents"
|
||||
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
|
||||
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
|
||||
}`}
|
||||
>
|
||||
<span>{PRODUCT_PATENT_DASHBOARD_LABEL}</span>
|
||||
</button>
|
||||
</AccordionSection>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenProductPatentLedger}
|
||||
className={`flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
|
||||
activeSection === "patents"
|
||||
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
|
||||
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
|
||||
}`}
|
||||
>
|
||||
<span>产品专利明细</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenProductPatentDashboard}
|
||||
className={`mt-2 flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
|
||||
activeSection === "patents"
|
||||
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
|
||||
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary"
|
||||
}`}
|
||||
>
|
||||
<span>{PRODUCT_PATENT_DASHBOARD_LABEL}</span>
|
||||
</button>
|
||||
</AccordionSection>
|
||||
) : null}
|
||||
|
||||
{onOpenSpuRuleLibraryWorkspace ? (
|
||||
<button
|
||||
@ -8875,6 +9065,8 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
const canDeleteNonIntegratedProducts = canDeleteIntakeProducts(
|
||||
props.user.role,
|
||||
);
|
||||
const canUseDesignControlledProductData =
|
||||
canAccessDesignControlledProductData(props.user);
|
||||
const shouldPersistWorkspaceState = !isEditorMode(props);
|
||||
const shouldUseSharedNonIntegratedApi = process.env.NODE_ENV !== "test";
|
||||
const [initialWorkspaceState] = useState(() =>
|
||||
@ -8887,8 +9079,13 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
initialWorkspaceState.tabs[0] ??
|
||||
null;
|
||||
const [activeSection, setActiveSection] = useState<IntakeSection>(
|
||||
props.initialActiveSection ??
|
||||
getWorkspaceTabInitialSection(initialActiveWorkspaceTab),
|
||||
canUseDesignControlledProductData
|
||||
? (props.initialActiveSection ??
|
||||
getWorkspaceTabInitialSection(initialActiveWorkspaceTab))
|
||||
: restrictSectionForBusinessUser(
|
||||
props.initialActiveSection ??
|
||||
getWorkspaceTabInitialSection(initialActiveWorkspaceTab),
|
||||
),
|
||||
);
|
||||
const [pendingEntryTab, setPendingEntryTab] = useState<IntakeEntryTab>(
|
||||
getWorkspaceTabInitialPendingEntryTab(initialActiveWorkspaceTab),
|
||||
@ -8998,16 +9195,22 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
userId: props.user.id,
|
||||
shouldRestore: true,
|
||||
});
|
||||
const safeRestoredState = canUseDesignControlledProductData
|
||||
? restoredState
|
||||
: restrictWorkspaceStateForBusinessUser(restoredState);
|
||||
const restoredActiveTab =
|
||||
restoredState.tabs.find((tab) => tab.id === restoredState.activeTabId) ??
|
||||
restoredState.tabs[0] ??
|
||||
safeRestoredState.tabs.find(
|
||||
(tab) => tab.id === safeRestoredState.activeTabId,
|
||||
) ??
|
||||
safeRestoredState.tabs[0] ??
|
||||
null;
|
||||
|
||||
workspaceTabSequenceRef.current = restoredState.sequence;
|
||||
setWorkspaceTabs(restoredState.tabs);
|
||||
workspaceTabSequenceRef.current = safeRestoredState.sequence;
|
||||
setWorkspaceTabs(safeRestoredState.tabs);
|
||||
syncWorkspaceState(restoredActiveTab);
|
||||
setWorkspaceStateReady(true);
|
||||
}, [
|
||||
canUseDesignControlledProductData,
|
||||
props.initialNonIntegratedProducts,
|
||||
props.user.id,
|
||||
shouldPersistWorkspaceState,
|
||||
@ -9351,6 +9554,10 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
productDevelopmentKind: ProductDevelopmentKind = "product",
|
||||
initialCreateProductKind: NewNonIntegratedCreateKind = "new",
|
||||
) {
|
||||
if (!canUseDesignControlledProductData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTab = workspaceTabs.find(
|
||||
(tab) =>
|
||||
tab.kind === "newNonIntegratedProduct" &&
|
||||
@ -9444,6 +9651,10 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
}
|
||||
|
||||
function openBomLibraryWorkspace(library: BomLibraryKey) {
|
||||
if (!canUseDesignControlledProductData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId = createWorkspaceTabId("bom-library", library);
|
||||
const nextTab: IntakeWorkspaceTab = {
|
||||
id: tabId,
|
||||
@ -9487,6 +9698,10 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
}
|
||||
|
||||
function openProductPatentDashboardWorkspace() {
|
||||
if (!canUseDesignControlledProductData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId = createWorkspaceTabId("product-patent", "dashboard");
|
||||
const nextTab: IntakeWorkspaceTab = {
|
||||
id: tabId,
|
||||
@ -9497,6 +9712,10 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
}
|
||||
|
||||
function openProductPatentLedgerWorkspace() {
|
||||
if (!canUseDesignControlledProductData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId = createWorkspaceTabId("product-patent", "ledger");
|
||||
const nextTab: IntakeWorkspaceTab = {
|
||||
id: tabId,
|
||||
@ -9520,6 +9739,10 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
}
|
||||
|
||||
function openProductPatents() {
|
||||
if (!canUseDesignControlledProductData) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveWorkspaceTabId(null);
|
||||
setActiveSection((current) => toggleSection(current, "patents"));
|
||||
}
|
||||
@ -9626,16 +9849,22 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
/>
|
||||
);
|
||||
} else if (activeWorkspaceTab?.kind === "productPatentLedger") {
|
||||
activeContent = (
|
||||
<ProductPatentDashboard
|
||||
products={nonIntegratedProducts}
|
||||
user={props.user}
|
||||
target={activeWorkspaceTab.target}
|
||||
/>
|
||||
);
|
||||
activeContent =
|
||||
canUseDesignControlledProductData || activeWorkspaceTab.target ? (
|
||||
<ProductPatentDashboard
|
||||
products={nonIntegratedProducts}
|
||||
user={props.user}
|
||||
target={activeWorkspaceTab.target}
|
||||
readOnly={!canUseDesignControlledProductData}
|
||||
/>
|
||||
) : (
|
||||
<EmptyDetail />
|
||||
);
|
||||
} else if (activeWorkspaceTab?.kind === "productPatentDashboard") {
|
||||
activeContent = (
|
||||
activeContent = canUseDesignControlledProductData ? (
|
||||
<ProductPatentStatusDashboard products={nonIntegratedProducts} />
|
||||
) : (
|
||||
<EmptyDetail />
|
||||
);
|
||||
} else if (activeWorkspaceTab?.kind === "newNonIntegratedProduct") {
|
||||
activeContent = (
|
||||
@ -9687,9 +9916,13 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (props.initialActiveSection) {
|
||||
setActiveSection(props.initialActiveSection);
|
||||
setActiveSection(
|
||||
canUseDesignControlledProductData
|
||||
? props.initialActiveSection
|
||||
: restrictSectionForBusinessUser(props.initialActiveSection),
|
||||
);
|
||||
}
|
||||
}, [props.initialActiveSection]);
|
||||
}, [canUseDesignControlledProductData, props.initialActiveSection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.initialActiveRawMaterialLibrary) {
|
||||
@ -9699,6 +9932,7 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!canUseDesignControlledProductData ||
|
||||
!props.initialOpenNewNonIntegratedProduct ||
|
||||
initialOpenNewNonIntegratedProductRef.current
|
||||
) {
|
||||
@ -9707,7 +9941,11 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
|
||||
initialOpenNewNonIntegratedProductRef.current = true;
|
||||
openNewNonIntegratedProductWorkspace();
|
||||
}, [openNewNonIntegratedProductWorkspace, props.initialOpenNewNonIntegratedProduct]);
|
||||
}, [
|
||||
canUseDesignControlledProductData,
|
||||
openNewNonIntegratedProductWorkspace,
|
||||
props.initialOpenNewNonIntegratedProduct,
|
||||
]);
|
||||
|
||||
if (!isEditorMode(props)) {
|
||||
const skuList = buildIntegratedSeriesRows({
|
||||
@ -9740,13 +9978,19 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
canDeleteProducts={canDeleteNonIntegratedProducts}
|
||||
currentUser={props.user}
|
||||
productDevelopmentKind="series"
|
||||
emptyMessage="当前还没有系列产品,点击右上角“新增产品”开始维护系列资料。"
|
||||
emptyMessage={
|
||||
canUseDesignControlledProductData
|
||||
? "当前还没有系列产品,点击右上角“新增产品”开始维护系列资料。"
|
||||
: "当前还没有系列产品。"
|
||||
}
|
||||
onProductsChange={(nextProducts) =>
|
||||
handleScopedProductsChange("series", nextProducts)
|
||||
}
|
||||
onOpenProductWorkspace={openProductWorkspace}
|
||||
onOpenNewNonIntegratedProductWorkspace={() =>
|
||||
openNewNonIntegratedProductWorkspace("series")
|
||||
onOpenNewNonIntegratedProductWorkspace={
|
||||
canUseDesignControlledProductData
|
||||
? () => openNewNonIntegratedProductWorkspace("series")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : pendingEntryTab === "stock" ? (
|
||||
@ -9773,8 +10017,15 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
handleScopedProductsChange("product", nextProducts, "new")
|
||||
}
|
||||
onOpenProductWorkspace={openProductWorkspace}
|
||||
onOpenNewNonIntegratedProductWorkspace={() =>
|
||||
openNewNonIntegratedProductWorkspace("product")
|
||||
emptyMessage={
|
||||
canUseDesignControlledProductData
|
||||
? undefined
|
||||
: "当前还没有产品开发明细。"
|
||||
}
|
||||
onOpenNewNonIntegratedProductWorkspace={
|
||||
canUseDesignControlledProductData
|
||||
? () => openNewNonIntegratedProductWorkspace("product")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
@ -9787,7 +10038,7 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
onOpenProductWorkspace={openProductWorkspace}
|
||||
/>
|
||||
);
|
||||
} else if (activeSection === "bom") {
|
||||
} else if (activeSection === "bom" && canUseDesignControlledProductData) {
|
||||
leftContent = (
|
||||
<BomDetail
|
||||
user={props.user}
|
||||
@ -9811,7 +10062,10 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
materialLibrarySubstrates={props.materialLibrarySubstrates}
|
||||
/>
|
||||
);
|
||||
} else if (activeSection === "patents") {
|
||||
} else if (
|
||||
activeSection === "patents" &&
|
||||
canUseDesignControlledProductData
|
||||
) {
|
||||
leftContent = (
|
||||
<ProductPatentDashboard products={nonIntegratedProducts} user={props.user} />
|
||||
);
|
||||
@ -9855,6 +10109,9 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
? openSpuRuleLibraryWorkspace
|
||||
: undefined
|
||||
}
|
||||
showDesignControlledProductData={
|
||||
canUseDesignControlledProductData
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -9888,13 +10145,19 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
canDeleteProducts={canDeleteNonIntegratedProducts}
|
||||
currentUser={props.user}
|
||||
productDevelopmentKind="series"
|
||||
emptyMessage="当前还没有系列产品,点击右上角“新增产品”开始维护系列资料。"
|
||||
emptyMessage={
|
||||
canUseDesignControlledProductData
|
||||
? "当前还没有系列产品,点击右上角“新增产品”开始维护系列资料。"
|
||||
: "当前还没有系列产品。"
|
||||
}
|
||||
onProductsChange={(nextProducts) =>
|
||||
handleScopedProductsChange("series", nextProducts)
|
||||
}
|
||||
onOpenProductWorkspace={openProductWorkspace}
|
||||
onOpenNewNonIntegratedProductWorkspace={() =>
|
||||
openNewNonIntegratedProductWorkspace("series")
|
||||
onOpenNewNonIntegratedProductWorkspace={
|
||||
canUseDesignControlledProductData
|
||||
? () => openNewNonIntegratedProductWorkspace("series")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : pendingEntryTab === "stock" ? (
|
||||
@ -9921,8 +10184,15 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
handleScopedProductsChange("product", nextProducts, "new")
|
||||
}
|
||||
onOpenProductWorkspace={openProductWorkspace}
|
||||
onOpenNewNonIntegratedProductWorkspace={() =>
|
||||
openNewNonIntegratedProductWorkspace("product")
|
||||
emptyMessage={
|
||||
canUseDesignControlledProductData
|
||||
? undefined
|
||||
: "当前还没有产品开发明细。"
|
||||
}
|
||||
onOpenNewNonIntegratedProductWorkspace={
|
||||
canUseDesignControlledProductData
|
||||
? () => openNewNonIntegratedProductWorkspace("product")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
@ -9935,7 +10205,7 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
onOpenProductWorkspace={openProductWorkspace}
|
||||
/>
|
||||
);
|
||||
} else if (activeSection === "bom") {
|
||||
} else if (activeSection === "bom" && canUseDesignControlledProductData) {
|
||||
leftContent = (
|
||||
<BomDetail
|
||||
user={props.user}
|
||||
@ -9959,7 +10229,10 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
materialLibrarySubstrates={props.materialLibrarySubstrates}
|
||||
/>
|
||||
);
|
||||
} else if (activeSection === "patents") {
|
||||
} else if (
|
||||
activeSection === "patents" &&
|
||||
canUseDesignControlledProductData
|
||||
) {
|
||||
leftContent = (
|
||||
<ProductPatentDashboard products={nonIntegratedProducts} user={props.user} />
|
||||
);
|
||||
@ -10001,6 +10274,7 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
? openSpuRuleLibraryWorkspace
|
||||
: undefined
|
||||
}
|
||||
showDesignControlledProductData={canUseDesignControlledProductData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3558,6 +3558,163 @@ describe("NonIntegratedDetailClient", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restricts brand center planning users by department-owned nodes", async () => {
|
||||
const actor = userEvent.setup();
|
||||
|
||||
saveNonIntegratedProduct(
|
||||
createProductRecord({
|
||||
id: "SZ0230-brand-center-planning-permission",
|
||||
spu: "SZ0230",
|
||||
productPlanning: "商品企划同事",
|
||||
visualPlanning: "空间视觉同事",
|
||||
visualDirector: "空间视觉同事",
|
||||
openingReference: {
|
||||
...createEmptyNonIntegratedOpeningReference(),
|
||||
productName: "品牌中心权限测试",
|
||||
planningOwner: "商品企划同事",
|
||||
},
|
||||
skuItems: [
|
||||
createSkuItem({
|
||||
id: "sku-brand-center-planning-permission-1",
|
||||
model: "SZ0230K01",
|
||||
salesStatus: "销售",
|
||||
}),
|
||||
],
|
||||
uploadFiles: [
|
||||
{
|
||||
id: "planning-hidden-product-data",
|
||||
name: "企划不可看产品资料.pdf",
|
||||
category: "产品模型",
|
||||
dataUrl: "data:application/pdf;base64,AAAA",
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
{
|
||||
id: "planning-readonly-review",
|
||||
name: "企划只读样品评审.pdf",
|
||||
category: "评审资料",
|
||||
dataUrl: "data:application/pdf;base64,BBBB",
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
{
|
||||
id: "planning-readonly-photo",
|
||||
name: "企划只读产品实拍.png",
|
||||
category: "产品实拍图",
|
||||
dataUrl: "data:image/png;base64,CCCC",
|
||||
mimeType: "image/png",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const { unmount } = render(
|
||||
<NonIntegratedDetailClient
|
||||
mode="edit"
|
||||
productId="SZ0230-brand-center-planning-permission"
|
||||
currentUserRole="USER"
|
||||
currentUserName="商品企划同事"
|
||||
currentUserBusinessRoles={["PLANNING"]}
|
||||
currentUserDepartmentPaths={[
|
||||
"江苏简尚品牌管理有限公司-品牌中心-商品企划部-桌类",
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("document-workspace-trigger-产品模型"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("document-workspace-trigger-包装细节"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("document-workspace-trigger-产品定厂"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("document-workspace-trigger-评审资料"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("document-workspace-trigger-产品实拍图"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-评审资料"));
|
||||
expect(screen.queryByRole("button", { name: "新增轮次" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "导出Excel" })).not.toBeInTheDocument();
|
||||
const confirmReviewButton = screen.getByRole("button", {
|
||||
name: "确认评审资料",
|
||||
});
|
||||
expect(confirmReviewButton).toBeDisabled();
|
||||
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-产品实拍图"));
|
||||
expect(
|
||||
screen.queryByTestId("product-photo-detail-upload-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("product-photo-batch-download"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-产品限价"));
|
||||
await actor.type(
|
||||
screen.getByTestId(
|
||||
"product-limit-expectedDailyLimitPrice-sku-brand-center-planning-permission-1",
|
||||
),
|
||||
"1999",
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const savedProduct = getNonIntegratedProductById(
|
||||
"SZ0230-brand-center-planning-permission",
|
||||
);
|
||||
expect(savedProduct?.productLimitRows?.[0]?.expectedDailyLimitPrice).toBe(
|
||||
"1999",
|
||||
);
|
||||
});
|
||||
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-企划布局"));
|
||||
expect(
|
||||
screen.queryByTestId(
|
||||
"planning-layout-styles-draft-sku-brand-center-planning-permission-1",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("document-workspace-confirm-企划布局"),
|
||||
).toBeDisabled();
|
||||
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<NonIntegratedDetailClient
|
||||
mode="edit"
|
||||
productId="SZ0230-brand-center-planning-permission"
|
||||
currentUserRole="USER"
|
||||
currentUserName="空间视觉同事"
|
||||
currentUserBusinessRoles={["PLANNING"]}
|
||||
currentUserDepartmentPaths={[
|
||||
"江苏简尚品牌管理有限公司-品牌中心-空间视觉部",
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("document-workspace-trigger-产品模型"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("document-workspace-trigger-包装细节"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-企划布局"));
|
||||
expect(
|
||||
screen.getByTestId(
|
||||
"planning-layout-styles-draft-sku-brand-center-planning-permission-1",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-产品限价"));
|
||||
expect(
|
||||
screen.getByTestId(
|
||||
"product-limit-expectedDailyLimitPrice-sku-brand-center-planning-permission-1",
|
||||
),
|
||||
).toHaveAttribute("readonly");
|
||||
});
|
||||
|
||||
it("hides redundant fields from the product detail summary", async () => {
|
||||
const actor = userEvent.setup();
|
||||
|
||||
@ -4272,9 +4429,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
).toHaveAttribute("readonly");
|
||||
});
|
||||
|
||||
it("lets link users edit finished sample quantities only", async () => {
|
||||
const actor = userEvent.setup();
|
||||
|
||||
it("hides finished sample shipments for link users", () => {
|
||||
saveNonIntegratedProduct(
|
||||
createProductRecord({
|
||||
id: "SZ0230-finished-sample-link-user",
|
||||
@ -4305,41 +4460,12 @@ describe("NonIntegratedDetailClient", () => {
|
||||
);
|
||||
|
||||
const panel = screen.getByTestId("document-workspace-panel");
|
||||
expect(screen.getByTestId("finished-sample-shipment-workspace")).toBeInTheDocument();
|
||||
expect(panel).toHaveTextContent("图片");
|
||||
expect(panel).toHaveTextContent("规格码");
|
||||
expect(panel).toHaveTextContent("供应商名称");
|
||||
expect(panel).toHaveTextContent("安装方式");
|
||||
|
||||
const quantityInput = screen.getByTestId(
|
||||
"finished-sample-shipment-sampleQuantity-sku-finished-link-1",
|
||||
);
|
||||
expect(quantityInput).not.toHaveAttribute("readonly");
|
||||
expect(
|
||||
screen.getByTestId("finished-sample-shipment-shipped-sku-finished-link-1"),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByTestId(
|
||||
"finished-sample-shipment-trackingNumber-sku-finished-link-1",
|
||||
),
|
||||
).toHaveAttribute("readonly");
|
||||
expect(screen.getByTestId("finished-sample-shipment-confirm")).toBeDisabled();
|
||||
|
||||
await actor.type(quantityInput, "2");
|
||||
|
||||
await waitFor(() => {
|
||||
const savedProduct = getNonIntegratedProductById(
|
||||
"SZ0230-finished-sample-link-user",
|
||||
);
|
||||
expect(savedProduct?.finishedSampleShipmentRows).toEqual([
|
||||
{
|
||||
skuId: "sku-finished-link-1",
|
||||
sampleQuantity: "2",
|
||||
shipped: "",
|
||||
trackingNumber: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
screen.queryByTestId("finished-sample-shipment-workspace"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("document-workspace-trigger-成品寄样")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("opening-reference-workspace")).toBeInTheDocument();
|
||||
expect(panel).toHaveTextContent("开品参考填写");
|
||||
});
|
||||
|
||||
it("lets purchasing users fill and confirm finished sample shipments", async () => {
|
||||
@ -4551,6 +4677,144 @@ describe("NonIntegratedDetailClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lets supply chain pricing and quality groups maintain their own nodes", async () => {
|
||||
const actor = userEvent.setup();
|
||||
|
||||
saveNonIntegratedProduct(
|
||||
createProductRecord({
|
||||
id: "SZ0230-supply-chain-group-permission",
|
||||
skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT,
|
||||
designer: "测试设计师",
|
||||
skuItems: [
|
||||
createSkuItem({
|
||||
id: "sku-supply-chain-group-permission-1",
|
||||
model: "SZ0230K01",
|
||||
skuCode: "SZ023001",
|
||||
barcode: "SZ023001N1280V01",
|
||||
manufacturer: "兰考华兰家具",
|
||||
}),
|
||||
],
|
||||
factoryRows: [
|
||||
{
|
||||
skuId: "sku-supply-chain-group-permission-1",
|
||||
confirmed: true,
|
||||
model: "SZ0230K01",
|
||||
skuCode: "SZ023001",
|
||||
setCount: "1",
|
||||
barcode: "SZ023001N1280V01",
|
||||
specName: "款式一 140/60/75",
|
||||
size: "140/60/75",
|
||||
color: "胡桃色",
|
||||
material: "橡胶木",
|
||||
installMethod: "整装",
|
||||
supplierName: "兰考华兰家具",
|
||||
supplierCode: "N1280",
|
||||
warehouse: "",
|
||||
minimumOrderQuantity: "",
|
||||
leadTime: "",
|
||||
assemblyMode: "",
|
||||
productionType: "",
|
||||
installationService: "",
|
||||
supplierCategory: "",
|
||||
purchaseOrderMethod: "",
|
||||
executeStandard: "GB/T3324-2024",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const { unmount } = render(
|
||||
<NonIntegratedDetailClient
|
||||
mode="edit"
|
||||
productId="SZ0230-supply-chain-group-permission"
|
||||
currentUserRole="USER"
|
||||
currentUserName="核价同事"
|
||||
currentUserBusinessRoles={["PURCHASING"]}
|
||||
currentUserDepartmentPaths={[
|
||||
"江苏简尚品牌管理有限公司-供应链中心-核价组",
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("document-workspace-trigger-样包测量"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("document-workspace-trigger-产品核价"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("document-workspace-trigger-工厂质检"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-样包测量"));
|
||||
const sampleLengthInput = screen.getByTestId(
|
||||
"sample-measurement-lengthCm-sku-supply-chain-group-permission-1",
|
||||
);
|
||||
expect(sampleLengthInput).not.toHaveAttribute("readonly");
|
||||
await actor.type(sampleLengthInput, "120");
|
||||
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-产品核价"));
|
||||
const costPriceInput = screen.getByPlaceholderText("0");
|
||||
expect(costPriceInput).not.toHaveAttribute("readonly");
|
||||
await actor.type(costPriceInput, "88");
|
||||
|
||||
await waitFor(() => {
|
||||
const savedProduct = getNonIntegratedProductById(
|
||||
"SZ0230-supply-chain-group-permission",
|
||||
);
|
||||
expect(savedProduct?.sampleMeasurementRows?.[0]?.lengthCm).toBe("120");
|
||||
expect(savedProduct?.pricingRows?.[0]?.costPrice).toBe("88");
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<NonIntegratedDetailClient
|
||||
mode="edit"
|
||||
productId="SZ0230-supply-chain-group-permission"
|
||||
currentUserRole="USER"
|
||||
currentUserName="质检同事"
|
||||
currentUserBusinessRoles={["PURCHASING"]}
|
||||
currentUserDepartmentPaths={[
|
||||
"江苏简尚品牌管理有限公司-供应链中心-品牌 采购部-质检组",
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("document-workspace-trigger-工厂质检"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("document-workspace-trigger-产品限价"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("document-workspace-trigger-开品参考"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-工厂质检"));
|
||||
const firstQualityWorkspace = screen.getByTestId("factory-quality-workspace");
|
||||
expect(firstQualityWorkspace).toBeInTheDocument();
|
||||
const qualityBatchInput = screen.getByPlaceholderText("填写质检批次");
|
||||
expect(qualityBatchInput).not.toHaveAttribute("readonly");
|
||||
await actor.type(qualityBatchInput, "FQ-001");
|
||||
await selectInlineOption(
|
||||
actor,
|
||||
screen.getByLabelText("SZ0230K01质检结论"),
|
||||
"合格",
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const savedProduct = getNonIntegratedProductById(
|
||||
"SZ0230-supply-chain-group-permission",
|
||||
);
|
||||
expect(savedProduct?.firstQualityRows?.[0]).toMatchObject({
|
||||
skuId: "sku-supply-chain-group-permission-1",
|
||||
qualityBatch: "FQ-001",
|
||||
conclusion: "合格",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("lets planning users edit opening reference, planning layout, and product limit nodes", async () => {
|
||||
const actor = userEvent.setup();
|
||||
const openingReferenceCategory = "开品参考";
|
||||
@ -4631,13 +4895,9 @@ describe("NonIntegratedDetailClient", () => {
|
||||
);
|
||||
});
|
||||
|
||||
await actor.click(
|
||||
screen.getByTestId(`document-workspace-trigger-${sampleMeasurementCategory}`),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("sample-measurement-lengthCm-sku-planning-permission-1"),
|
||||
).toHaveAttribute("readonly");
|
||||
screen.queryByTestId(`document-workspace-trigger-${sampleMeasurementCategory}`),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets supervisors edit product detail nodes", () => {
|
||||
@ -4669,7 +4929,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
expect(screen.getByLabelText("开品名称")).not.toHaveAttribute("readonly");
|
||||
});
|
||||
|
||||
it("keeps regular users read-only in purchasing document nodes", async () => {
|
||||
it("hides purchasing document nodes for regular users", () => {
|
||||
const sampleMeasurementCategory = "样包测量";
|
||||
|
||||
saveNonIntegratedProduct(
|
||||
@ -4697,8 +4957,10 @@ describe("NonIntegratedDetailClient", () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("sample-measurement-lengthCm-sku-regular-permission-1"),
|
||||
).toHaveAttribute("readonly");
|
||||
screen.queryByTestId("sample-measurement-lengthCm-sku-regular-permission-1"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("document-workspace-trigger-样包测量")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("opening-reference-workspace")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders first quality rows by SKU batch", async () => {
|
||||
@ -4967,7 +5229,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-产品定厂"));
|
||||
expect(screen.queryByTestId("document-workspace-trigger-产品定厂")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("product-factory-reopen-confirmation"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@ -260,10 +260,14 @@ import {
|
||||
} from "./supplierDirectory";
|
||||
import {
|
||||
canCreateNonIntegratedProductFromIntake,
|
||||
canEditAnyNonIntegratedWorkspace,
|
||||
canEditNonIntegratedProductIdentity,
|
||||
canEditNonIntegratedWorkspace,
|
||||
isCurrentUserNonIntegratedPurchaseOwner,
|
||||
isIntakeNonIntegratedBrandCenterPlanningUser,
|
||||
isIntakeNonIntegratedFullEditor,
|
||||
isIntakeNonIntegratedPricingGroupUser,
|
||||
isIntakeNonIntegratedQualityGroupUser,
|
||||
isIntakeNonIntegratedSupplyChainUser,
|
||||
type IntakeNonIntegratedPermissionUser,
|
||||
} from "@/lib/non-integrated-permissions";
|
||||
@ -1054,10 +1058,60 @@ const PURCHASE_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES =
|
||||
|
||||
function isPurchaseHiddenDocumentWorkspaceCategory(
|
||||
category: DocumentWorkspaceCategory,
|
||||
options?: { canViewFactoryQuality?: boolean },
|
||||
) {
|
||||
if (category === "工厂质检" && options?.canViewFactoryQuality) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PURCHASE_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
|
||||
}
|
||||
|
||||
const PLANNING_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES =
|
||||
new Set<DocumentWorkspaceCategory>([
|
||||
PRODUCT_DATA_WORKSPACE_CATEGORY,
|
||||
PATENT_APPLICATION_WORKSPACE_CATEGORY,
|
||||
PACKAGING_WORKSPACE_CATEGORY,
|
||||
PRODUCT_FACTORY_WORKSPACE_CATEGORY,
|
||||
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
|
||||
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
|
||||
"工厂质检",
|
||||
DETECTION_WORKSPACE_CATEGORY,
|
||||
]);
|
||||
|
||||
const PLANNING_DOWNLOADABLE_DOCUMENT_WORKSPACE_CATEGORIES =
|
||||
new Set<DocumentWorkspaceCategory>(["开品参考", "设计卖点"]);
|
||||
|
||||
const PLAIN_VIEWER_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES =
|
||||
new Set<DocumentWorkspaceCategory>([
|
||||
PRODUCT_DATA_WORKSPACE_CATEGORY,
|
||||
REVIEW_WORKSPACE_CATEGORY,
|
||||
PATENT_APPLICATION_WORKSPACE_CATEGORY,
|
||||
PACKAGING_WORKSPACE_CATEGORY,
|
||||
PRODUCT_FACTORY_WORKSPACE_CATEGORY,
|
||||
SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY,
|
||||
FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY,
|
||||
"工厂质检",
|
||||
]);
|
||||
|
||||
function isPlanningHiddenDocumentWorkspaceCategory(
|
||||
category: DocumentWorkspaceCategory,
|
||||
) {
|
||||
return PLANNING_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
|
||||
}
|
||||
|
||||
function isPlainViewerHiddenDocumentWorkspaceCategory(
|
||||
category: DocumentWorkspaceCategory,
|
||||
) {
|
||||
return PLAIN_VIEWER_HIDDEN_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
|
||||
}
|
||||
|
||||
function canPlanningDownloadDocumentWorkspaceCategory(
|
||||
category: DocumentWorkspaceCategory,
|
||||
) {
|
||||
return PLANNING_DOWNLOADABLE_DOCUMENT_WORKSPACE_CATEGORIES.has(category);
|
||||
}
|
||||
|
||||
function getUniqueNonEmptyStrings(
|
||||
values: ReadonlyArray<string | null | undefined>,
|
||||
) {
|
||||
@ -11081,22 +11135,82 @@ export default function NonIntegratedDetailClient({
|
||||
currentUserRole === "ADMIN" || currentUserRole === "SUPER_ADMIN";
|
||||
const isCurrentUserFullDetailEditor =
|
||||
isIntakeNonIntegratedFullEditor(currentPermissionUser);
|
||||
const isCurrentUserSupplyChainPurchaseDepartment =
|
||||
isIntakeNonIntegratedSupplyChainUser(currentPermissionUser);
|
||||
const isCurrentUserSupplyChainPricingGroup =
|
||||
isIntakeNonIntegratedPricingGroupUser(currentPermissionUser);
|
||||
const isCurrentUserSupplyChainQualityGroup =
|
||||
isIntakeNonIntegratedQualityGroupUser(currentPermissionUser);
|
||||
const isCurrentUserPurchaseDownloadRestricted =
|
||||
hasCurrentUserContext &&
|
||||
!isCurrentUserFullDetailEditor &&
|
||||
isIntakeNonIntegratedSupplyChainUser(currentPermissionUser);
|
||||
(isCurrentUserSupplyChainPurchaseDepartment ||
|
||||
isCurrentUserSupplyChainPricingGroup ||
|
||||
isCurrentUserSupplyChainQualityGroup);
|
||||
const isCurrentUserPlanningContentRestricted =
|
||||
hasCurrentUserContext &&
|
||||
!isCurrentUserFullDetailEditor &&
|
||||
isIntakeNonIntegratedBrandCenterPlanningUser(currentPermissionUser);
|
||||
const canCurrentUserEditAnyConfiguredWorkspace =
|
||||
hasCurrentUserContext &&
|
||||
canEditAnyNonIntegratedWorkspace(
|
||||
currentPermissionProduct,
|
||||
currentPermissionUser,
|
||||
);
|
||||
const isCurrentUserPlainReadOnlyViewer =
|
||||
hasCurrentUserContext &&
|
||||
!isCurrentUserFullDetailEditor &&
|
||||
!canCurrentUserEditAnyConfiguredWorkspace &&
|
||||
!isCurrentUserPurchaseDownloadRestricted &&
|
||||
!isCurrentUserPlanningContentRestricted;
|
||||
const isCurrentUserWorkspaceDownloadRestricted =
|
||||
isCurrentUserPurchaseDownloadRestricted ||
|
||||
isCurrentUserPlanningContentRestricted;
|
||||
const canPurchaseDownloadActiveWorkspace =
|
||||
isIntakeNonIntegratedSupplyChainUser(currentPermissionUser) &&
|
||||
isCurrentUserSupplyChainPurchaseDepartment &&
|
||||
(activeDocumentWorkspaceCategory === PRODUCT_DATA_WORKSPACE_CATEGORY ||
|
||||
activeDocumentWorkspaceCategory === REVIEW_WORKSPACE_CATEGORY);
|
||||
const canPlanningDownloadActiveWorkspace =
|
||||
isIntakeNonIntegratedBrandCenterPlanningUser(currentPermissionUser) &&
|
||||
canPlanningDownloadDocumentWorkspaceCategory(activeDocumentWorkspaceCategory);
|
||||
const canDownloadWorkspaceFiles =
|
||||
!isCurrentUserPurchaseDownloadRestricted ||
|
||||
canPurchaseDownloadActiveWorkspace;
|
||||
!isCurrentUserPlainReadOnlyViewer &&
|
||||
(!isCurrentUserWorkspaceDownloadRestricted ||
|
||||
canPurchaseDownloadActiveWorkspace ||
|
||||
canPlanningDownloadActiveWorkspace);
|
||||
const isDocumentWorkspaceCategoryVisibleForCurrentUser = useCallback(
|
||||
(category: DocumentWorkspaceCategory) =>
|
||||
!isCurrentUserPurchaseDownloadRestricted ||
|
||||
!isPurchaseHiddenDocumentWorkspaceCategory(category),
|
||||
[isCurrentUserPurchaseDownloadRestricted],
|
||||
(category: DocumentWorkspaceCategory) => {
|
||||
if (
|
||||
isCurrentUserPlainReadOnlyViewer &&
|
||||
isPlainViewerHiddenDocumentWorkspaceCategory(category)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
isCurrentUserPurchaseDownloadRestricted &&
|
||||
isPurchaseHiddenDocumentWorkspaceCategory(category, {
|
||||
canViewFactoryQuality: isCurrentUserSupplyChainQualityGroup,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
isCurrentUserPlanningContentRestricted &&
|
||||
isPlanningHiddenDocumentWorkspaceCategory(category)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[
|
||||
isCurrentUserPlanningContentRestricted,
|
||||
isCurrentUserPlainReadOnlyViewer,
|
||||
isCurrentUserPurchaseDownloadRestricted,
|
||||
isCurrentUserSupplyChainQualityGroup,
|
||||
],
|
||||
);
|
||||
const currentUserNameNormalized = currentUserName?.trim() ?? "";
|
||||
const currentUserIdNormalized = currentUserId?.trim() ?? "";
|
||||
@ -14286,8 +14400,11 @@ export default function NonIntegratedDetailClient({
|
||||
useEffect(() => {
|
||||
if (mode === "create") return;
|
||||
if (documentWorkspaceView !== "document") return;
|
||||
if (!isCurrentUserPurchaseDownloadRestricted) return;
|
||||
if (!isPurchaseHiddenDocumentWorkspaceCategory(activeDocumentWorkspaceCategory)) {
|
||||
if (
|
||||
isDocumentWorkspaceCategoryVisibleForCurrentUser(
|
||||
activeDocumentWorkspaceCategory,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -14298,7 +14415,7 @@ export default function NonIntegratedDetailClient({
|
||||
}, [
|
||||
activeDocumentWorkspaceCategory,
|
||||
documentWorkspaceView,
|
||||
isCurrentUserPurchaseDownloadRestricted,
|
||||
isDocumentWorkspaceCategoryVisibleForCurrentUser,
|
||||
mode,
|
||||
visibleDocumentWorkspaceCategories,
|
||||
]);
|
||||
@ -18303,6 +18420,7 @@ export default function NonIntegratedDetailClient({
|
||||
|
||||
function renderDocumentWorkspaceConfirmationAction() {
|
||||
const category = activeDocumentWorkspaceCategory;
|
||||
if (isCurrentUserPlainReadOnlyViewer) return null;
|
||||
if (!shouldRequireWorkflowConfirmation(category)) return null;
|
||||
|
||||
const hasContent = activeDocumentWorkspaceHasContent;
|
||||
@ -18338,6 +18456,7 @@ export default function NonIntegratedDetailClient({
|
||||
|
||||
function renderBasicInfoConfirmationAction() {
|
||||
if (mode === "create") return null;
|
||||
if (isCurrentUserPlainReadOnlyViewer) return null;
|
||||
|
||||
if (basicInfoIsApproved) {
|
||||
return (
|
||||
@ -21848,31 +21967,33 @@ export default function NonIntegratedDetailClient({
|
||||
>
|
||||
<div className="mb-2 flex shrink-0 flex-wrap items-start justify-between gap-2 border-b border-apple-separator pb-2">
|
||||
{renderFirstQualityWorkspacePanelTabs()}
|
||||
<div className={DOCUMENT_WORKSPACE_ACTION_GROUP_CLASS}>
|
||||
{canDownloadWorkspaceFiles ? (
|
||||
{isCurrentUserPlainReadOnlyViewer ? null : (
|
||||
<div className={DOCUMENT_WORKSPACE_ACTION_GROUP_CLASS}>
|
||||
{canDownloadWorkspaceFiles ? (
|
||||
<DocumentWorkspaceActionButton
|
||||
onClick={() =>
|
||||
handleDownloadWorkspaceFiles(factoryQualityFiles)
|
||||
}
|
||||
disabled={factoryQualityFiles.length === 0}
|
||||
>
|
||||
批量下载
|
||||
</DocumentWorkspaceActionButton>
|
||||
) : null}
|
||||
<DocumentWorkspaceActionButton
|
||||
onClick={() =>
|
||||
handleDownloadWorkspaceFiles(factoryQualityFiles)
|
||||
onClick={() => void handleConfirmFirstQualityRows()}
|
||||
disabled={
|
||||
isReadOnly ||
|
||||
displayedProductFirstQualityRows.length === 0 ||
|
||||
!displayedFirstQualityRowsComplete ||
|
||||
displayedFirstQualityIsConfirmed
|
||||
}
|
||||
disabled={factoryQualityFiles.length === 0}
|
||||
>
|
||||
批量下载
|
||||
{displayedFirstQualityIsConfirmed
|
||||
? "已质检"
|
||||
: "确认首批质检"}
|
||||
</DocumentWorkspaceActionButton>
|
||||
) : null}
|
||||
<DocumentWorkspaceActionButton
|
||||
onClick={() => void handleConfirmFirstQualityRows()}
|
||||
disabled={
|
||||
isReadOnly ||
|
||||
displayedProductFirstQualityRows.length === 0 ||
|
||||
!displayedFirstQualityRowsComplete ||
|
||||
displayedFirstQualityIsConfirmed
|
||||
}
|
||||
>
|
||||
{displayedFirstQualityIsConfirmed
|
||||
? "已质检"
|
||||
: "确认首批质检"}
|
||||
</DocumentWorkspaceActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{renderFactoryQualityWorkspace()}
|
||||
@ -21887,69 +22008,71 @@ export default function NonIntegratedDetailClient({
|
||||
>
|
||||
<div className="mb-2 flex shrink-0 flex-wrap items-start justify-between gap-2 border-b border-apple-separator pb-2">
|
||||
{renderFirstQualityWorkspacePanelTabs()}
|
||||
<div className={DOCUMENT_WORKSPACE_ACTION_GROUP_CLASS}>
|
||||
{isReadOnly ? null : (
|
||||
<>
|
||||
<DocumentWorkspaceActionButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
documentWorkspaceUploadInputRef.current?.click()
|
||||
}
|
||||
>
|
||||
批量上传
|
||||
</DocumentWorkspaceActionButton>
|
||||
<input
|
||||
ref={documentWorkspaceUploadInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={QUALITY_STANDARD_FILE_ACCEPT}
|
||||
data-testid="quality-standard-upload-input"
|
||||
className="hidden"
|
||||
onChange={(event) =>
|
||||
void handleUploadDetectionQualityStandardFiles(event)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{canDownloadWorkspaceFiles ? (
|
||||
<>
|
||||
<DocumentWorkspaceActionButton
|
||||
onClick={() =>
|
||||
handleDownloadWorkspaceFiles(qualityStandardFiles)
|
||||
}
|
||||
disabled={qualityStandardFiles.length === 0}
|
||||
>
|
||||
批量下载
|
||||
</DocumentWorkspaceActionButton>
|
||||
{isCurrentUserPlainReadOnlyViewer ? null : (
|
||||
<div className={DOCUMENT_WORKSPACE_ACTION_GROUP_CLASS}>
|
||||
{isReadOnly ? null : (
|
||||
<>
|
||||
<DocumentWorkspaceActionButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
documentWorkspaceUploadInputRef.current?.click()
|
||||
}
|
||||
>
|
||||
批量上传
|
||||
</DocumentWorkspaceActionButton>
|
||||
<input
|
||||
ref={documentWorkspaceUploadInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={QUALITY_STANDARD_FILE_ACCEPT}
|
||||
data-testid="quality-standard-upload-input"
|
||||
className="hidden"
|
||||
onChange={(event) =>
|
||||
void handleUploadDetectionQualityStandardFiles(event)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{canDownloadWorkspaceFiles ? (
|
||||
<>
|
||||
<DocumentWorkspaceActionButton
|
||||
onClick={() =>
|
||||
handleDownloadWorkspaceFiles(qualityStandardFiles)
|
||||
}
|
||||
disabled={qualityStandardFiles.length === 0}
|
||||
>
|
||||
批量下载
|
||||
</DocumentWorkspaceActionButton>
|
||||
<DocumentWorkspaceActionButton
|
||||
onClick={() =>
|
||||
effectiveSelectedWorkspaceFile
|
||||
? handleDownloadUploadFile(
|
||||
effectiveSelectedWorkspaceFile,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
disabled={!effectiveSelectedWorkspaceFile}
|
||||
>
|
||||
下载
|
||||
</DocumentWorkspaceActionButton>
|
||||
</>
|
||||
) : null}
|
||||
{isReadOnly ? null : (
|
||||
<DocumentWorkspaceActionButton
|
||||
onClick={() =>
|
||||
effectiveSelectedWorkspaceFile
|
||||
? handleDownloadUploadFile(
|
||||
effectiveSelectedWorkspaceFile,
|
||||
? handleDeleteUploadFile(
|
||||
effectiveSelectedWorkspaceFile.id,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
disabled={!effectiveSelectedWorkspaceFile}
|
||||
>
|
||||
下载
|
||||
删除
|
||||
</DocumentWorkspaceActionButton>
|
||||
</>
|
||||
) : null}
|
||||
{isReadOnly ? null : (
|
||||
<DocumentWorkspaceActionButton
|
||||
onClick={() =>
|
||||
effectiveSelectedWorkspaceFile
|
||||
? handleDeleteUploadFile(
|
||||
effectiveSelectedWorkspaceFile.id,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
disabled={!effectiveSelectedWorkspaceFile}
|
||||
>
|
||||
删除
|
||||
</DocumentWorkspaceActionButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{renderQualityStandardWorkspace()}
|
||||
@ -23703,23 +23826,25 @@ export default function NonIntegratedDetailClient({
|
||||
>
|
||||
{isSystemEntered ? "已录入" : "未录入"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title={
|
||||
isSystemEntered
|
||||
? "当前 SKU 已录入系统,后续以聚水潭回传状态为准"
|
||||
: PRODUCT_PRICING_JST_PUSH_ENABLED &&
|
||||
pricingReadyForFuturePush
|
||||
? "请由采购/核价确认推送"
|
||||
: pricingReadyForFuturePush
|
||||
? "聚水潭接口未接入,暂不可确认推送"
|
||||
: "请先填写成本价;聚水潭接口未接入,暂不可确认推送"
|
||||
}
|
||||
className="inline-flex h-7 min-w-[52px] items-center justify-center rounded-full bg-[#1f8bff] px-3 text-xs font-apple-medium text-white shadow-sm transition hover:bg-[#1478df] disabled:cursor-not-allowed disabled:bg-apple-bg-tertiary disabled:text-apple-label-tertiary disabled:shadow-none"
|
||||
>
|
||||
确认推送
|
||||
</button>
|
||||
{isCurrentUserPlainReadOnlyViewer ? null : (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title={
|
||||
isSystemEntered
|
||||
? "当前 SKU 已录入系统,后续以聚水潭回传状态为准"
|
||||
: PRODUCT_PRICING_JST_PUSH_ENABLED &&
|
||||
pricingReadyForFuturePush
|
||||
? "请由采购/核价确认推送"
|
||||
: pricingReadyForFuturePush
|
||||
? "聚水潭接口未接入,暂不可确认推送"
|
||||
: "请先填写成本价;聚水潭接口未接入,暂不可确认推送"
|
||||
}
|
||||
className="inline-flex h-7 min-w-[52px] items-center justify-center rounded-full bg-[#1f8bff] px-3 text-xs font-apple-medium text-white shadow-sm transition hover:bg-[#1478df] disabled:cursor-not-allowed disabled:bg-apple-bg-tertiary disabled:text-apple-label-tertiary disabled:shadow-none"
|
||||
>
|
||||
确认推送
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -24560,7 +24685,9 @@ function renderFactoryQualityWorkspace() {
|
||||
开品参考填写
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] leading-4 text-apple-label-secondary">
|
||||
开品前先记录参考图、品牌方向、类目、竞品、开品理由和设计卖点。立项通过后再补产品资料、样品评审和完整基础信息。
|
||||
{isCurrentUserPlainReadOnlyViewer
|
||||
? "开品前记录参考图、品牌方向、类目、竞品、开品理由和设计卖点。"
|
||||
: "开品前先记录参考图、品牌方向、类目、竞品、开品理由和设计卖点。立项通过后再补产品资料、样品评审和完整基础信息。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className={DOCUMENT_WORKSPACE_ACTION_GROUP_CLASS}>
|
||||
@ -25109,6 +25236,8 @@ function renderFactoryQualityWorkspace() {
|
||||
hasContent: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
if (isCurrentUserPlainReadOnlyViewer) return null;
|
||||
|
||||
const isApproved = isWorkflowNodeConfirmed(category, hasContent);
|
||||
|
||||
if (isApproved) {
|
||||
@ -27550,7 +27679,10 @@ function renderFactoryQualityWorkspace() {
|
||||
active: activeSkuTab === option.key,
|
||||
})}
|
||||
>
|
||||
{option.label}
|
||||
{isCurrentUserPlainReadOnlyViewer &&
|
||||
option.key === "fileUpload"
|
||||
? "资料文件"
|
||||
: option.label}
|
||||
</button>
|
||||
))}
|
||||
{canDownloadWorkspaceFiles ? (
|
||||
@ -29908,6 +30040,7 @@ function renderFactoryQualityWorkspace() {
|
||||
bodyClassName="flex min-h-0 flex-1 flex-col"
|
||||
compact={isProductPhotoWorkspace}
|
||||
headerActions={
|
||||
isCurrentUserPlainReadOnlyViewer ? null :
|
||||
isOpeningReferenceWorkspace ||
|
||||
isPatentApplicationWorkspace ||
|
||||
isProjectReviewWorkspace ? null : isPlanningLayoutWorkspace ? (
|
||||
@ -30828,14 +30961,16 @@ function renderFactoryQualityWorkspace() {
|
||||
</>
|
||||
) : (
|
||||
<span className="px-2 text-xs text-apple-label-secondary">
|
||||
待上传
|
||||
{isReadOnly ? "暂无图片" : "待上传"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-[10px] leading-4 text-apple-label-secondary">
|
||||
{slotFile
|
||||
? "已匹配"
|
||||
: "点击上传"}
|
||||
: isReadOnly
|
||||
? "暂无图片"
|
||||
: "点击上传"}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
@ -30861,9 +30996,9 @@ function renderFactoryQualityWorkspace() {
|
||||
当前还没有{activeProductPhotoPanelTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-5 text-apple-label-secondary">
|
||||
点击这里或上方“
|
||||
{currentProductPhotoGroupUploadLabel}
|
||||
”后,这里会显示图片缩略图列表。
|
||||
{isReadOnly
|
||||
? `当前还没有${activeProductPhotoPanelTitle}资料。`
|
||||
: `点击这里或上方“${currentProductPhotoGroupUploadLabel}”后,这里会显示图片缩略图列表。`}
|
||||
</p>
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, type ReactNode } from 'react'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { Suspense, useEffect, useState, type ReactNode } from 'react'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { AppTopNav } from './AppTopNav'
|
||||
import { AppSideNav } from './AppSideNav'
|
||||
import {
|
||||
FORMAL_PRODUCTS_HOME_PATH,
|
||||
canAccessWorkspacePath,
|
||||
type WorkspacePermissionUser,
|
||||
} from '@/lib/workspace-route-permissions'
|
||||
|
||||
// AppShell 只服务新平台主链;旧 /products 兼容链和工具页不进入这套全局壳子
|
||||
export const APP_SHELL_ROOTS = [
|
||||
@ -32,19 +37,35 @@ export function shouldUseAppShell(pathname: string): boolean {
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode
|
||||
user?: {
|
||||
user?: WorkspaceShellUser | null
|
||||
}
|
||||
|
||||
interface WorkspaceShellUser extends WorkspacePermissionUser {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
businessRoles?: string[]
|
||||
avatar: string | null
|
||||
department: { id: string; name: string } | null
|
||||
} | null
|
||||
department: { id: string; name: string; fullPath?: string | null } | null
|
||||
departmentPaths?: string[]
|
||||
}
|
||||
|
||||
interface AuthMeResponse {
|
||||
success?: boolean
|
||||
data?: {
|
||||
authenticated?: boolean
|
||||
user?: WorkspaceShellUser
|
||||
}
|
||||
}
|
||||
|
||||
function AppShellContent({ children, user }: AppShellProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [fetchedUser, setFetchedUser] = useState<WorkspaceShellUser | null | undefined>(
|
||||
user === undefined ? undefined : user,
|
||||
)
|
||||
const isOpeningDetailLinkView =
|
||||
/^\/intake\/non-integrated\/[^/]+$/.test(pathname) &&
|
||||
searchParams.get('workspace') === 'opening-reference'
|
||||
@ -77,6 +98,57 @@ function AppShellContent({ children, user }: AppShellProps) {
|
||||
const shouldLockMainViewport =
|
||||
isWorkbenchShellRoute
|
||||
|
||||
useEffect(() => {
|
||||
if (user !== undefined) {
|
||||
setFetchedUser(user)
|
||||
return
|
||||
}
|
||||
|
||||
if (!shouldUseAppShell(pathname)) {
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', { cache: 'no-store' })
|
||||
const payload = (await response.json().catch(() => null)) as AuthMeResponse | null
|
||||
|
||||
if (!active) return
|
||||
|
||||
if (response.ok && payload?.success && payload.data?.authenticated && payload.data.user) {
|
||||
setFetchedUser(payload.data.user)
|
||||
return
|
||||
}
|
||||
|
||||
setFetchedUser(null)
|
||||
} catch {
|
||||
if (active) {
|
||||
setFetchedUser(null)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [pathname, user])
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchedUser === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!shouldUseAppShell(pathname)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!canAccessWorkspacePath(pathname, fetchedUser)) {
|
||||
router.replace(FORMAL_PRODUCTS_HOME_PATH)
|
||||
}
|
||||
}, [fetchedUser, pathname, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLockMainViewport || typeof document === 'undefined') {
|
||||
return
|
||||
@ -122,7 +194,7 @@ function AppShellContent({ children, user }: AppShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-surface)' }}>
|
||||
<AppTopNav
|
||||
user={user}
|
||||
user={user === undefined ? fetchedUser ?? null : user}
|
||||
variant={isWorkbenchShellRoute ? 'workbench' : 'default'}
|
||||
/>
|
||||
{!isWorkbenchShellRoute && <AppSideNav />}
|
||||
|
||||
@ -15,6 +15,12 @@ import {
|
||||
} from 'lucide-react'
|
||||
import UserMenu from '@/components/ui/UserMenu'
|
||||
import { APP_BRAND_LOGO_SRC, APP_BRAND_NAME } from '@/lib/branding'
|
||||
import {
|
||||
FORMAL_PRODUCTS_HOME_PATH,
|
||||
canAccessWorkspacePath,
|
||||
getWorkspaceAccessLevel,
|
||||
type WorkspacePermissionUser,
|
||||
} from '@/lib/workspace-route-permissions'
|
||||
|
||||
const WORKBENCH_NAV_ITEMS = [
|
||||
{ href: '/', icon: Home, label: '首页概览' },
|
||||
@ -32,14 +38,16 @@ function matchesWorkbenchHref(pathname: string, href: string) {
|
||||
}
|
||||
|
||||
interface AppTopNavProps {
|
||||
user?: {
|
||||
user?: ({
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
businessRoles?: string[]
|
||||
avatar: string | null
|
||||
department: { id: string; name: string } | null
|
||||
} | null
|
||||
department: { id: string; name: string; fullPath?: string | null } | null
|
||||
departmentPaths?: string[]
|
||||
} & WorkspacePermissionUser) | null
|
||||
variant?: 'default' | 'workbench'
|
||||
}
|
||||
|
||||
@ -47,9 +55,16 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
|
||||
const pathname = usePathname() ?? '/'
|
||||
|
||||
if (variant === 'workbench') {
|
||||
const shouldRestrictNavigation =
|
||||
user !== undefined && getWorkspaceAccessLevel(user) === 'formal-products-only'
|
||||
const brandHref = shouldRestrictNavigation ? FORMAL_PRODUCTS_HOME_PATH : '/'
|
||||
const visibleNavItems = shouldRestrictNavigation
|
||||
? WORKBENCH_NAV_ITEMS.filter((item) => canAccessWorkspacePath(item.href, user))
|
||||
: WORKBENCH_NAV_ITEMS
|
||||
|
||||
return (
|
||||
<nav className="fixed left-0 right-0 top-0 z-50 flex h-16 items-center border-b border-black/[0.04] bg-white/[0.92] px-8 font-[Manrope,sans-serif] tracking-tight backdrop-blur-2xl">
|
||||
<Link href="/" className="flex min-w-[172px] items-center gap-2.5">
|
||||
<Link href={brandHref} className="flex min-w-[172px] items-center gap-2.5">
|
||||
<img
|
||||
src={APP_BRAND_LOGO_SRC}
|
||||
alt=""
|
||||
@ -66,7 +81,7 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
|
||||
<div className="mx-7 h-8 w-px bg-black/[0.05]" />
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{WORKBENCH_NAV_ITEMS.map((item) => {
|
||||
{visibleNavItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = matchesWorkbenchHref(pathname, item.href)
|
||||
|
||||
|
||||
@ -2,9 +2,11 @@ import { render, screen } from '@testing-library/react'
|
||||
import { AppShell, shouldUseAppShell } from '../AppShell'
|
||||
|
||||
const usePathnameMock = jest.fn()
|
||||
const replaceMock = jest.fn()
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: () => usePathnameMock(),
|
||||
useRouter: () => ({ replace: replaceMock }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
@ -23,6 +25,7 @@ jest.mock('@/components/layout/AppSideNav', () => ({
|
||||
describe('AppShell', () => {
|
||||
beforeEach(() => {
|
||||
usePathnameMock.mockReset()
|
||||
replaceMock.mockReset()
|
||||
})
|
||||
|
||||
it('只对新平台主链页面启用 AppShell', () => {
|
||||
|
||||
@ -8,6 +8,8 @@ export type AccessoryLibraryItem = {
|
||||
letter: string
|
||||
code: string
|
||||
name: string
|
||||
color?: string
|
||||
material?: string
|
||||
price: string
|
||||
imageUrl: string
|
||||
imageName: string
|
||||
@ -71,6 +73,8 @@ export function readAccessoryLibraryItems() {
|
||||
typeof item.letter === 'string' &&
|
||||
typeof item.code === 'string' &&
|
||||
typeof item.name === 'string' &&
|
||||
(item.color === undefined || typeof item.color === 'string') &&
|
||||
(item.material === undefined || typeof item.material === 'string') &&
|
||||
typeof item.price === 'string' &&
|
||||
typeof item.imageUrl === 'string' &&
|
||||
typeof item.imageName === 'string' &&
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { Role, UserStatus } from '@prisma/client';
|
||||
import type { WorkspaceAccessLevel } from '@/lib/workspace-route-permissions';
|
||||
|
||||
/**
|
||||
* JWT 配置 - 安全增强
|
||||
@ -39,6 +40,7 @@ export interface TokenPayload {
|
||||
isFirstLogin: boolean;
|
||||
departmentId?: string;
|
||||
teamId?: string;
|
||||
workspaceAccess?: WorkspaceAccessLevel;
|
||||
exp?: number; // JWT 过期时间(Unix 时间戳)
|
||||
iat?: number; // JWT 签发时间(Unix 时间戳)
|
||||
}
|
||||
|
||||
@ -166,6 +166,10 @@ function isBrandPlanningDepartmentPath(path: string) {
|
||||
return /商品企划部/.test(path)
|
||||
}
|
||||
|
||||
function isBrandCenterDepartmentPath(path: string) {
|
||||
return /品牌中心/.test(path)
|
||||
}
|
||||
|
||||
function isSpaceVisualDepartmentPath(path: string) {
|
||||
return /空间视觉部/.test(path)
|
||||
}
|
||||
@ -237,6 +241,15 @@ export function isIntakeNonIntegratedPlanningDepartmentUser(
|
||||
)
|
||||
}
|
||||
|
||||
export function isIntakeNonIntegratedBrandCenterPlanningUser(
|
||||
user: IntakeNonIntegratedPermissionUser,
|
||||
) {
|
||||
return (
|
||||
hasDepartmentPath(user, isBrandCenterDepartmentPath) ||
|
||||
hasBusinessRoleFallback(user, 'PLANNING')
|
||||
)
|
||||
}
|
||||
|
||||
export function isIntakeNonIntegratedSpaceDepartmentUser(
|
||||
user: IntakeNonIntegratedPermissionUser,
|
||||
) {
|
||||
@ -344,7 +357,10 @@ export function isCurrentUserNonIntegratedSpaceOwner(
|
||||
export function isIntakeNonIntegratedPlanningLayoutEditor(
|
||||
user: IntakeNonIntegratedPermissionUser,
|
||||
) {
|
||||
return hasBusinessRole(user, 'PLANNING')
|
||||
return (
|
||||
hasDepartmentPath(user, isSpaceVisualDepartmentPath) ||
|
||||
hasBusinessRoleFallback(user, 'PLANNING')
|
||||
)
|
||||
}
|
||||
|
||||
export function isCurrentUserNonIntegratedPurchaseOwner(
|
||||
|
||||
81
furniture-3d-system/src/lib/workspace-route-permissions.ts
Normal file
81
furniture-3d-system/src/lib/workspace-route-permissions.ts
Normal file
@ -0,0 +1,81 @@
|
||||
export const FORMAL_PRODUCTS_HOME_PATH = '/intake'
|
||||
|
||||
export type WorkspaceAccessLevel = 'full' | 'formal-products-only'
|
||||
|
||||
export interface WorkspacePermissionUser {
|
||||
role?: string | null
|
||||
businessRoles?: readonly string[] | null
|
||||
department?: { name?: string | null; fullPath?: string | null } | null
|
||||
departmentPaths?: readonly string[] | null
|
||||
}
|
||||
|
||||
const FULL_ACCESS_ROLES = new Set(['SUPER_ADMIN', 'ADMIN'])
|
||||
const DESIGN_BUSINESS_ROLE = 'DESIGN'
|
||||
|
||||
const RESTRICTED_ALLOWED_PATH_PREFIXES = [
|
||||
FORMAL_PRODUCTS_HOME_PATH,
|
||||
'/profile',
|
||||
'/approval-workflows',
|
||||
]
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function getDepartmentPathValues(user: WorkspacePermissionUser) {
|
||||
return [
|
||||
user.department?.fullPath,
|
||||
user.department?.name,
|
||||
...(user.departmentPaths ?? []),
|
||||
]
|
||||
.map(normalizeText)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function isDesignDepartmentPath(path: string) {
|
||||
return /产品研发|设计|空间视觉/.test(path)
|
||||
}
|
||||
|
||||
export function hasFullWorkspaceAccess(user: WorkspacePermissionUser | null | undefined) {
|
||||
if (!user) return false
|
||||
if (user.role && FULL_ACCESS_ROLES.has(user.role)) return true
|
||||
if (user.businessRoles?.includes(DESIGN_BUSINESS_ROLE)) return true
|
||||
return getDepartmentPathValues(user).some(isDesignDepartmentPath)
|
||||
}
|
||||
|
||||
export function getWorkspaceAccessLevel(
|
||||
user: WorkspacePermissionUser | null | undefined,
|
||||
): WorkspaceAccessLevel {
|
||||
return hasFullWorkspaceAccess(user) ? 'full' : 'formal-products-only'
|
||||
}
|
||||
|
||||
export function isRestrictedWorkspacePathAllowed(pathname: string) {
|
||||
return RESTRICTED_ALLOWED_PATH_PREFIXES.some(
|
||||
(path) => pathname === path || pathname.startsWith(`${path}/`),
|
||||
)
|
||||
}
|
||||
|
||||
export function canAccessWorkspacePath(
|
||||
pathname: string,
|
||||
user: WorkspacePermissionUser | null | undefined,
|
||||
) {
|
||||
if (getWorkspaceAccessLevel(user) === 'full') return true
|
||||
return isRestrictedWorkspacePathAllowed(pathname)
|
||||
}
|
||||
|
||||
export function canAccessWorkspacePathByAccessLevel(
|
||||
pathname: string,
|
||||
accessLevel: WorkspaceAccessLevel | null | undefined,
|
||||
) {
|
||||
if (!accessLevel || accessLevel === 'full') return true
|
||||
return isRestrictedWorkspacePathAllowed(pathname)
|
||||
}
|
||||
|
||||
export function resolveWorkspaceRedirectForUser(
|
||||
requestedPath: string,
|
||||
user: WorkspacePermissionUser | null | undefined,
|
||||
) {
|
||||
return canAccessWorkspacePath(requestedPath, user)
|
||||
? requestedPath
|
||||
: FORMAL_PRODUCTS_HOME_PATH
|
||||
}
|
||||
@ -9,6 +9,11 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { validateToken } from '@/lib/token-validator'
|
||||
import {
|
||||
FORMAL_PRODUCTS_HOME_PATH,
|
||||
canAccessWorkspacePathByAccessLevel,
|
||||
type WorkspaceAccessLevel,
|
||||
} from '@/lib/workspace-route-permissions'
|
||||
|
||||
export const publicPaths = [
|
||||
'/login',
|
||||
@ -37,6 +42,11 @@ export function isPublicPath(pathname: string): boolean {
|
||||
return PUBLIC_ASSET_PATH_PATTERN.test(pathname) || publicPaths.some((path) => pathname.startsWith(path))
|
||||
}
|
||||
|
||||
interface WorkspaceTokenPayload {
|
||||
exp?: number
|
||||
workspaceAccess?: WorkspaceAccessLevel
|
||||
}
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
if (isPublicPath(pathname)) {
|
||||
@ -50,7 +60,7 @@ export async function proxy(request: NextRequest) {
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
const payload = await validateToken<{ exp?: number }>(token, JWT_SECRET)
|
||||
const payload = await validateToken<WorkspaceTokenPayload>(token, JWT_SECRET)
|
||||
if (!payload) {
|
||||
const loginUrl = new URL('/login', request.url)
|
||||
loginUrl.searchParams.set('redirect', pathname)
|
||||
@ -59,6 +69,10 @@ export async function proxy(request: NextRequest) {
|
||||
return response
|
||||
}
|
||||
|
||||
if (!canAccessWorkspacePathByAccessLevel(pathname, payload.workspaceAccess)) {
|
||||
return NextResponse.redirect(new URL(FORMAL_PRODUCTS_HOME_PATH, request.url))
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
|
||||
@ -187,6 +187,7 @@ class AuthRepository {
|
||||
email: string
|
||||
name: string
|
||||
role: Role
|
||||
businessRoles: BusinessAccountType[]
|
||||
status: UserStatus
|
||||
isFirstLogin: boolean
|
||||
loginSource: string
|
||||
@ -201,6 +202,7 @@ class AuthRepository {
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
businessRoles: true,
|
||||
status: true,
|
||||
isFirstLogin: true,
|
||||
loginSource: true,
|
||||
|
||||
@ -15,6 +15,10 @@ import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { BusinessAccountType, Prisma, Role, UserStatus } from '@prisma/client'
|
||||
import { authRepository, type AuthUserWithRelations } from '../repositories/auth.repository'
|
||||
import { getUserDepartmentPathsById } from './user-department-paths.service'
|
||||
import {
|
||||
getWorkspaceAccessLevel,
|
||||
type WorkspaceAccessLevel,
|
||||
} from '@/lib/workspace-route-permissions'
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
@ -91,6 +95,7 @@ export interface TokenPayload {
|
||||
isFirstLogin: boolean
|
||||
departmentId?: string
|
||||
teamId?: string
|
||||
workspaceAccess?: WorkspaceAccessLevel
|
||||
}
|
||||
|
||||
// ============ 辅助函数 ============
|
||||
@ -237,6 +242,10 @@ class AuthService {
|
||||
isFirstLogin: false,
|
||||
departmentId: updatedUser.departmentId || undefined,
|
||||
teamId: updatedUser.teamId || undefined,
|
||||
workspaceAccess: getWorkspaceAccessLevel({
|
||||
role: updatedUser.role,
|
||||
businessRoles: updatedUser.businessRoles,
|
||||
}),
|
||||
})
|
||||
|
||||
return {
|
||||
@ -365,6 +374,10 @@ class AuthService {
|
||||
isFirstLogin: user.isFirstLogin,
|
||||
departmentId: user.departmentId || undefined,
|
||||
teamId: user.teamId || undefined,
|
||||
workspaceAccess: getWorkspaceAccessLevel({
|
||||
role: user.role,
|
||||
businessRoles: user.businessRoles,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@ -410,6 +423,12 @@ class AuthService {
|
||||
isFirstLogin: user.isFirstLogin,
|
||||
departmentId: user.departmentId || undefined,
|
||||
teamId: user.teamId || undefined,
|
||||
workspaceAccess: getWorkspaceAccessLevel({
|
||||
role: user.role,
|
||||
businessRoles: user.businessRoles,
|
||||
department: user.department,
|
||||
departmentPaths,
|
||||
}),
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user