From aa2ce7910bbacb337a278740660e9a5ab907530d Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 10 Jun 2026 19:35:39 +0800 Subject: [PATCH] refactor(intake): split primary intake domains --- .../app/intake/AlphabetLibraryPanel.test.tsx | 4 +- .../intake/EdgeBandingLibraryPanel.test.tsx | 2 +- .../src/app/intake/IntakeClient.test.tsx | 18 +- .../src/app/intake/IntakeClient.tsx | 8177 +---------------- .../AccessoryLibraryPanel.tsx | 0 .../intake/bom-library/BomLibraryDomain.tsx | 202 + .../EdgeBandingLibraryPanel.tsx | 0 .../PackagingMaterialLibraryPanel.tsx | 0 .../SteelFrameLibraryPanel.tsx | 0 .../src/app/intake/components/SpuLabel.tsx | 7 + .../NonIntegratedDetailClient.test.tsx | 2 +- .../NonIntegratedDetailClient.tsx | 6 +- .../ProductDevelopmentDomain.tsx | 4927 ++++++++++ .../SpuRuleLibraryWorkspace.test.tsx | 0 .../SpuRuleLibraryWorkspace.tsx | 0 .../spuRuleLibraryLogic.test.ts | 0 .../spuRuleLibraryLogic.ts | 0 .../spuRuleLibrarySeed.ts | 0 .../product-patent/ProductPatentDomain.tsx | 3169 +++++++ .../RawMaterialLibraryDomain.tsx | 46 + .../RawMaterialLibraryPanel.tsx | 0 .../ReferenceEstimatorDomain.tsx | 19 + .../jushuitan-product-pricing-push.service.ts | 2 +- 23 files changed, 8436 insertions(+), 8145 deletions(-) rename furniture-3d-system/src/app/intake/{ => bom-library}/AccessoryLibraryPanel.tsx (100%) create mode 100644 furniture-3d-system/src/app/intake/bom-library/BomLibraryDomain.tsx rename furniture-3d-system/src/app/intake/{ => bom-library}/EdgeBandingLibraryPanel.tsx (100%) rename furniture-3d-system/src/app/intake/{ => bom-library}/PackagingMaterialLibraryPanel.tsx (100%) rename furniture-3d-system/src/app/intake/{ => bom-library}/SteelFrameLibraryPanel.tsx (100%) create mode 100644 furniture-3d-system/src/app/intake/components/SpuLabel.tsx create mode 100644 furniture-3d-system/src/app/intake/product-development/ProductDevelopmentDomain.tsx rename furniture-3d-system/src/app/intake/{ => product-development}/SpuRuleLibraryWorkspace.test.tsx (100%) rename furniture-3d-system/src/app/intake/{ => product-development}/SpuRuleLibraryWorkspace.tsx (100%) rename furniture-3d-system/src/app/intake/{ => product-development}/spuRuleLibraryLogic.test.ts (100%) rename furniture-3d-system/src/app/intake/{ => product-development}/spuRuleLibraryLogic.ts (100%) rename furniture-3d-system/src/app/intake/{ => product-development}/spuRuleLibrarySeed.ts (100%) create mode 100644 furniture-3d-system/src/app/intake/product-patent/ProductPatentDomain.tsx create mode 100644 furniture-3d-system/src/app/intake/raw-material-library/RawMaterialLibraryDomain.tsx rename furniture-3d-system/src/app/intake/{ => raw-material-library}/RawMaterialLibraryPanel.tsx (100%) create mode 100644 furniture-3d-system/src/app/intake/reference-estimator/ReferenceEstimatorDomain.tsx diff --git a/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx b/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx index a4901cb7..a76344e9 100644 --- a/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx +++ b/furniture-3d-system/src/app/intake/AlphabetLibraryPanel.test.tsx @@ -1,7 +1,7 @@ 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 AccessoryLibraryPanel from '@/app/intake/bom-library/AccessoryLibraryPanel' +import PackagingMaterialLibraryPanel from '@/app/intake/bom-library/PackagingMaterialLibraryPanel' const LEGACY_PACKAGING_MATERIAL_LIBRARY_STORAGE_KEY = 'formal-intake-packaging-material-library-items' diff --git a/furniture-3d-system/src/app/intake/EdgeBandingLibraryPanel.test.tsx b/furniture-3d-system/src/app/intake/EdgeBandingLibraryPanel.test.tsx index b99376b0..ec857af5 100644 --- a/furniture-3d-system/src/app/intake/EdgeBandingLibraryPanel.test.tsx +++ b/furniture-3d-system/src/app/intake/EdgeBandingLibraryPanel.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import EdgeBandingLibraryPanel from '@/app/intake/EdgeBandingLibraryPanel' +import EdgeBandingLibraryPanel from '@/app/intake/bom-library/EdgeBandingLibraryPanel' import { createEdgeBandingLibraryItem } from '@/lib/edge-banding-library' const LEGACY_EDGE_BANDING_LIBRARY_STORAGE_KEY = diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx index 9202425a..fb112342 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx @@ -820,6 +820,7 @@ describe('IntakeClient', () => { 'true', ) } finally { + jest.runOnlyPendingTimers() jest.useRealTimers() } }) @@ -1961,10 +1962,25 @@ describe('IntakeClient', () => { new File(['reference-image'], 'cabinet.png', { type: 'image/png' }), ) + await waitFor( + () => + expect(fetchMock).toHaveBeenCalledWith( + '/api/intake/reference-image-estimate', + expect.objectContaining({ + method: 'POST', + }), + ), + { timeout: 5000 }, + ) + expect(screen.getByRole('heading', { name: '样包模板明细' })).toBeInTheDocument() expect(screen.getByText('分类')).toBeInTheDocument() expect(screen.getByText('封边米数')).toBeInTheDocument() - expect(await screen.findByText('图片识别:柜体侧板')).toBeInTheDocument() + expect( + await screen.findByText('图片识别:柜体侧板', undefined, { + timeout: 5000, + }), + ).toBeInTheDocument() expect(screen.getByText('图片识别:层板/顶底板')).toBeInTheDocument() expect(screen.getByText('图片识别:前后拉板')).toBeInTheDocument() expect(screen.getByText('图片识别:三合板背板')).toBeInTheDocument() diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index 32647f01..a5635371 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -1,14 +1,8 @@ "use client"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; import { - type ChangeEvent, - type KeyboardEvent, - type ReactNode, useCallback, useEffect, - useMemo, useRef, useState, } from "react"; @@ -16,67 +10,31 @@ import { ArrowDownToLine, Boxes, Calculator, - Check, ClipboardCheck, ClipboardList, - Copy, Database, - Download, FileSpreadsheet, Home, KeyRound, Layers, - Layers3, Package, PackageOpen, - Palette, - Pencil, - Plus, - RotateCcw, ShieldCheck, - Trash2, - Upload, Warehouse, X as XIcon, type LucideIcon, } from "lucide-react"; -import MaterialLibraryClient from "@/app/asset-center/material-library/MaterialLibraryClient"; -import AccessoryLibraryPanel from "@/app/intake/AccessoryLibraryPanel"; -import EdgeBandingLibraryPanel from "@/app/intake/EdgeBandingLibraryPanel"; -import PackagingMaterialLibraryPanel from "@/app/intake/PackagingMaterialLibraryPanel"; -import RawMaterialLibraryPanel, { - ReferenceImageCostEstimator, - type RawMaterialLibraryKey, -} from "@/app/intake/RawMaterialLibraryPanel"; -import SteelFrameLibraryPanel from "@/app/intake/SteelFrameLibraryPanel"; -import { Badge } from "@/components/ui/Badge"; import Button from "@/components/ui/Button"; import { PlatformSplitLayout } from "@/components/platform/PlatformSplitLayout"; import { AccordionSection } from "@/components/platform/AccordionSection"; -import { QUALITY_INSPECTION_EDITABLE_FIELD_KEYS } from "@/lib/non-integrated-quality-inspection-schema"; import { readPreSkuTextField } from "@/lib/open-product/platform-view"; import { - createDefaultDocumentWorkspaceConfirmations, - createCopiedNonIntegratedProduct, - deleteNonIntegratedProduct, getNonIntegratedProductDisplayName, - getSearchableNonIntegratedSpuKeywords, - hasPendingDocumentWorkspaceConfirmation, - listNonIntegratedProducts, - NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES, - NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES, - NON_INTEGRATED_PRODUCTS_UPDATED, - getNonIntegratedDocumentWorkspaceCategoryLabel, - normalizeNonIntegratedPatentApplication, - resolveNonIntegratedPersistedSpu, - saveNonIntegratedProduct, - stripNonIntegratedBrandPrefix, type NonIntegratedDocumentWorkspaceCategory, type NonIntegratedProductRecord, type NonIntegratedSkuComponentDetail, type NonIntegratedSkuHolePoint, - type NonIntegratedWorkflowNodeCategory, } from "./non-integrated/nonIntegratedStorage"; import NonIntegratedDetailClient from "./non-integrated/NonIntegratedDetailClient"; import { @@ -85,21 +43,12 @@ import { type SeriesDraftSkuFields, } from "./seriesComponentDetails"; import { - deleteSharedNonIntegratedProduct, getSharedNonIntegratedProductById, - listSharedNonIntegratedProductPage, - listSharedNonIntegratedStockCategoryOptions, - saveSharedNonIntegratedProduct, - type NonIntegratedProductPageInfo, } from "./non-integrated/nonIntegratedApi"; import { - canEditAnyNonIntegratedWorkspace, canCreateNonIntegratedProductFromIntake, - canEditNonIntegratedProductIdentity, canDeleteNonIntegratedProductByIntakePermission, isIntakeNonIntegratedDesignDepartmentUser, - isIntakeNonIntegratedPricingGroupUser, - isIntakeNonIntegratedQualityGroupUser, type IntakeNonIntegratedPermissionUser, } from "@/lib/non-integrated-permissions"; import { @@ -119,23 +68,48 @@ import { type IntakeNodePermissionSubject, type IntakeNodePermissionTargetKey, } from "@/lib/intake-node-permission-settings"; +import { SpuRuleLibraryWorkspace } from "./product-development/SpuRuleLibraryWorkspace"; import { - getNextProductPatentFeeDueDate, - listProductPatentRecords, - listProductPatentRecordsFromDatabase, - mergeProductPatentRecordsWithProducts, - PRODUCT_PATENT_FEE_YEARS, - saveProductPatentRecords, - saveProductPatentRecordsToDatabase, - type ProductPatentContinueDecision, - type ProductPatentFeeStatus, - type ProductPatentRecord, - type ProductPatentSaRequired, - type ProductPatentSalesStatus, - type ProductPatentStatus, - type ProductPatentType, -} from "./productPatentStorage"; -import { SpuRuleLibraryWorkspace } from "./SpuRuleLibraryWorkspace"; + BOM_LIBRARY_OPTIONS, + BomDetail, + getBomLibraryLabel, + type BomLibraryKey, +} from "./bom-library/BomLibraryDomain"; +import { SpuLabel } from "./components/SpuLabel"; +import { + canOpenNonIntegratedProductForEdit, + DetailPanel, + getCurrentNodeCategoryForList, + getCurrentNodeSummary, + getDocumentWorkspaceCountByCategory, + getPendingEntryTabFromNewProductTab, + getPendingEntryTabFromProduct, + getPendingEntryTabFromProductDevelopmentKind, + getPendingEntryTabLabel, + getProductDevelopmentDetailLabel, + getProductDevelopmentKind, + getProductWorkspaceRenderKey, + getUniqueNonEmptyValues, + getUniqueSortedStrings, + isJstSkuImportedStockProduct, + isSeriesComponentDetail, + NonIntegratedPendingDetail, + ProductDevelopmentProgressDashboard, + readSeriesComponentDetailSourceSeriesName, + type NonIntegratedProductKindFilter, +} from "./product-development/ProductDevelopmentDomain"; +import { + ProductPatentDashboard, + ProductPatentStatusDashboard, + type ProductPatentLedgerTarget, +} from "./product-patent/ProductPatentDomain"; +import { + getRawMaterialLibraryLabel, + RAW_MATERIAL_LIBRARY_OPTIONS, + RawMaterialLibraryDetail, + type RawMaterialLibraryKey, +} from "./raw-material-library/RawMaterialLibraryDomain"; +import { ReferenceEstimatorDetail } from "./reference-estimator/ReferenceEstimatorDomain"; import type { CategoryDTO, DraftGroupDetailDTO, @@ -257,28 +231,6 @@ function canAccessDesignControlledProductData( return isIntakeNonIntegratedDesignDepartmentUser(user); } -function canEditNonIntegratedProduct( - product: NonIntegratedProductRecord, - user: IntakeProductPermissionUser, -): boolean { - return canEditNonIntegratedProductIdentity(product, user); -} - -function canOpenNonIntegratedProductForEdit( - product: NonIntegratedProductRecord, - user: IntakeProductPermissionUser, -): boolean { - if (canEditAnyNonIntegratedWorkspace(product, user)) { - return true; - } - - const subjectKeys = resolveIntakeNodePermissionSubjectKeys(user); - return Boolean(resolveIntakeNodePermissionAccess( - {}, - "planningLayout", - subjectKeys, - ).edit); -} function isIntakeAdminUser(user: IntakeProductPermissionUser) { return user.role === "SUPER_ADMIN" || user.role === "ADMIN"; @@ -319,22 +271,6 @@ function canEditIntakeFormalWorkspace( ); } -function createRestoredNonIntegratedProduct( - product: NonIntegratedProductRecord, -): NonIntegratedProductRecord { - return { - ...product, - projectStatus: "draft", - openingReference: { - ...product.openingReference, - currentStage: "立项前", - }, - documentWorkspaceConfirmations: - createDefaultDocumentWorkspaceConfirmations(false), - documentWorkspaceApprovedAt: {}, - updatedAt: new Date().toISOString(), - }; -} type IntakeSection = | "pending" @@ -350,27 +286,11 @@ type ProductDevelopmentKind = NonNullable< NonIntegratedProductRecord["productDevelopmentKind"] >; type NewNonIntegratedCreateKind = "new" | "stock" | "seriesComponents"; -type BomLibraryKey = - | "materialLibrary" - | "finishLibrary" - | "edgeBandingLibrary" - | "steelFrameLibrary" - | "packagingLibrary" - | "packagingMaterialLibrary"; type WorkspaceProductMode = "view" | "edit"; type WorkspaceProductDetailState = { view: "detail" | "document"; category: NonIntegratedDocumentWorkspaceCategory; }; -type ProductPatentLedgerTarget = { - productId?: string; - patentGroupKey?: string; - applicationNo?: string; - patentNo?: string; - spu?: string; - patentName?: string; -}; - const FORMAL_SIDEBAR_SECTION_ICON_CLASS = "h-4 w-4"; const FORMAL_SIDEBAR_ITEM_ICON_CLASS = "h-3.5 w-3.5 shrink-0"; const FORMAL_SIDEBAR_GROUP_ICONS = { @@ -1007,438 +927,6 @@ type IntegratedPendingSeriesRow = { status: string; }; -function SpuLabel() { - return ( - - SPU - - ); -} - -function DocumentStatusMark({ - category, - ready, -}: { - category: NonIntegratedDocumentStatusCategory; - ready: boolean; -}) { - const Icon = ready ? Check : XIcon; - const label = getNonIntegratedDocumentWorkspaceCategoryLabel(category); - const statusText = ready ? "已完成" : "未完成或无数据"; - - return ( - - - ); -} - -type NonIntegratedDocumentStatusCategory = NonIntegratedWorkflowNodeCategory; - -const NON_INTEGRATED_DOCUMENT_STATUS_HIDDEN_CATEGORIES: ReadonlySet = - new Set([ - "立项评审", - "检测报告", - ]); - -const NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS = [ - ...NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES.filter( - (category) => !NON_INTEGRATED_DOCUMENT_STATUS_HIDDEN_CATEGORIES.has(category), - ), -] as NonIntegratedDocumentStatusCategory[]; -const NON_INTEGRATED_FLOW_COMPLETE_CATEGORY = - NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS.find( - (category) => category === "产品核价", - ) ?? - NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS[ - NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS.length - 1 - ]; -const NON_INTEGRATED_NODE_OWNER_LABELS: Partial< - Record -> = { - 开品参考: "外观设计师", - 设计卖点: "外观设计师", - 产品模型: "外观/结构", - 评审资料: "设计/采购", - 基础信息: "结构设计师", - 企划布局: "空间设计师", - 专利申请: "外观设计师", - 产品实拍图: "结构设计师", - 安装方式: "结构设计师", - 包装细节: "结构设计师", - 产品定厂: "采购负责人", - 样包测量: "采购/核价组", - 成品寄样: "品牌/采购", - 产品核价: "采购/核价组", - 产品限价: "商品企划", - 工厂质检: "质检组", -}; - -function getNonIntegratedListNodeLabel( - category: NonIntegratedDocumentStatusCategory, -) { - if (category === "企划布局") return "空间布局"; - return getNonIntegratedDocumentWorkspaceCategoryLabel(category); -} - -function getNonIntegratedListNodeOwnerLabel( - category: NonIntegratedDocumentStatusCategory, -) { - return NON_INTEGRATED_NODE_OWNER_LABELS[category] ?? ""; -} - -function safeTrim(value: unknown) { - return typeof value === "string" ? value.trim() : ""; -} - -function getNonIntegratedListPeople(product: NonIntegratedProductRecord) { - return { - appearanceDesigner: - safeTrim(product.openingReference?.designer) || safeTrim(product.designer), - structureDesigner: - safeTrim(product.openingReference?.structureDesigner), - spuOwner: - safeTrim(product.spuOwner) || - safeTrim(product.operator) || - safeTrim(product.operationsOwner), - planningOwner: - safeTrim(product.productPlanning) || - safeTrim(product.openingReference?.planningOwner), - spaceDesigner: - safeTrim(product.visualPlanning) || safeTrim(product.visualDirector), - purchaseOwner: safeTrim(product.purchaseOwner) || safeTrim(product.purchaser), - }; -} - -function displayPerson(value: string) { - return value.trim() || "-"; -} - -function productMatchesText(value: string, keyword: string) { - const normalizedKeyword = keyword.trim().toLowerCase(); - return ( - normalizedKeyword.length === 0 || - value.toLowerCase().includes(normalizedKeyword) - ); -} - -function getLegacyNonIntegratedProductOwnerNames( - product: NonIntegratedProductRecord, -) { - const people = getNonIntegratedListPeople(product); - return getUniqueNonEmptyValues([ - people.appearanceDesigner, - people.structureDesigner, - people.spuOwner, - people.planningOwner, - people.spaceDesigner, - people.purchaseOwner, - ]); -} - -function matchesCurrentUserName( - values: Array, - 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 people = getNonIntegratedListPeople(product); - const scopedValues: string[] = []; - - 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 matchesCurrentUserName( - getCurrentUserResponsibleOwnerValues(product, user), - user, - ); -} - -const PENDING_PAGE_SIZE = 50; -const TABLE_ACTION_BUTTON_BASE = - "!h-8 !min-h-8 !w-8 !rounded-md !px-0 !py-0 !shadow-none"; -const TABLE_ACTION_BUTTON_SECONDARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-200 !bg-white !text-zinc-500 hover:!border-zinc-300 hover:!bg-zinc-50 hover:!text-zinc-950 focus:!border-zinc-400 disabled:!border-zinc-100 disabled:!bg-zinc-50 disabled:!text-zinc-300`; -const TABLE_ACTION_BUTTON_PRIMARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-950 !bg-zinc-950 !text-white hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400`; -const INTAKE_PRIMARY_BUTTON_CLASS = - "!border-zinc-950 !bg-zinc-950 !bg-none !text-white !shadow-none hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400"; -type NonIntegratedQuickScope = "all" | "created" | "pending"; -type NonIntegratedProductKindFilter = "all" | "new" | "stock"; -type NonIntegratedListQuery = { - productKind: NonIntegratedProductKindFilter; - seriesName: string; - spu: string; - appearanceDesigner: string; - structureDesigner: string; - spuOwner: string; - planningOwner: string; - spaceDesigner: string; - purchaseOwner: string; - stockSku: string; - stockCategory: string; - stockItemType: string; - stockSalesStatus: string; - stockEnabledStatus: string; - stockDataScope: string; -}; - -type PersistedNonIntegratedListState = { - queryDraft?: Partial; - appliedQuery?: Partial; - currentPage?: number; - activeQuickScope?: NonIntegratedQuickScope; -}; - -const NON_INTEGRATED_LIST_STATE_STORAGE_PREFIX = - "non-integrated-list-state-v2"; - -function createDefaultNonIntegratedListQuery( - productKind: NonIntegratedProductKindFilter, -): NonIntegratedListQuery { - return { - productKind, - seriesName: "", - spu: "", - appearanceDesigner: "", - structureDesigner: "", - spuOwner: "", - planningOwner: "", - spaceDesigner: "", - purchaseOwner: "", - stockSku: "", - stockCategory: "", - stockItemType: "all", - stockSalesStatus: "all", - stockEnabledStatus: "all", - stockDataScope: "all", - }; -} - -function buildNonIntegratedListStateStorageKey( - productDevelopmentKind: ProductDevelopmentKind, - fixedProductKind?: NonIntegratedProductKindFilter, -) { - return [ - NON_INTEGRATED_LIST_STATE_STORAGE_PREFIX, - productDevelopmentKind, - fixedProductKind ?? "all", - ].join(":"); -} - -function normalizeNonIntegratedListQuery( - value: Partial | undefined, - fallbackProductKind: NonIntegratedProductKindFilter, -) { - const defaultQuery = createDefaultNonIntegratedListQuery(fallbackProductKind); - if (!value || typeof value !== "object") return defaultQuery; - - return Object.fromEntries( - Object.entries(defaultQuery).map(([key, fallbackValue]) => { - const nextValue = value[key as keyof NonIntegratedListQuery]; - - return [ - key, - typeof nextValue === "string" ? nextValue : fallbackValue, - ]; - }), - ) as NonIntegratedListQuery; -} - -function normalizePersistedQuickScope(value: unknown): NonIntegratedQuickScope { - return value === "created" || value === "pending" ? value : "all"; -} - -function normalizePersistedPage(value: unknown) { - const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed <= 0) return 1; - - return Math.floor(parsed); -} - -function readPersistedNonIntegratedListState( - key: string, -): PersistedNonIntegratedListState | null { - if (typeof window === "undefined") return null; - - try { - const rawValue = window.localStorage.getItem(key); - if (!rawValue) return null; - const parsed = JSON.parse(rawValue); - - return parsed && typeof parsed === "object" - ? (parsed as PersistedNonIntegratedListState) - : null; - } catch { - return null; - } -} - -function writePersistedNonIntegratedListState( - key: string, - state: PersistedNonIntegratedListState, -) { - if (typeof window === "undefined") return; - - try { - window.localStorage.setItem(key, JSON.stringify(state)); - } catch { - // Ignore storage failures; the filters still work for the current session. - } -} - -function getUniqueSortedStrings(values: Array) { - return Array.from( - new Set(values.map((value) => value?.trim() ?? "").filter(Boolean)), - ).sort((left, right) => left.localeCompare(right, "zh-Hans-CN")); -} - -const BOM_LIBRARY_OPTIONS: { - key: BomLibraryKey; - label: string; - description: string; - helper: string; -}[] = [ - { - key: "materialLibrary", - label: "板材资料库", - description: - "只展示板材基材,方便在产品资料 BOM 下维护板材本体资料。", - helper: - "数据仍复用资产中心基础资料库,系列和饰面花色入口在这里不显示。", - }, - { - key: "finishLibrary", - label: "花色资料库", - description: - "只展示饰面花色,方便在产品资料 BOM 下维护花色、贴图和参数。", - helper: - "数据仍复用资产中心基础资料库,板材基材入口在这里不显示。", - }, - { - key: "edgeBandingLibrary", - label: "封边资料库", - description: - "按板材厚度维护封边条,细节资料里的封边自动项会按对应厚度筛选并带出单价。", - helper: - "维护规格名称、规格、单位、单价和适配板材厚度;封边行仍可在细节资料里手动改单价。", - }, - { - key: "steelFrameLibrary", - label: "钢架资料库", - description: - "不再预置系统钢架规格,只保留每吨钢价与人工新建规格,系统会自动重算重量、支数和每米单价。", - helper: - "当前按名称、长、宽、壁厚录入,保存后的规格会直接参与钢架 BOM 匹配,并随每吨钢价联动刷新。", - }, - { - key: "packagingLibrary", - label: "配件资料库", - description: - "产品资料下独立维护的配件资料区,按新的业务口径重新配置,不复用原包材资料逻辑。", - helper: "适合维护配件名称、配件编码、分类规则和后续要补充的独立资料。", - }, - { - key: "packagingMaterialLibrary", - label: "包材资料库", - description: - "产品资料下独立维护的包材资料区,沿用和配件资料库相同的字母分组逻辑,但单独保存数据。", - helper: - "适合维护纸箱、泡沫、护角等包材名称、编码、图片和价格,不会和配件资料串在一起。", - }, -]; - -const RAW_MATERIAL_LIBRARY_OPTIONS: { - key: RawMaterialLibraryKey; - label: string; - description: string; -}[] = [ - { - key: "processConfig", - label: "材质工艺配置", - description: "表格类信息和工艺规则占位", - }, - { - key: "detailArchive", - label: "材质详情档案", - description: "材质百科式详情页占位", - }, -]; const DEFAULT_WORKSPACE_TAB_ID = "pending-entry:nonIntegrated:default"; const PRODUCT_PATENT_LEDGER_LABEL = "产品专利明细"; @@ -1446,279 +934,6 @@ const PRODUCT_PATENT_DASHBOARD_LABEL = "产品专利看板"; const REFERENCE_ESTIMATOR_GROUP_LABEL = "预估成本测算"; const REFERENCE_ESTIMATOR_WORKSPACE_LABEL = "参考图预估成本"; -function getPendingEntryTabLabel(tab: IntakeEntryTab) { - if (tab === "integrated") return "模块化产品明细"; - if (tab === "stock") return "存量商品明细"; - return "产品开发明细"; -} - -function getPendingEntryTabFromProductDevelopmentKind( - kind: ProductDevelopmentKind, -): IntakeEntryTab { - return kind === "series" ? "integrated" : "nonIntegrated"; -} - -function getProductDevelopmentKind( - product: Pick, -): ProductDevelopmentKind { - return product.productDevelopmentKind === "series" ? "series" : "product"; -} - -function readOptionalRecordString(value: unknown) { - return typeof value === "string" ? value.trim() : ""; -} - -function readOptionalNestedString( - value: unknown, - key: string, -) { - if (!value || typeof value !== "object" || Array.isArray(value)) return ""; - return readOptionalRecordString((value as Record)[key]); -} - -function readStockSourceString( - product: Record, - key: string, -) { - return readOptionalNestedString(product.metadata, key); -} - -function getSeriesProductSpuDisplay(product: NonIntegratedProductRecord) { - const componentDetailType = readOptionalNestedString( - product.metadata, - "componentDetailType", - ); - const componentSpu = readOptionalNestedString( - product.metadata, - "componentSpu", - ).toUpperCase(); - const finishedProductSpu = readOptionalNestedString( - product.metadata, - "finishedProductSpu", - ).toUpperCase(); - - if (componentDetailType === "asset-series-components" && componentSpu) { - return componentSpu; - } - if (finishedProductSpu) return finishedProductSpu; - if (componentSpu) return componentSpu; - - return resolveNonIntegratedPersistedSpu(product); -} - -function getNonIntegratedListSpuDisplay( - product: NonIntegratedProductRecord, - productDevelopmentKind: ProductDevelopmentKind, -) { - return productDevelopmentKind === "series" - ? getSeriesProductSpuDisplay(product) - : getNonIntegratedProductDisplayName(product); -} - -function readFirstSkuString( - product: Pick, - key: keyof NonIntegratedProductRecord["skuItems"][number], -) { - const firstSku = product.skuItems[0]; - return firstSku ? readOptionalRecordString(firstSku[key]) : ""; -} - -function matchesOptionalText(value: string, keyword: string) { - const normalizedKeyword = keyword.trim().toUpperCase(); - if (!normalizedKeyword) return true; - - return value.trim().toUpperCase().includes(normalizedKeyword); -} - -function matchesExactFilter(value: string, filterValue: string) { - const normalizedFilter = filterValue.trim(); - if (!normalizedFilter || normalizedFilter === "all") return true; - if (normalizedFilter === "__empty") return value.trim().length === 0; - - return value.trim() === normalizedFilter; -} - -function isNormalJstSkuSpu(product: NonIntegratedProductRecord) { - const sourceItemType = readStockSourceString(product, "sourceItemType"); - const sourceCategory = readStockSourceString(product, "sourceCategory"); - - return ( - sourceItemType === "成品" && - sourceCategory.length > 0 && - !sourceCategory.startsWith("其他 -") - ); -} - -function isNormalOnSaleJstSku(product: NonIntegratedProductRecord) { - return ( - isNormalJstSkuSpu(product) && - readStockSourceString(product, "sourceSalesStatus") === "销售" && - readStockSourceString(product, "sourceEnabled") === "1" - ); -} - -function stockSourceMatchesQuery( - product: NonIntegratedProductRecord, - query: NonIntegratedListQuery, -) { - const sourceSku = readStockSourceString(product, "sourceSkuId"); - const sourceSpu = readStockSourceString(product, "sourceSpu"); - const sourceCategory = readStockSourceString(product, "sourceCategory"); - const sourceItemType = readStockSourceString(product, "sourceItemType"); - const sourceSalesStatus = readStockSourceString(product, "sourceSalesStatus"); - const sourceEnabled = readStockSourceString(product, "sourceEnabled"); - const firstSkuBarcode = readFirstSkuString(product, "barcode"); - const stockSkuMatched = - matchesOptionalText(sourceSku, query.stockSku) || - matchesOptionalText(firstSkuBarcode, query.stockSku); - const stockCategoryMatched = matchesOptionalText( - sourceCategory, - query.stockCategory, - ); - const stockItemTypeMatched = matchesExactFilter( - sourceItemType, - query.stockItemType, - ); - const stockSalesStatusMatched = matchesExactFilter( - sourceSalesStatus, - query.stockSalesStatus, - ); - const stockEnabledMatched = matchesExactFilter( - sourceEnabled, - query.stockEnabledStatus, - ); - const stockDataScopeMatched = - query.stockDataScope === "normalSpu" - ? isNormalJstSkuSpu(product) - : query.stockDataScope === "normalOnSale" - ? isNormalOnSaleJstSku(product) - : true; - - return ( - (matchesOptionalText(sourceSpu, query.spu) || - matchesOptionalText(product.spu, query.spu)) && - stockSkuMatched && - stockCategoryMatched && - stockItemTypeMatched && - stockSalesStatusMatched && - stockEnabledMatched && - stockDataScopeMatched - ); -} - -function isJstSkuSourceMarker(value: string) { - const normalizedValue = value.trim().toLowerCase().replace(/-/g, "_"); - return normalizedValue === "jst_sku" || normalizedValue === "jstsku"; -} - -function isJstSkuImportedStockProduct( - product: Pick< - NonIntegratedProductRecord, - | "id" - | "formalSpuAssigned" - | "openingReference" - | "productDevelopmentKind" - | "spu" - > & - Record, -) { - if (getProductDevelopmentKind(product) !== "product") return false; - if (product.id.trim().startsWith("jst-stock-")) return true; - - const metadata = product.metadata; - const sourceCandidates = [ - readOptionalRecordString(product.source), - readOptionalRecordString(product.sourceTable), - readOptionalRecordString(product.importSource), - readOptionalRecordString(product.externalSource), - readOptionalNestedString(metadata, "source"), - readOptionalNestedString(metadata, "sourceTable"), - readOptionalNestedString(metadata, "importSource"), - readOptionalNestedString(metadata, "externalSource"), - ]; - - return sourceCandidates.some(isJstSkuSourceMarker); -} - -function getPendingEntryTabFromProduct(product: NonIntegratedProductRecord) { - if (getProductDevelopmentKind(product) === "series") return "integrated"; - return isJstSkuImportedStockProduct(product) ? "stock" : "nonIntegrated"; -} - -function getPendingEntryTabFromNewProductTab(tab: { - productDevelopmentKind: ProductDevelopmentKind; -}) { - if (tab.productDevelopmentKind === "series") return "integrated"; - return "nonIntegrated"; -} - -function readSeriesComponentDetailMetadata(product: Record) { - const metadata = product.metadata; - if (!metadata || typeof metadata !== "object") return null; - - const record = metadata as Record; - return record.componentDetailType === "asset-series-components" - ? record - : null; -} - -function isSeriesComponentDetail(product: Record) { - return Boolean(readSeriesComponentDetailMetadata(product)); -} - -function readSeriesComponentDetailSourceSeriesName( - product: NonIntegratedProductRecord, -) { - const metadata = readSeriesComponentDetailMetadata( - product as unknown as Record, - ); - const sourceSeriesName = - typeof metadata?.sourceSeriesName === "string" - ? metadata.sourceSeriesName.trim() - : ""; - - return sourceSeriesName || product.openingReference.productName.trim(); -} - -function formatSeriesComponentSeriesNameDisplay(seriesName: string) { - const normalizedName = seriesName - .trim() - .replace(/\s*[--]\s*部件开发明细$/u, "") - .replace(/\s*部件开发明细$/u, "") - .replace(/\s*[--]\s*部件$/u, "") - .replace(/\s+部件$/u, "") - .trim(); - - return normalizedName ? `${normalizedName}-部件` : ""; -} - -function getSeriesProductSeriesNameDisplay(product: NonIntegratedProductRecord) { - if (isSeriesComponentDetail(product as unknown as Record)) { - return formatSeriesComponentSeriesNameDisplay( - readSeriesComponentDetailSourceSeriesName(product), - ); - } - - return ( - product.openingReference?.productName?.trim() || - readOptionalNestedString(product.metadata, "sourceSeriesName") - ); -} - -function getProductDevelopmentDetailLabel( - product: NonIntegratedProductRecord, -) { - const suffix = - getProductDevelopmentKind(product) === "series" - ? isSeriesComponentDetail(product as unknown as Record) - ? "部件开发明细" - : "模块化产品明细" - : isJstSkuImportedStockProduct(product) - ? "存量商品明细" - : "产品开发明细"; - - return `${getNonIntegratedProductDisplayName(product)}${suffix}`; -} function readDraftGroupSeriesName(draftGroup: { assetSeriesName?: string | null; @@ -2339,20 +1554,6 @@ function resolveWorkspaceTabsForOpen( }; } -function getBomLibraryLabel(library: BomLibraryKey) { - return ( - BOM_LIBRARY_OPTIONS.find((option) => option.key === library)?.label ?? - "BOM资料库" - ); -} - -function getRawMaterialLibraryLabel(library: RawMaterialLibraryKey) { - return ( - RAW_MATERIAL_LIBRARY_OPTIONS.find((option) => option.key === library) - ?.label ?? "原材料资料库" - ); -} - function IntakeWorkspaceTabBar({ tabs, activeTabId, @@ -2763,4171 +1964,6 @@ function toggleSection( return current === section ? null : section; } -function DetailPanel({ - title, - description, - children, -}: { - title: string; - description: string; - children?: React.ReactNode; -}) { - return ( -
-
-

- {title} -

-

- {description} -

-
-
{children}
-
- ); -} - -function ImagePreviewDialog({ - src, - alt, - onClose, -}: { - src: string; - alt: string; - onClose: () => void; -}) { - return ( -
-
event.stopPropagation()} - > -
-

- {alt} -

- -
-
- {/* eslint-disable-next-line @next/next/no-img-element */} - {alt} -
-
-
- ); -} - -function hasReviewIssueContentForList( - issue: NonIntegratedProductRecord["reviewSheet"]["issues"][number], -) { - return [ - issue.issueImage, - issue.issueImageName, - issue.issueDescription, - issue.fixSuggestion, - issue.referenceImage, - issue.referenceImageName, - ].some((value) => value.trim().length > 0); -} - -function hasCustomReviewMethodForList(reviewMethod: string) { - const normalizedReviewMethod = reviewMethod.trim(); - const defaultReviewMethods = new Set([ - "", - "现场", - "现场审样", - "鐜板満", - "鐜板満瀹℃牱", - ]); - - return !defaultReviewMethods.has(normalizedReviewMethod); -} - -function hasReviewSheetContentForList( - sheet: NonIntegratedProductRecord["reviewSheet"], -) { - return [ - sheet.version.trim() !== "v0.0.1" ? sheet.version : "", - sheet.factory, - sheet.reviewDate, - hasCustomReviewMethodForList(sheet.reviewMethod) ? sheet.reviewMethod : "", - ...sheet.issues.flatMap((issue) => - hasReviewIssueContentForList(issue) - ? [ - issue.issueImage, - issue.issueImageName, - issue.issueDescription, - issue.fixSuggestion, - issue.referenceImage, - issue.referenceImageName, - ] - : [], - ), - ].some((value) => value.trim().length > 0); -} - -function hasOpeningReferenceContentForList( - reference?: NonIntegratedProductRecord["openingReference"] | null, -) { - if (!reference) return false; - - return [ - reference.productImage, - reference.productImageName, - reference.productName, - reference.temporaryCode, - reference.brand, - ...reference.adaptableBrands, - reference.productCategory, - reference.salesCategory, - reference.designer, - reference.planningOwner, - reference.currentStage, - reference.year, - reference.competitorLink, - reference.targetPriceBand, - reference.size, - reference.material, - reference.urgent, - reference.reason, - ].some((value) => value.trim().length > 0); -} - -function hasProjectReviewContentForList( - review?: NonIntegratedProductRecord["projectReview"] | null, -) { - if (!review) return false; - - return [ - review.reviewDate, - review.reviewers, - review.conclusion, - review.revisionNotes, - review.decidedAt, - ].some((value) => value.trim().length > 0); -} - -function hasQualityInspectionSheetContentForList( - sheet: NonIntegratedProductRecord["qualityInspectionSheet"], -) { - return sheet.rows.some((row) => - QUALITY_INSPECTION_EDITABLE_FIELD_KEYS.some( - (key) => String(row[key] ?? "").trim().length > 0, - ), - ); -} - -function hasPackagingInnerItemContentForList( - item: NonNullable< - NonIntegratedProductRecord["styleDetails"][number]["innerPackagingItems"] - >[number], -) { - return [ - item.materialName, - item.specification, - item.quantity, - item.unit, - item.remark, - ].some((value) => value.trim().length > 0); -} - -function hasPackagingContentForList(product: NonIntegratedProductRecord) { - return product.styleDetails.some((detail) => { - const hasInnerItems = (detail.innerPackagingItems ?? []).some((item) => - hasPackagingInnerItemContentForList(item), - ); - - return ( - [ - detail.packageDetail ?? "", - detail.craftDetail ?? "", - detail.outerPackagingType ?? "", - detail.packagingModel3dDataUrl ?? "", - ].some((value) => value.trim().length > 0) || hasInnerItems - ); - }); -} - -function hasInstallationContentForList(product: NonIntegratedProductRecord) { - return ( - [product.installMethod, product.installSteps, product.model3dDataUrl ?? ""].some( - (value) => value.trim().length > 0, - ) || - (product.styleDetails ?? []).some((detail) => - [ - detail.installMethod, - detail.installSteps, - detail.model3dDataUrl, - ].some((value) => (value ?? "").trim().length > 0), - ) - ); -} - -function hasBasicInfoContentForList(product: NonIntegratedProductRecord) { - return [ - product.spu, - product.year, - product.materialType, - product.brand, - ...product.adaptableBrands, - product.productCategory, - product.salesCategory, - product.spuOwner, - product.designer, - product.productPlanning, - product.purchaseOwner, - product.operator, - product.demandFlowNo, - product.processTemplate, - product.demandDepartment, - product.visualDirector, - product.patentOwner, - product.salesChannel, - product.salesPlatform, - product.productionType, - product.shippingMode, - product.manualProducer, - product.isSelfDevelopedPatentProduct, - product.needsClearanceHandling, - ].some((value) => value.trim().length > 0); -} - -function getConfirmedProductFactoryRowCountForList( - product: NonIntegratedProductRecord, -) { - return (product.factoryRows ?? []).filter((row) => row.confirmed).length; -} - -function hasProductFactoryRowsConfirmedForList( - product: NonIntegratedProductRecord, -) { - return ( - (product.factoryRows ?? []).length > 0 && - (product.factoryRows ?? []).every((row) => row.confirmed) - ); -} - -function getPopulatedSampleMeasurementRowCountForList( - product: NonIntegratedProductRecord, -) { - return (product.sampleMeasurementRows ?? []).filter((row) => - [ - row.lengthCm, - row.widthCm, - row.heightCm, - row.weightKg, - ].some((value) => value.trim().length > 0), - ).length; -} - -function getPopulatedFinishedSampleShipmentRowCountForList( - product: NonIntegratedProductRecord, -) { - return (product.finishedSampleShipmentRows ?? []).filter((row) => - [row.sampleQuantity, row.shipped, row.trackingNumber].some( - (value) => value.trim().length > 0, - ), - ).length; -} - -function getPopulatedProductPricingRowCountForList( - product: NonIntegratedProductRecord, -) { - return (product.pricingRows ?? []).filter((row) => - [row.costPrice, row.productTag, row.systemEnteredAt ?? ""].some( - (value) => value.trim().length > 0, - ), - ).length; -} - -function getProductWorkspaceRenderKey(product: NonIntegratedProductRecord) { - return [ - product.updatedAt ?? product.createdAt ?? "", - product.skuItems?.length ?? 0, - ].join(":"); -} - -function getPopulatedPlanningLayoutRowCountForList( - product: NonIntegratedProductRecord, -) { - return (product.planningLayoutRows ?? []).filter((row) => - [ - row.style, - row.styleTone, - row.audience, - row.priceBand, - row.brand ?? "", - row.shop, - ...(row.styles ?? []), - ...(row.styleTones ?? []), - ...(row.audiences ?? []), - ...row.brands, - ...row.spaces, - ...row.categories, - ].some((value) => value.trim().length > 0), - ).length; -} - -function getPopulatedProductLimitRowCountForList( - product: NonIntegratedProductRecord, -) { - return (product.productLimitRows ?? []).filter((row) => - [ - row.expectedDailyLimitPrice, - row.expectedLimitGrossMarginRate, - row.nonSpuOwnerLimitPrice, - ].some((value) => value.trim().length > 0), - ).length; -} - -function getPopulatedFirstQualityRowCountForList( - product: NonIntegratedProductRecord, -) { - return (product.firstQualityRows ?? []).filter((row) => { - const conclusion = row.conclusion.trim(); - return Boolean( - row.qualityBatch.trim() || - row.detail.trim() || - (conclusion && conclusion !== "待检"), - ); - }).length; -} - -function hasPatentApplicationContentForList(product: NonIntegratedProductRecord) { - const application = normalizeNonIntegratedPatentApplication( - product.patentApplication, - ); - - return [ - application.shouldApply === "待判断" ? "" : application.shouldApply, - application.patentName ?? "", - application.patentStatus === "待确认" ? "" : application.patentStatus ?? "", - application.saRequired === "否" ? "" : application.saRequired ?? "", - application.salesStatus === "在售" ? "" : application.salesStatus ?? "", - application.designer ?? "", - application.applicationNo, - application.applicationDate, - application.applicationResult, - application.resultDate, - application.patentNo, - application.firstFeeDate, - ].some((value) => value.trim().length > 0); -} - -function getDocumentWorkspaceCountByCategory( - product: NonIntegratedProductRecord, -): Record { - const uploadFileCountByCategory = Object.fromEntries( - NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES.map((category) => [category, 0]), - ) as Record; - - product.uploadFiles.forEach((file) => { - const category = file.category as NonIntegratedDocumentWorkspaceCategory; - if (NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES.includes(category)) { - uploadFileCountByCategory[category] += 1; - } - }); - - const reviewRounds = - product.reviewSheets?.length && product.reviewSheets.length > 0 - ? product.reviewSheets - : [ - { - reviewSheet: product.reviewSheet, - }, - ]; - - const populatedReviewRoundCount = reviewRounds.filter((round) => - hasReviewSheetContentForList(round.reviewSheet), - ).length; - - const qualityInspectionHasContent = hasQualityInspectionSheetContentForList( - product.qualityInspectionSheet, - ); - - return { - ...uploadFileCountByCategory, - 开品参考: - uploadFileCountByCategory["开品参考"] + - (hasOpeningReferenceContentForList(product.openingReference) ? 1 : 0), - 立项评审: - uploadFileCountByCategory["立项评审"] + - (hasProjectReviewContentForList(product.projectReview) ? 1 : 0), - 评审资料: uploadFileCountByCategory["评审资料"] + populatedReviewRoundCount, - 专利申请: - uploadFileCountByCategory["专利申请"] + - (hasPatentApplicationContentForList(product) ? 1 : 0), - 检测报告: - uploadFileCountByCategory["检测报告"] + - (qualityInspectionHasContent ? 1 : 0), - 企划布局: - uploadFileCountByCategory["企划布局"] + - getPopulatedPlanningLayoutRowCountForList(product), - 安装方式: - uploadFileCountByCategory["安装方式"] + - (hasInstallationContentForList(product) ? 1 : 0), - 包装细节: - uploadFileCountByCategory["包装细节"] + - (hasPackagingContentForList(product) ? 1 : 0), - 产品定厂: - uploadFileCountByCategory["产品定厂"] + - getConfirmedProductFactoryRowCountForList(product), - 样包测量: - uploadFileCountByCategory["样包测量"] + - getPopulatedSampleMeasurementRowCountForList(product), - 成品寄样: - uploadFileCountByCategory["成品寄样"] + - getPopulatedFinishedSampleShipmentRowCountForList(product), - 产品核价: - uploadFileCountByCategory["产品核价"] + - getPopulatedProductPricingRowCountForList(product), - 产品限价: - uploadFileCountByCategory["产品限价"] + - getPopulatedProductLimitRowCountForList(product), - 工厂质检: - uploadFileCountByCategory["工厂质检"] + - getPopulatedFirstQualityRowCountForList(product), - [NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY]: hasBasicInfoContentForList( - product, - ) - ? 1 - : 0, - }; -} - -const LIST_DATE_TIME_ZONE = "Asia/Shanghai"; - -function formatDateTimeForList(date: Date) { - const parts = new Intl.DateTimeFormat("zh-CN", { - timeZone: LIST_DATE_TIME_ZONE, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hourCycle: "h23", - }).formatToParts(date); - const partMap = Object.fromEntries( - parts.map((part) => [part.type, part.value]), - ); - - return `${partMap.year}-${partMap.month}-${partMap.day} ${partMap.hour}:${partMap.minute}:${partMap.second}`; -} - -function getFormattedListDateTime(value: string) { - const trimmedValue = value.trim(); - if (!trimmedValue) return "-"; - - const hasTimeZone = /(?:z|[+-]\d{2}:?\d{2})$/i.test(trimmedValue); - if (hasTimeZone) { - const parsedDate = new Date(trimmedValue); - if (!Number.isNaN(parsedDate.getTime())) { - return formatDateTimeForList(parsedDate); - } - } - - const isoMatch = trimmedValue.match( - /^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2}:\d{2})/, - ); - if (isoMatch) { - return `${isoMatch[1]} ${isoMatch[2]}`; - } - - const parsedDate = new Date(trimmedValue); - if (Number.isNaN(parsedDate.getTime())) return trimmedValue; - - const pad = (value: number) => String(value).padStart(2, "0"); - return `${parsedDate.getFullYear()}-${pad(parsedDate.getMonth() + 1)}-${pad( - parsedDate.getDate(), - )} ${pad(parsedDate.getHours())}:${pad(parsedDate.getMinutes())}:${pad( - parsedDate.getSeconds(), - )}`; -} - -function getUniqueNonEmptyValues(values: Array) { - return Array.from( - new Set( - values - .map((value) => value?.trim() ?? "") - .filter((value) => value.length > 0), - ), - ); -} - -function getCurrentNodeOwnerCandidates( - product: NonIntegratedProductRecord, - category: NonIntegratedDocumentStatusCategory, -) { - if (category === "立项评审") { - return getUniqueNonEmptyValues([ - product.projectReview?.designerReviewer, - product.projectReview?.purchaserReviewer, - product.projectReview?.planningReviewer, - product.projectReview?.operatorReviewer, - product.designer, - product.purchaser, - product.operator, - ]); - } - - if (category === "开品参考") { - return getUniqueNonEmptyValues([ - product.openingReference?.designer, - product.openingReference?.planningOwner, - product.designer, - product.operator, - product.purchaser, - ]); - } - - if (category === "设计卖点") { - return getUniqueNonEmptyValues([ - product.operator, - product.openingReference?.planningOwner, - product.designer, - product.purchaser, - ]); - } - - if (category === NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY) { - return getUniqueNonEmptyValues([ - product.spuOwner, - product.designer, - product.productPlanning, - product.purchaseOwner, - product.operator, - product.purchaser, - ]); - } - - if ( - [ - "产品定厂", - "样包测量", - "成品寄样", - "产品核价", - "工厂质检", - "检测报告", - ].includes(category) - ) { - return getUniqueNonEmptyValues([ - product.purchaser, - product.operator, - product.designer, - ]); - } - - return getUniqueNonEmptyValues([ - product.designer, - product.purchaser, - product.operator, - ]); -} - -function getCurrentNodeSummary( - product: NonIntegratedProductRecord, - documentWorkspaceCountByCategory: Record< - NonIntegratedDocumentStatusCategory, - number - >, -) { - const currentCategory = getCurrentNodeCategoryForList( - product, - documentWorkspaceCountByCategory, - ); - - if (!currentCategory) { - return { - nodeLabel: "全部完成", - ownerText: "-", - }; - } - - const ownerCandidates = getCurrentNodeOwnerCandidates( - product, - currentCategory, - ); - - return { - nodeLabel: getNonIntegratedListNodeLabel(currentCategory), - ownerText: ownerCandidates.length > 0 ? ownerCandidates.join("、") : "-", - }; -} - -function getCurrentNodeCategoryForList( - product: NonIntegratedProductRecord, - documentWorkspaceCountByCategory: Record< - NonIntegratedDocumentStatusCategory, - number - >, -) { - return ( - NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS.find((category) => { - return !isDocumentWorkspaceReadyForList( - product, - category, - documentWorkspaceCountByCategory, - ); - }) ?? null - ); -} - -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, - documentWorkspaceCountByCategory: Record< - NonIntegratedDocumentStatusCategory, - number - >, -) { - const currentCategory = getCurrentNodeCategoryForList( - product, - documentWorkspaceCountByCategory, - ); - if (!currentCategory) return false; - - return isCurrentUserResponsibleForCurrentNode(product, user, currentCategory); -} - -function isDocumentWorkspaceApprovedForList( - product: NonIntegratedProductRecord, - category: NonIntegratedDocumentStatusCategory, -) { - if (category === "立项评审" && product.projectStatus === "approved") { - return true; - } - - if (category === "产品定厂") { - return hasProductFactoryRowsConfirmedForList(product); - } - - if ( - category === "产品核价" && - hasProductPricingRowsSystemEnteredForList(product) - ) { - return true; - } - - return Boolean( - product.documentWorkspaceConfirmations?.[category] && - product.documentWorkspaceApprovedAt?.[category], - ); -} - -function isDocumentWorkspaceReadyForList( - product: NonIntegratedProductRecord, - category: NonIntegratedDocumentStatusCategory, - documentWorkspaceCountByCategory: Record< - NonIntegratedDocumentStatusCategory, - number - >, -) { - return ( - (documentWorkspaceCountByCategory[category] ?? 0) > 0 && - isDocumentWorkspaceApprovedForList(product, category) - ); -} - -function isNonIntegratedFlowCompleted( - product: NonIntegratedProductRecord, - documentWorkspaceCountByCategory: Record< - NonIntegratedDocumentStatusCategory, - number - >, -) { - return isDocumentWorkspaceReadyForList( - product, - NON_INTEGRATED_FLOW_COMPLETE_CATEGORY, - documentWorkspaceCountByCategory, - ); -} - -function getFlowStatusDisplay(statusKey: ProductProgressStatus): { - label: string; - badgeType: "success" | "info" | "warning" | "error"; -} { - if (statusKey === "done") { - return { label: "已完成", badgeType: "success" }; - } - - if (statusKey === "scrapped") { - return { label: "已废弃", badgeType: "error" }; - } - - return { label: "流程中", badgeType: "info" }; -} - -function CopyProductDialog({ - product, - targetBrand, - brandOptions, - errorMessage, - isSubmitting, - onChangeBrand, - onConfirm, - onClose, -}: { - product: NonIntegratedProductRecord; - targetBrand: string; - brandOptions: string[]; - errorMessage: string; - isSubmitting: boolean; - onChangeBrand: (value: string) => void; - onConfirm: () => void; - onClose: () => void; -}) { - return ( -
-
event.stopPropagation()} - > -
-
-

- 复制产品 -

-

- 当前 SPU:{product.spu} - 。选择目标品牌后,会先带出整套资料,再进入待确认流程。 -

-
- -
- - - - {errorMessage ? ( -

{errorMessage}

- ) : null} - -
- - -
-
-
- ); -} - -const _IntegratedPendingDetail = function IntegratedPendingDetail({ - rows, -}: { - rows: IntegratedPendingSeriesRow[]; -}) { - const [queryDraft, setQueryDraft] = useState({ - spu: "", - series: "", - designer: "", - operator: "", - purchaser: "", - }); - const [appliedQuery, setAppliedQuery] = useState({ - spu: "", - series: "", - designer: "", - operator: "", - purchaser: "", - }); - const [currentPage, setCurrentPage] = useState(1); - - const filteredList = useMemo(() => { - return rows.filter((row) => { - const spuMatched = - appliedQuery.spu.trim().length === 0 || - row.baseSpu - .toLowerCase() - .includes(appliedQuery.spu.trim().toLowerCase()); - const seriesMatched = - appliedQuery.series.trim().length === 0 || - row.seriesName - .toLowerCase() - .includes(appliedQuery.series.trim().toLowerCase()); - const designerMatched = - appliedQuery.designer.trim().length === 0 || - row.designer - .toLowerCase() - .includes(appliedQuery.designer.trim().toLowerCase()); - const operatorMatched = - appliedQuery.operator.trim().length === 0 || - row.operator - .toLowerCase() - .includes(appliedQuery.operator.trim().toLowerCase()); - const purchaserMatched = - appliedQuery.purchaser.trim().length === 0 || - row.purchaser - .toLowerCase() - .includes(appliedQuery.purchaser.trim().toLowerCase()); - - return ( - spuMatched && - seriesMatched && - designerMatched && - operatorMatched && - purchaserMatched - ); - }); - }, [appliedQuery, rows]); - - const totalPages = Math.max( - 1, - Math.ceil(filteredList.length / PENDING_PAGE_SIZE), - ); - const safeCurrentPage = Math.min(currentPage, totalPages); - const pagedList = filteredList.slice( - (safeCurrentPage - 1) * PENDING_PAGE_SIZE, - safeCurrentPage * PENDING_PAGE_SIZE, - ); - - return ( -
-
-
- - - - - -
- -
-
- - - -
-
-
- -
-
-
- - - - - - - - - - - - - - - - {filteredList.length === 0 ? ( - - - - ) : ( - pagedList.map((row, index) => ( - - - - - - - - - - - - )) - )} - -
序号成品预览图 - - 系列设计师运营采购状态操作
- {rows.length === 0 - ? "当前还没有可入库的集成系列,先去辅助装配完成手动拼装,或回到草稿组继续补齐资料。" - : "当前查询条件下没有找到资料。"} -
- {(safeCurrentPage - 1) * PENDING_PAGE_SIZE + index + 1} - -
- 暂无图片 -
-
- {row.baseSpu} - -

- {row.seriesName} -

-

- {row.draftGroupName} -

-
- {row.designer || "-"} - - {row.operator || "-"} - - {row.purchaser || "-"} - - - {row.status} - - -
- - -
-
-
-
-
- {filteredList.length > 0 ? ( -
- - 第 {safeCurrentPage} / {totalPages} 页,每页 {PENDING_PAGE_SIZE} 条 - -
- - -
-
- ) : null} -
- ); -}; - -function NonIntegratedPendingDetail({ - initialProducts, - canDeleteProducts, - currentUser, - productDevelopmentKind = "product", - fixedProductKind, - createButtonLabel = "新增产品", - secondaryCreateButtonLabel, - emptyMessage = "当前还没有非集成产品,点击右上角“新增产品”开始维护资料。", - onProductsChange, - onOpenProductWorkspace, - onOpenNewNonIntegratedProductWorkspace, - onOpenSecondaryNewNonIntegratedProductWorkspace, -}: { - initialProducts: NonIntegratedProductRecord[]; - canDeleteProducts: boolean; - currentUser: IntakeProductPermissionUser; - productDevelopmentKind?: ProductDevelopmentKind; - fixedProductKind?: NonIntegratedProductKindFilter; - createButtonLabel?: string; - secondaryCreateButtonLabel?: string; - emptyMessage?: string; - onProductsChange?: (products: NonIntegratedProductRecord[]) => void; - onOpenProductWorkspace?: ( - product: NonIntegratedProductRecord, - mode: WorkspaceProductMode, - ) => void; - onOpenNewNonIntegratedProductWorkspace?: () => void; - onOpenSecondaryNewNonIntegratedProductWorkspace?: () => void; -}) { - const router = useRouter(); - const initialProductKind = fixedProductKind ?? "all"; - const listStateStorageKey = buildNonIntegratedListStateStorageKey( - productDevelopmentKind, - fixedProductKind, - ); - const initialPersistedListState = useMemo( - () => readPersistedNonIntegratedListState(listStateStorageKey), - [listStateStorageKey], - ); - const [products, setProducts] = - useState(initialProducts); - const [currentPage, setCurrentPage] = useState(() => - normalizePersistedPage(initialPersistedListState?.currentPage), - ); - const [pageJumpInput, setPageJumpInput] = useState(() => - String(normalizePersistedPage(initialPersistedListState?.currentPage)), - ); - const [pageInfo, setPageInfo] = - useState(null); - const [isListLoading, setIsListLoading] = useState(false); - const [listErrorMessage, setListErrorMessage] = useState(""); - const [previewProduct, setPreviewProduct] = useState<{ - src: string; - alt: string; - } | null>(null); - const [copyDialogState, setCopyDialogState] = useState<{ - product: NonIntegratedProductRecord; - targetBrand: string; - } | null>(null); - const [copyErrorMessage, setCopyErrorMessage] = useState(""); - const [isCopySubmitting, setIsCopySubmitting] = useState(false); - const [activeQuickScope, setActiveQuickScope] = - useState(() => - normalizePersistedQuickScope(initialPersistedListState?.activeQuickScope), - ); - const [queryDraft, setQueryDraft] = useState(() => - normalizeNonIntegratedListQuery( - initialPersistedListState?.queryDraft, - initialProductKind, - ), - ); - const [appliedQuery, setAppliedQuery] = useState(() => - normalizeNonIntegratedListQuery( - initialPersistedListState?.appliedQuery, - initialProductKind, - ), - ); - const [sharedStockCategoryOptions, setSharedStockCategoryOptions] = useState< - string[] - >([]); - const [stockCategoryOptionsReady, setStockCategoryOptionsReady] = - useState(false); - const listStateStorageKeyRef = useRef(listStateStorageKey); - const skipNextListStatePersistRef = useRef(false); - const shouldUseSharedNonIntegratedApi = process.env.NODE_ENV !== "test"; - const effectiveProductKind = fixedProductKind ?? appliedQuery.productKind; - const hasCreateAction = Boolean( - onOpenNewNonIntegratedProductWorkspace || - onOpenSecondaryNewNonIntegratedProductWorkspace, - ); - - function scopeProductsByDevelopmentKind( - nextProducts: NonIntegratedProductRecord[], - ) { - return nextProducts.filter( - (product) => getProductDevelopmentKind(product) === productDevelopmentKind, - ); - } - - useEffect(() => { - if (shouldUseSharedNonIntegratedApi) { - return; - } - - function syncProducts() { - const nextProducts = listNonIntegratedProducts().filter( - (product) => getProductDevelopmentKind(product) === productDevelopmentKind, - ); - setProducts(nextProducts); - } - - syncProducts(); - window.addEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, syncProducts); - return () => - window.removeEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, syncProducts); - }, [initialProducts, productDevelopmentKind, shouldUseSharedNonIntegratedApi]); - - useEffect(() => { - if (listStateStorageKeyRef.current === listStateStorageKey) return; - - listStateStorageKeyRef.current = listStateStorageKey; - skipNextListStatePersistRef.current = true; - - const persistedState = - readPersistedNonIntegratedListState(listStateStorageKey); - const nextPage = normalizePersistedPage(persistedState?.currentPage); - setCurrentPage(nextPage); - setPageJumpInput(String(nextPage)); - setActiveQuickScope( - normalizePersistedQuickScope(persistedState?.activeQuickScope), - ); - setQueryDraft( - normalizeNonIntegratedListQuery( - persistedState?.queryDraft, - initialProductKind, - ), - ); - setAppliedQuery( - normalizeNonIntegratedListQuery( - persistedState?.appliedQuery, - initialProductKind, - ), - ); - }, [initialProductKind, listStateStorageKey]); - - useEffect(() => { - if (skipNextListStatePersistRef.current) { - skipNextListStatePersistRef.current = false; - return; - } - - writePersistedNonIntegratedListState(listStateStorageKey, { - queryDraft, - appliedQuery, - currentPage, - activeQuickScope, - }); - }, [ - activeQuickScope, - appliedQuery, - currentPage, - listStateStorageKey, - queryDraft, - ]); - - useEffect(() => { - if (!fixedProductKind) return; - - setQueryDraft((current) => ({ ...current, productKind: fixedProductKind })); - setAppliedQuery((current) => ({ - ...current, - productKind: fixedProductKind, - })); - }, [fixedProductKind]); - - useEffect(() => { - if (!shouldUseSharedNonIntegratedApi || effectiveProductKind !== "stock") { - setStockCategoryOptionsReady(!shouldUseSharedNonIntegratedApi); - return; - } - - let cancelled = false; - setStockCategoryOptionsReady(false); - - listSharedNonIntegratedStockCategoryOptions() - .then((data) => { - if (cancelled) return; - setSharedStockCategoryOptions(data.categories); - }) - .catch(() => { - if (cancelled) return; - setSharedStockCategoryOptions([]); - }) - .finally(() => { - if (!cancelled) { - setStockCategoryOptionsReady(true); - } - }); - - return () => { - cancelled = true; - }; - }, [effectiveProductKind, shouldUseSharedNonIntegratedApi]); - - useEffect(() => { - if (!shouldUseSharedNonIntegratedApi) return; - - let cancelled = false; - setIsListLoading(true); - setListErrorMessage(""); - - listSharedNonIntegratedProductPage({ - page: currentPage, - pageSize: PENDING_PAGE_SIZE, - productDevelopmentKind, - quickScope: activeQuickScope, - ...appliedQuery, - productKind: effectiveProductKind, - }) - .then((data) => { - if (cancelled) return; - - setProducts(data.products); - setPageInfo(data.pageInfo); - }) - .catch((error) => { - if (cancelled) return; - - setListErrorMessage( - error instanceof Error ? error.message : "列表加载失败,请稍后再试", - ); - setProducts([]); - setPageInfo(null); - }) - .finally(() => { - if (!cancelled) { - setIsListLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [ - activeQuickScope, - appliedQuery, - currentPage, - effectiveProductKind, - productDevelopmentKind, - shouldUseSharedNonIntegratedApi, - ]); - - async function handleDelete(product: NonIntegratedProductRecord) { - const productLabel = product.spu?.trim() || "当前产品"; - - if (!window.confirm(`确认删除“${productLabel}”吗?\n删除后不可恢复。`)) { - return; - } - - try { - if (shouldUseSharedNonIntegratedApi) { - await deleteSharedNonIntegratedProduct(product.id); - const nextProducts = products.filter( - (currentProduct) => currentProduct.id !== product.id, - ); - setProducts(nextProducts); - onProductsChange?.(nextProducts); - return; - } - - deleteNonIntegratedProduct(product.id); - const nextProducts = scopeProductsByDevelopmentKind( - listNonIntegratedProducts(), - ); - setProducts(nextProducts); - onProductsChange?.(nextProducts); - } catch (error) { - window.alert( - error instanceof Error ? error.message : "删除失败,请稍后再试", - ); - } - } - - async function handleRestore(product: NonIntegratedProductRecord) { - const productLabel = product.spu?.trim() || "当前产品"; - - if ( - !window.confirm( - `确认恢复“${productLabel}”吗?\n恢复后原资料会保留,但所有流程节点和完成时间都需要重新审批。`, - ) - ) { - return; - } - - try { - const restoredProduct = createRestoredNonIntegratedProduct(product); - - if (shouldUseSharedNonIntegratedApi) { - const savedProduct = - await saveSharedNonIntegratedProduct(restoredProduct); - const nextProducts = products.map((currentProduct) => - currentProduct.id === savedProduct.id ? savedProduct : currentProduct, - ); - setProducts(nextProducts); - onProductsChange?.(nextProducts); - return; - } - - saveNonIntegratedProduct(restoredProduct); - const nextProducts = scopeProductsByDevelopmentKind( - listNonIntegratedProducts(), - ); - setProducts(nextProducts); - onProductsChange?.(nextProducts); - } catch (error) { - window.alert( - error instanceof Error ? error.message : "恢复失败,请稍后再试", - ); - } - } - - function getAvailableCopyBrands(product: NonIntegratedProductRecord) { - const configuredBrands = Array.from( - new Set([ - product.brand, - ...(product.adaptableBrands ?? []), - ...(product.openingReference?.adaptableBrands ?? []), - ]), - ).filter((brand) => brand.trim().length > 0); - const sourceBaseSpu = stripNonIntegratedBrandPrefix( - product.copiedFromSpu || product.spu, - ); - const existingBrandsForSpu = new Set( - products - .filter( - (currentProduct) => - stripNonIntegratedBrandPrefix( - currentProduct.copiedFromSpu || currentProduct.spu, - ) === sourceBaseSpu, - ) - .map((currentProduct) => currentProduct.brand) - .filter((brand) => brand.trim().length > 0), - ); - - return configuredBrands.filter( - (brand) => brand !== product.brand && !existingBrandsForSpu.has(brand), - ); - } - - function openCopyDialog(product: NonIntegratedProductRecord) { - const availableBrands = getAvailableCopyBrands(product); - if (availableBrands.length === 0) return; - - setCopyErrorMessage(""); - setCopyDialogState({ - product, - targetBrand: availableBrands[0], - }); - } - - async function handleConfirmCopy() { - if (!copyDialogState) return; - - setCopyErrorMessage(""); - setIsCopySubmitting(true); - - try { - const copiedProduct = createCopiedNonIntegratedProduct( - copyDialogState.product, - copyDialogState.targetBrand, - ); - const openSavedProduct = (savedProduct: NonIntegratedProductRecord) => { - setCopyDialogState(null); - if (onOpenProductWorkspace) { - onOpenProductWorkspace(savedProduct, "edit"); - return; - } - - router.push( - `/intake/non-integrated/${encodeURIComponent(savedProduct.id)}`, - ); - }; - - if (shouldUseSharedNonIntegratedApi) { - const savedProduct = - await saveSharedNonIntegratedProduct(copiedProduct); - const nextProducts = [ - savedProduct, - ...products.filter((product) => product.id !== savedProduct.id), - ]; - setProducts(nextProducts); - onProductsChange?.(nextProducts); - openSavedProduct(savedProduct); - } else { - const savedProduct = saveNonIntegratedProduct(copiedProduct); - const nextProducts = scopeProductsByDevelopmentKind( - listNonIntegratedProducts(), - ); - setProducts(nextProducts); - onProductsChange?.(nextProducts); - openSavedProduct(savedProduct); - } - } catch (error) { - setCopyErrorMessage( - error instanceof Error ? error.message : "复制失败,请稍后再试", - ); - } finally { - setIsCopySubmitting(false); - } - } - - const filteredProducts = useMemo(() => { - return products.filter((product) => { - const normalizedSpuKeyword = appliedQuery.spu.trim().toUpperCase(); - const productDisplayName = - getNonIntegratedProductDisplayName(product).toUpperCase(); - const listSpuDisplay = getNonIntegratedListSpuDisplay( - product, - productDevelopmentKind, - ).toUpperCase(); - const documentWorkspaceCountByCategory = - getDocumentWorkspaceCountByCategory(product); - const people = getNonIntegratedListPeople(product); - const stockProduct = isJstSkuImportedStockProduct(product); - const sourceSpu = readStockSourceString(product, "sourceSpu"); - const sourceSku = readStockSourceString(product, "sourceSkuId"); - const productKindMatched = - effectiveProductKind === "all" || - (effectiveProductKind === "stock" - ? stockProduct - : !stockProduct); - const spuMatched = - normalizedSpuKeyword.length === 0 || - listSpuDisplay.includes(normalizedSpuKeyword) || - productDisplayName.includes(normalizedSpuKeyword) || - sourceSpu.toUpperCase().includes(normalizedSpuKeyword) || - sourceSku.toUpperCase().includes(normalizedSpuKeyword) || - getSearchableNonIntegratedSpuKeywords(product.spu).some((keyword) => - keyword.includes(normalizedSpuKeyword), - ); - const stockSourceMatched = - effectiveProductKind !== "stock" || - stockSourceMatchesQuery(product, appliedQuery); - const seriesNameMatched = - productDevelopmentKind !== "series" || - productMatchesText( - [ - readSeriesComponentDetailSourceSeriesName(product), - getSeriesProductSeriesNameDisplay(product), - readOptionalNestedString(product.metadata, "sourceSeriesName"), - readOptionalNestedString(product.metadata, "sourceSeriesCode"), - product.openingReference.productName, - getNonIntegratedProductDisplayName(product), - getSeriesProductSpuDisplay(product), - product.spu, - readOptionalRecordString( - (product as unknown as Record).spuBase, - ), - ].join(" "), - appliedQuery.seriesName, - ); - const peopleMatched = - productMatchesText( - people.appearanceDesigner, - appliedQuery.appearanceDesigner, - ) && - productMatchesText( - people.structureDesigner, - appliedQuery.structureDesigner, - ) && - productMatchesText(people.spuOwner, appliedQuery.spuOwner) && - productMatchesText(people.planningOwner, appliedQuery.planningOwner) && - productMatchesText(people.spaceDesigner, appliedQuery.spaceDesigner) && - productMatchesText(people.purchaseOwner, appliedQuery.purchaseOwner); - const quickScopeMatched = - activeQuickScope === "all" || - (activeQuickScope === "created" - ? isCurrentUserResponsibleForNonIntegratedProduct( - product, - currentUser, - ) - : isCurrentUserPendingNonIntegratedProduct( - product, - currentUser, - documentWorkspaceCountByCategory, - )); - - return ( - productKindMatched && - spuMatched && - stockSourceMatched && - seriesNameMatched && - peopleMatched && - quickScopeMatched - ); - }); - }, [ - activeQuickScope, - appliedQuery, - currentUser, - effectiveProductKind, - productDevelopmentKind, - products, - ]); - const totalRows = shouldUseSharedNonIntegratedApi - ? (pageInfo?.total ?? filteredProducts.length) - : filteredProducts.length; - const totalPages = shouldUseSharedNonIntegratedApi - ? (pageInfo?.totalPages ?? 1) - : Math.max(1, Math.ceil(filteredProducts.length / PENDING_PAGE_SIZE)); - const safeCurrentPage = Math.min(currentPage, totalPages); - const visibleProducts = shouldUseSharedNonIntegratedApi - ? filteredProducts - : filteredProducts.slice( - (safeCurrentPage - 1) * PENDING_PAGE_SIZE, - safeCurrentPage * PENDING_PAGE_SIZE, - ); - - useEffect(() => { - setPageJumpInput(String(safeCurrentPage)); - }, [safeCurrentPage]); - - function commitPageJump(value = pageJumpInput) { - const parsedPage = Number.parseInt(value.trim(), 10); - const nextPage = Number.isFinite(parsedPage) - ? Math.min(totalPages, Math.max(1, parsedPage)) - : safeCurrentPage; - - setCurrentPage(nextPage); - setPageJumpInput(String(nextPage)); - } - - const isSeriesDevelopmentList = productDevelopmentKind === "series"; - const isStockDevelopmentList = effectiveProductKind === "stock"; - const localStockCategoryOptions = useMemo( - () => - getUniqueSortedStrings( - products - .filter(isJstSkuImportedStockProduct) - .map((product) => readStockSourceString(product, "sourceCategory")), - ), - [products], - ); - const stockCategoryOptions = shouldUseSharedNonIntegratedApi - ? sharedStockCategoryOptions - : localStockCategoryOptions; - const stockCategorySelectOptions = useMemo( - () => { - const categories = stockCategoryOptionsReady - ? stockCategoryOptions - : getUniqueSortedStrings([ - ...stockCategoryOptions, - queryDraft.stockCategory, - appliedQuery.stockCategory, - ]); - - return [ - { value: "", label: "全部" }, - ...categories.map((category) => ({ - value: category, - label: category, - })), - ]; - }, - [ - appliedQuery.stockCategory, - queryDraft.stockCategory, - stockCategoryOptions, - stockCategoryOptionsReady, - ], - ); - - useEffect(() => { - if ( - !isStockDevelopmentList || - !stockCategoryOptionsReady || - stockCategoryOptions.length === 0 - ) { - return; - } - - const existingCategories = new Set(stockCategoryOptions); - if ( - queryDraft.stockCategory && - !existingCategories.has(queryDraft.stockCategory) - ) { - setQueryDraft((current) => ({ ...current, stockCategory: "" })); - } - if ( - appliedQuery.stockCategory && - !existingCategories.has(appliedQuery.stockCategory) - ) { - setAppliedQuery((current) => ({ ...current, stockCategory: "" })); - } - }, [ - appliedQuery.stockCategory, - isStockDevelopmentList, - queryDraft.stockCategory, - stockCategoryOptions, - stockCategoryOptionsReady, - ]); - const productFilterFields: Array<{ - key: keyof NonIntegratedListQuery; - label: ReactNode; - ariaLabel: string; - placeholder: string; - options?: Array<{ value: string; label: string }>; - }> = [ - { key: "spu", label: , ariaLabel: "SPU", placeholder: "搜索 SPU" }, - { - key: "appearanceDesigner", - label: "外观设计师", - ariaLabel: "外观设计师", - placeholder: "搜索外观设计师", - }, - { - key: "structureDesigner", - label: "结构设计师", - ariaLabel: "结构设计师", - placeholder: "搜索结构设计师", - }, - { - key: "spuOwner", - label: "SPU负责人", - ariaLabel: "SPU负责人", - placeholder: "搜索SPU负责人", - }, - { - key: "planningOwner", - label: "商品企划", - ariaLabel: "商品企划", - placeholder: "搜索商品企划", - }, - { - key: "spaceDesigner", - label: "空间设计师", - ariaLabel: "空间设计师", - placeholder: "搜索空间设计师", - }, - { - key: "purchaseOwner", - label: "采购负责人", - ariaLabel: "采购负责人", - placeholder: "搜索采购负责人", - }, - ]; - const stockFilterFields: typeof productFilterFields = [ - { key: "spu", label: , ariaLabel: "SPU", placeholder: "搜索SPU/i_id" }, - { - key: "stockSku", - label: "SKU条码", - ariaLabel: "SKU条码", - placeholder: "搜索sku_id", - }, - { - key: "stockCategory", - label: "类目", - ariaLabel: "类目", - placeholder: "", - options: stockCategorySelectOptions, - }, - { - key: "stockItemType", - label: "商品类型", - ariaLabel: "商品类型", - placeholder: "", - options: [ - { value: "all", label: "全部" }, - { value: "成品", label: "成品" }, - { value: "半成品", label: "半成品/配件" }, - ], - }, - { - key: "stockSalesStatus", - label: "销售状态", - ariaLabel: "销售状态", - placeholder: "", - options: [ - { value: "all", label: "全部" }, - { value: "销售", label: "销售" }, - { value: "停产", label: "停产" }, - { value: "退市", label: "退市" }, - { value: "__empty", label: "空" }, - ], - }, - { - key: "stockEnabledStatus", - label: "启用状态", - ariaLabel: "启用状态", - placeholder: "", - options: [ - { value: "all", label: "全部" }, - { value: "1", label: "启用" }, - { value: "0", label: "禁用" }, - { value: "-1", label: "停用" }, - { value: "__empty", label: "空" }, - ], - }, - { - key: "stockDataScope", - label: "数据范围", - ariaLabel: "数据范围", - placeholder: "", - options: [ - { value: "all", label: "全部数据" }, - { value: "normalSpu", label: "正常SPU" }, - { value: "normalOnSale", label: "正常SPU+在售SKU" }, - ], - }, - ]; - const seriesProductFilterFields: typeof productFilterFields = - isSeriesDevelopmentList - ? [ - { - key: "seriesName", - label: "系列名称", - ariaLabel: "系列名称", - placeholder: "搜索系列名称", - }, - ...productFilterFields, - ] - : productFilterFields; - const filterFields = isStockDevelopmentList - ? stockFilterFields - : seriesProductFilterFields; - const filterGridColumnClass = - filterFields.length === 8 - ? hasCreateAction - ? "md:grid-cols-[repeat(8,minmax(0,1fr))_72px_216px]" - : "md:grid-cols-[repeat(8,minmax(0,1fr))_72px]" - : hasCreateAction - ? "md:grid-cols-[repeat(7,minmax(0,1fr))_72px_216px]" - : "md:grid-cols-[repeat(7,minmax(0,1fr))_72px]"; - const quickScopes: Array<{ key: NonIntegratedQuickScope; label: string }> = [ - { key: "all", label: "全部" }, - { key: "created", label: "我负责的" }, - { key: "pending", label: "待处理" }, - ]; - const listColumnCount = - 11 + - NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS.length + - (isSeriesDevelopmentList ? 1 : 0); - - return ( -
-
-
-
- {quickScopes.map((scope) => ( - - ))} -
-
- {filterFields.map((filter) => ( - - ))} -
- -
- {hasCreateAction ? ( -
- {onOpenNewNonIntegratedProductWorkspace ? ( - - ) : null} - {onOpenSecondaryNewNonIntegratedProductWorkspace && - secondaryCreateButtonLabel ? ( - - ) : null} -
- ) : null} -
-
-
- -
-
-
- - - - - - {isSeriesDevelopmentList ? ( - - ) : null} - - - - - - - - - {NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS.map((category) => ( - - ))} - - - - - {visibleProducts.length === 0 ? ( - - - - ) : ( - visibleProducts.map((product, index) => { - const productDisplayName = - getNonIntegratedProductDisplayName(product); - const documentWorkspaceCountByCategory = - getDocumentWorkspaceCountByCategory(product); - const isFlowAbandoned = - product.projectStatus === "rejected"; - const people = getNonIntegratedListPeople(product); - const canEditProduct = canEditNonIntegratedProduct( - product, - currentUser, - ); - const canOpenProductForEdit = - canOpenNonIntegratedProductForEdit(product, currentUser); - const productOpenMode: WorkspaceProductMode = - !isFlowAbandoned && canOpenProductForEdit ? "edit" : "view"; - const currentNodeSummary = getCurrentNodeSummary( - product, - documentWorkspaceCountByCategory, - ); - const productPreviewImage = - product.openingReference?.productImage?.trim() || - product.previewImage; - const seriesDisplayName = - getSeriesProductSeriesNameDisplay(product) || "-"; - const listSpuDisplay = getNonIntegratedListSpuDisplay( - product, - productDevelopmentKind, - ); - - return ( - - - - {isSeriesDevelopmentList ? ( - - ) : null} - - - - - - - - - {NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS.map( - (category) => { - const isReady = isDocumentWorkspaceReadyForList( - product, - category, - documentWorkspaceCountByCategory, - ); - - return ( - - ); - }, - )} - - - ); - }) - )} - -
- 序号 - - 成品预览图 - - 系列名称 - - - - 外观设计师 - - 结构设计师 - - SPU负责人 - - 商品企划 - - 空间设计师 - - 采购负责人 - - 当前节点 - -
- {getNonIntegratedListNodeLabel(category)} - {getNonIntegratedListNodeOwnerLabel(category) ? ( - - {getNonIntegratedListNodeOwnerLabel(category)} - - ) : null} -
-
- 操作 -
- {isListLoading - ? "正在加载资料..." - : listErrorMessage || - (products.length === 0 - ? emptyMessage - : "当前查询条件下没有找到资料。")} -
- {(safeCurrentPage - 1) * PENDING_PAGE_SIZE + index + 1} - - - - {seriesDisplayName} - - - - {displayPerson(people.appearanceDesigner)} - - {displayPerson(people.structureDesigner)} - - {displayPerson(people.spuOwner)} - - {displayPerson(people.planningOwner)} - - {displayPerson(people.spaceDesigner)} - - {displayPerson(people.purchaseOwner)} - - - {currentNodeSummary.nodeLabel} - - - - -
- - {canEditProduct && !isFlowAbandoned ? ( - - ) : null} - {canDeleteProducts && canEditProduct ? ( - - ) : null} - {isFlowAbandoned && canEditProduct ? ( - - ) : null} -
-
-
- {totalRows > 0 ? ( -
- -
- 共 {totalRows} 条 - 第 - - setPageJumpInput(event.target.value.replace(/\D/g, "")) - } - onBlur={() => commitPageJump()} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - commitPageJump(); - } - }} - disabled={isListLoading} - className="h-8 w-12 rounded-apple-md border border-apple-separator bg-apple-bg-primary px-2 text-center text-xs text-apple-label-primary outline-none focus:border-zinc-400 disabled:bg-apple-bg-secondary disabled:text-apple-label-secondary" - /> - / {totalPages} 页 - 每页 {PENDING_PAGE_SIZE} 条 - {isListLoading ? 加载中... : null} -
- -
- ) : null} -
-
- {previewProduct ? ( - setPreviewProduct(null)} - /> - ) : null} - {copyDialogState ? ( - - setCopyDialogState((current) => - current ? { ...current, targetBrand: value } : current, - ) - } - onConfirm={() => void handleConfirmCopy()} - onClose={() => { - setCopyDialogState(null); - setCopyErrorMessage(""); - }} - /> - ) : null} -
- ); -} - -type ProductProgressStatus = - | "done" - | "running" - | "pending" - | "returned" - | "scrapped"; -type ProductProgressStatusFilter = - | "all" - | "done" - | "running" - | "risk" - | "flowRisk" - | "dataRisk"; -type ProductProgressPanel = "total" | "nodes" | "risk"; -type ProductProgressDimension = "brand" | "category" | "designer" | "type"; -type ProductProgressDimensionFilter = { - dimension: ProductProgressDimension; - value: string; - values?: string[]; -} | null; - -type ProductProgressDimensionChartItem = { - label: string; - values: string[]; - count: number; - percent: number; - start: number; - end: number; - color: string; -}; - -type ProductProgressDimensionAnnotation = ProductProgressDimensionChartItem & { - anchorX: number; - anchorY: number; - elbowX: number; - elbowY: number; - lineEndX: number; - labelX: number; - labelY: number; - textAnchor: "start" | "end"; -}; - -type ProductProgressRow = { - id: string; - source: "integrated" | "nonIntegrated"; - typeKey: "ordinary" | "modular"; - typeLabel: string; - product?: NonIntegratedProductRecord; - previewImage: string; - flowNo: string; - spu: string; - flowTemplate: string; - statusKey: ProductProgressStatus; - statusLabel: string; - approver: string; - productCategory: string; - salesCategory: string; - brand: string; - designer: string; - spuOwner: string; - operator: string; - purchaser: string; - demandDepartment: string; - currentNode: string; - currentOwner: string; - startTime: string; - endTime: string; - totalDays: string; - nodeApprovedAt: Partial>; -}; - -type ProductProgressExportColumn = { - header: string; - value: (row: ProductProgressRow, index: number) => string | number; -}; - -const PRODUCT_PROGRESS_STATUS_META: Record< - ProductProgressStatus, - { label: string; badgeType: "success" | "info" | "warning" | "error" } -> = { - done: { label: "已完成", badgeType: "success" }, - running: { label: "流程中", badgeType: "info" }, - pending: { label: "流程中", badgeType: "info" }, - returned: { label: "退回修改", badgeType: "warning" }, - scrapped: { label: "已废弃", badgeType: "error" }, -}; - -const PRODUCT_PROGRESS_FILTERS: Array<{ - key: ProductProgressStatusFilter; - label: string; - description: string; -}> = [ - { key: "all", label: "全部产品", description: "总盘面,含普通和模块化。" }, - { key: "done", label: "已完成", description: "仓库抽检完成后归入。" }, - { key: "running", label: "流程中", description: "已立项,后续节点推进中。" }, - { key: "risk", label: "风险", description: "流程或资料时间异常。" }, - { key: "flowRisk", label: "流程异常", description: "开始到结束超过 45 天。" }, - { key: "dataRisk", label: "资料异常", description: "产品资料超过 90 天未确认。" }, -]; - -const PRODUCT_PROGRESS_PANELS: Array<{ - key: ProductProgressPanel; - label: string; - description: string; -}> = [ - { - key: "total", - label: "状态总览", - description: "按状态筛选,再看品牌、品类、设计师和类型。", - }, - { - key: "nodes", - label: "节点耗时", - description: "按节点看累计耗时和卡点。", - }, - { - key: "risk", - label: "风险", - description: "流程异常和资料异常集中查看。", - }, -]; - -function getProductProgressStatusLabel(status: ProductProgressStatusFilter) { - if (status === "all") return "全部产品"; - if (status === "risk") return "风险"; - if (status === "flowRisk") return "流程异常"; - if (status === "dataRisk") return "资料异常"; - return PRODUCT_PROGRESS_STATUS_META[status].label; -} - -function formatNodeApprovalTime(value?: string) { - if (!value?.trim()) return "-"; - - const formattedValue = getFormattedListDateTime(value); - const match = formattedValue.match(/^\d{4}-(\d{2}-\d{2}) (\d{2}:\d{2})/); - if (match) return `${match[1]} ${match[2]}`; - - return formattedValue; -} - -function parseProgressDate(value?: string) { - if (!value?.trim()) return null; - - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date; -} - -function getLatestProductPricingSystemEnteredAt( - product: NonIntegratedProductRecord, -) { - const enteredDates = (product.pricingRows ?? []) - .map((row) => parseProgressDate(row.systemEnteredAt)) - .filter((date): date is Date => date !== null) - .sort((left, right) => right.getTime() - left.getTime()); - - return enteredDates[0] ?? null; -} - -function hasProductPricingRowsSystemEnteredForList( - product: NonIntegratedProductRecord, -) { - const pricingRows = (product.pricingRows ?? []).filter((row) => - [row.costPrice, row.productTag, row.systemEnteredAt ?? ""].some( - (value) => value.trim().length > 0, - ), - ); - - return ( - pricingRows.length > 0 && - pricingRows.every((row) => parseProgressDate(row.systemEnteredAt) !== null) - ); -} - -function getLatestApprovedAt( - approvals: NonIntegratedProductRecord["documentWorkspaceApprovedAt"], -) { - const approvedDates = Object.values(approvals ?? {}) - .map((value) => parseProgressDate(value)) - .filter((date): date is Date => date !== null) - .sort((left, right) => right.getTime() - left.getTime()); - - return approvedDates[0] ?? null; -} - -function getNonIntegratedProgressEndedAt( - product: NonIntegratedProductRecord, - statusKey: ProductProgressStatus, -) { - if (statusKey === "scrapped") { - return getLatestApprovedAt(product.documentWorkspaceApprovedAt); - } - - if (statusKey !== "done") return null; - - if (NON_INTEGRATED_FLOW_COMPLETE_CATEGORY === "产品核价") { - return ( - (hasProductPricingRowsSystemEnteredForList(product) - ? getLatestProductPricingSystemEnteredAt(product) - : null) ?? - parseProgressDate( - product.documentWorkspaceApprovedAt?.[ - NON_INTEGRATED_FLOW_COMPLETE_CATEGORY - ], - ) ?? - getLatestApprovedAt(product.documentWorkspaceApprovedAt) - ); - } - - return ( - parseProgressDate( - product.documentWorkspaceApprovedAt?.[ - NON_INTEGRATED_FLOW_COMPLETE_CATEGORY - ], - ) ?? getLatestApprovedAt(product.documentWorkspaceApprovedAt) - ); -} - -function getElapsedDays(startValue: string, endDate: Date | null) { - const diffDays = getProgressElapsedDaysValue(startValue, endDate); - if (diffDays === null) return "-"; - - return `${Math.max(diffDays, 0.01).toFixed(2)} 天`; -} - -function getProgressElapsedDaysValue(startValue: string, endDate: Date | null) { - if (!startValue.trim() || !endDate) return null; - - const startDate = new Date(startValue); - if (Number.isNaN(startDate.getTime())) return null; - - const diffDays = - (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24); - if (!Number.isFinite(diffDays) || diffDays < 0) return null; - - return diffDays; -} - -function formatNodeElapsedDays(startValue: string, completedValue?: string) { - return getElapsedDays(startValue, parseProgressDate(completedValue)); -} - -function hasProductProgressFlowRisk(row: ProductProgressRow) { - const referenceEndDate = parseProgressDate(row.endTime) ?? new Date(); - const elapsedDays = getProgressElapsedDaysValue( - row.startTime, - referenceEndDate, - ); - - return elapsedDays !== null && elapsedDays > 45; -} - -function hasProductProgressDataRisk(row: ProductProgressRow) { - if (row.source !== "nonIntegrated") return false; - if (row.nodeApprovedAt["产品模型"]) return false; - - const elapsedDays = getProgressElapsedDaysValue(row.startTime, new Date()); - return elapsedDays !== null && elapsedDays > 90; -} - -function hasProductProgressRisk(row: ProductProgressRow) { - return hasProductProgressFlowRisk(row) || hasProductProgressDataRisk(row); -} - -function rowMatchesProductProgressStatus( - row: ProductProgressRow, - status: ProductProgressStatusFilter, -) { - if (status === "all") return true; - if (status === "done") return row.statusKey === "done"; - if (status === "running") { - return ( - row.statusKey === "running" || - row.statusKey === "pending" || - row.statusKey === "returned" - ); - } - if (status === "risk") return hasProductProgressRisk(row); - if (status === "flowRisk") return hasProductProgressFlowRisk(row); - if (status === "dataRisk") return hasProductProgressDataRisk(row); - - return false; -} - -function formatProgressExportFileDate(date: Date) { - const pad = (value: number) => String(value).padStart(2, "0"); - return [ - date.getFullYear(), - pad(date.getMonth() + 1), - pad(date.getDate()), - "-", - pad(date.getHours()), - pad(date.getMinutes()), - ].join(""); -} - -function sanitizeExcelSheetName(value: string) { - const sheetName = value.replace(/[\\/?*\[\]:]/g, " ").trim(); - return (sheetName || "产品开发进度").slice(0, 31); -} - -function getProductProgressExportColumns(): ProductProgressExportColumn[] { - return [ - { header: "序号", value: (_row, index) => index + 1 }, - { header: "类型", value: (row) => row.typeLabel }, - { header: "流程单号", value: (row) => row.flowNo || "-" }, - { header: "SPU / 开品名", value: (row) => row.spu || "-" }, - { header: "流程模板", value: (row) => row.flowTemplate }, - { header: "审批状态", value: (row) => row.statusLabel }, - { header: "审批人", value: (row) => row.approver }, - { header: "类目", value: (row) => row.productCategory }, - { header: "销售类目", value: (row) => row.salesCategory }, - { header: "品牌", value: (row) => row.brand }, - { header: "设计师", value: (row) => row.designer }, - { header: "SPU负责人", value: (row) => row.spuOwner }, - { header: "运营负责人", value: (row) => row.operator }, - { header: "采购", value: (row) => row.purchaser }, - { header: "需求部门", value: (row) => row.demandDepartment }, - { header: "当前节点", value: (row) => row.currentNode }, - { header: "当前节点负责人", value: (row) => row.currentOwner }, - { - header: "开始时间", - value: (row) => formatNodeApprovalTime(row.startTime), - }, - { header: "结束时间", value: (row) => formatNodeApprovalTime(row.endTime) }, - { header: "总耗时", value: (row) => row.totalDays }, - ...NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS.map((category) => ({ - header: `${getNonIntegratedDocumentWorkspaceCategoryLabel(category)}耗时`, - value: (row: ProductProgressRow) => - formatNodeElapsedDays(row.startTime, row.nodeApprovedAt[category]), - })), - ]; -} - -async function exportProductProgressRowsToExcel( - rows: ProductProgressRow[], - title: string, -) { - const XLSX = await import("xlsx"); - const columns = getProductProgressExportColumns(); - const sheetRows = [ - columns.map((column) => column.header), - ...rows.map((row, index) => - columns.map((column) => column.value(row, index)), - ), - ]; - const worksheet = XLSX.utils.aoa_to_sheet(sheetRows); - worksheet["!cols"] = columns.map((column) => ({ - wch: Math.max(10, Math.min(column.header.length + 6, 22)), - })); - - const workbook = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet( - workbook, - worksheet, - sanitizeExcelSheetName(title), - ); - - const workbookBuffer = XLSX.write(workbook, { - bookType: "xlsx", - type: "array", - }) as ArrayBuffer; - const blob = new Blob([workbookBuffer], { - type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `产品开发进度-${formatProgressExportFileDate(new Date())}.xlsx`; - document.body.appendChild(link); - link.click(); - link.remove(); - URL.revokeObjectURL(url); -} - -function getNonIntegratedProgressStatus( - product: NonIntegratedProductRecord, - documentWorkspaceCountByCategory: Record< - NonIntegratedDocumentStatusCategory, - number - >, -): ProductProgressStatus { - if (product.projectStatus === "rejected") return "scrapped"; - if (product.projectStatus === "revision") return "returned"; - if (product.projectStatus === "draft") return "pending"; - - return isNonIntegratedFlowCompleted(product, documentWorkspaceCountByCategory) - ? "done" - : "running"; -} - -function getProgressRowStatusDisplay(statusKey: ProductProgressStatus) { - if (statusKey === "pending" || statusKey === "returned") { - return getFlowStatusDisplay("running"); - } - - return getFlowStatusDisplay(statusKey); -} - -function buildNonIntegratedProgressRow( - product: NonIntegratedProductRecord, -): ProductProgressRow { - const productProgressType = - getProductDevelopmentKind(product) === "series" ? "modular" : "ordinary"; - const flowTemplate = - productProgressType === "modular" - ? "模块化流程" - : product.processTemplate || "普通流程"; - const documentWorkspaceCountByCategory = - getDocumentWorkspaceCountByCategory(product); - const statusKey = getNonIntegratedProgressStatus( - product, - documentWorkspaceCountByCategory, - ); - const statusDisplay = getProgressRowStatusDisplay(statusKey); - const currentNodeSummary = getCurrentNodeSummary( - product, - documentWorkspaceCountByCategory, - ); - const startTime = product.createdAt; - const endedAt = getNonIntegratedProgressEndedAt(product, statusKey); - const pricingSystemEnteredAt = - NON_INTEGRATED_FLOW_COMPLETE_CATEGORY === "产品核价" && - hasProductPricingRowsSystemEnteredForList(product) - ? getLatestProductPricingSystemEnteredAt(product) - : null; - const nodeApprovedAt = { - ...(product.documentWorkspaceApprovedAt ?? {}), - ...(pricingSystemEnteredAt - ? { - [NON_INTEGRATED_FLOW_COMPLETE_CATEGORY]: - pricingSystemEnteredAt.toISOString(), - } - : {}), - }; - - return { - id: `non-integrated:${product.id}`, - source: "nonIntegrated", - typeKey: productProgressType, - typeLabel: productProgressType === "modular" ? "模块化" : "普通", - product, - previewImage: - product.openingReference?.productImage || product.previewImage || "", - flowNo: product.demandFlowNo || product.id, - spu: getNonIntegratedProductDisplayName(product), - flowTemplate, - statusKey, - statusLabel: statusDisplay.label, - approver: - product.projectReview?.operatorReviewer || - product.projectReview?.planningReviewer || - product.projectReview?.designerReviewer || - product.projectReview?.reviewers || - "-", - productCategory: - product.productCategory || - product.openingReference?.productCategory || - "-", - salesCategory: - product.salesCategory || product.openingReference?.salesCategory || "-", - brand: product.brand || product.openingReference?.brand || "-", - designer: product.designer || product.openingReference?.designer || "-", - spuOwner: product.spuOwner || "-", - operator: product.operator || product.operationsOwner || "-", - purchaser: product.purchaser || product.purchaseOwner || "-", - demandDepartment: product.demandDepartment || "-", - currentNode: currentNodeSummary.nodeLabel, - currentOwner: currentNodeSummary.ownerText, - startTime, - endTime: endedAt ? endedAt.toISOString() : "", - totalDays: getElapsedDays(startTime, endedAt ?? new Date()), - nodeApprovedAt, - }; -} - -function getIntegratedProgressStatus(row: IntegratedPendingSeriesRow) { - const normalizedStatus = row.status.trim(); - if ( - normalizedStatus.includes("已完成") || - normalizedStatus.includes("FORMALIZED") - ) { - return "done"; - } - - return "running"; -} - -function buildIntegratedProgressRow( - row: IntegratedPendingSeriesRow, -): ProductProgressRow { - const statusKey = getIntegratedProgressStatus(row); - const statusDisplay = getProgressRowStatusDisplay(statusKey); - - return { - id: `integrated:${row.id}`, - source: "integrated", - typeKey: "modular", - typeLabel: "模块化", - previewImage: "", - flowNo: row.draftGroupId, - spu: row.baseSpu, - flowTemplate: "模块化流程", - statusKey, - statusLabel: statusDisplay.label, - approver: "-", - productCategory: row.seriesName || "-", - salesCategory: row.seriesName || "-", - brand: "-", - designer: row.designer || "-", - spuOwner: "-", - operator: row.operator || "-", - purchaser: row.purchaser || "-", - demandDepartment: row.draftGroupName || "-", - currentNode: statusKey === "done" ? "全部完成" : "产品开发明细", - currentOwner: - getUniqueNonEmptyValues([row.designer, row.operator, row.purchaser]).join( - "、", - ) || "-", - startTime: "", - endTime: "", - totalDays: "-", - nodeApprovedAt: {}, - }; -} - -function countRowsByStatus( - rows: ProductProgressRow[], - status: ProductProgressStatusFilter, -) { - return rows.filter((row) => rowMatchesProductProgressStatus(row, status)) - .length; -} - -function getProductProgressDimensionValue( - row: ProductProgressRow, - dimension: ProductProgressDimension, -) { - const valueGetter: Record< - ProductProgressDimension, - (row: ProductProgressRow) => string - > = { - brand: (currentRow) => currentRow.brand, - category: (currentRow) => currentRow.productCategory, - designer: (currentRow) => currentRow.designer, - type: (currentRow) => currentRow.typeLabel, - }; - - return valueGetter[dimension](row).trim() || "未填写"; -} - -const PRODUCT_PROGRESS_DIMENSION_COLORS = [ - "#0b1220", - "#315f7d", - "#5e8fb2", - "#9bb8cd", - "#d69a2d", - "#8b6f47", - "#55706c", - "#c7d3dc", -]; - -function getDimensionCounts( - rows: ProductProgressRow[], - dimension: ProductProgressDimension, -) { - const counts = rows.reduce>((current, row) => { - const label = getProductProgressDimensionValue(row, dimension); - current[label] = (current[label] ?? 0) + 1; - return current; - }, {}); - - return Object.entries(counts).sort( - (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]), - ); -} - -function getDimensionChartItems( - rows: ProductProgressRow[], - dimension: ProductProgressDimension, - limit = 8, -) { - const counts = getDimensionCounts(rows, dimension); - const total = rows.length; - if (total === 0) return []; - - const visibleCount = Math.max(limit - 1, 1); - const entries = - counts.length > limit - ? [ - ...counts.slice(0, visibleCount).map(([label, count]) => ({ - label, - values: [label], - count, - })), - { - label: "其他", - values: counts.slice(visibleCount).map(([label]) => label), - count: counts - .slice(visibleCount) - .reduce((sum, [, count]) => sum + count, 0), - }, - ] - : counts.map(([label, count]) => ({ - label, - values: [label], - count, - })); - - let cursor = 0; - return entries.map((entry, index) => { - const percent = Math.round((entry.count / total) * 100); - const start = cursor; - const end = - index === entries.length - 1 ? 100 : cursor + (entry.count / total) * 100; - cursor = end; - return { - ...entry, - percent, - start, - end, - color: - PRODUCT_PROGRESS_DIMENSION_COLORS[ - index % PRODUCT_PROGRESS_DIMENSION_COLORS.length - ], - }; - }); -} - -function getDimensionPolarPoint( - centerX: number, - centerY: number, - radius: number, - angle: number, -) { - const radians = ((angle - 25) * Math.PI) / 50; - return { - x: centerX + radius * Math.cos(radians), - y: centerY + radius * Math.sin(radians), - }; -} - -function spreadDimensionLabelYs( - annotations: T[], - minY: number, - maxY: number, - gap: number, -) { - const sorted = [...annotations].sort( - (left, right) => left.labelY - right.labelY, - ); - for (let index = 1; index < sorted.length; index += 1) { - sorted[index].labelY = Math.max( - sorted[index].labelY, - sorted[index - 1].labelY + gap, - ); - } - - const overflow = - sorted.length > 0 ? sorted[sorted.length - 1].labelY - maxY : 0; - if (overflow > 0) { - sorted.forEach((item) => { - item.labelY -= overflow; - }); - } - - sorted.forEach((item, index) => { - const lowerBound = index === 0 ? minY : sorted[index - 1].labelY + gap; - item.labelY = Math.min(Math.max(item.labelY, lowerBound), maxY); - }); -} - -function getDimensionChartAnnotations( - items: ProductProgressDimensionChartItem[], -) { - const centerX = 190; - const centerY = 142; - const ringRadius = 78; - const elbowRadius = 106; - const annotations: ProductProgressDimensionAnnotation[] = items.map( - (item) => { - const midAngle = (item.start + item.end) / 2; - const anchor = getDimensionPolarPoint( - centerX, - centerY, - ringRadius, - midAngle, - ); - const elbow = getDimensionPolarPoint( - centerX, - centerY, - elbowRadius, - midAngle, - ); - const isRight = elbow.x >= centerX; - return { - ...item, - anchorX: anchor.x, - anchorY: anchor.y, - elbowX: elbow.x, - elbowY: elbow.y, - lineEndX: isRight ? 298 : 82, - labelX: isRight ? 306 : 74, - labelY: elbow.y, - textAnchor: isRight ? "start" : "end", - }; - }, - ); - - spreadDimensionLabelYs( - annotations.filter((item) => item.textAnchor === "start"), - 28, - 260, - 31, - ); - spreadDimensionLabelYs( - annotations.filter((item) => item.textAnchor === "end"), - 28, - 260, - 31, - ); - - return annotations; -} - -function getShortDimensionLabel(label: string) { - const chars = Array.from(label); - return chars.length > 8 ? `${chars.slice(0, 8).join("")}...` : label; -} - -function rowMatchesDimensionFilter( - row: ProductProgressRow, - dimension: ProductProgressDimension, - filter: ProductProgressDimensionFilter, -) { - if (filter === null || filter.dimension !== dimension) return true; - const value = getProductProgressDimensionValue(row, dimension); - return (filter.values ?? [filter.value]).includes(value); -} - -function getProgressSegmentWidth(count: number, total: number) { - if (total <= 0 || count <= 0) return "0%"; - return `${Math.max((count / total) * 100, 2)}%`; -} - -function ProgressTable({ - rows, - title, - description, - toolbar, - onExport, - currentUser, - onOpenProductWorkspace, -}: { - rows: ProductProgressRow[]; - title: string; - description: string; - toolbar?: ReactNode; - onExport?: () => void; - currentUser: IntakeProductPermissionUser; - onOpenProductWorkspace?: ( - product: NonIntegratedProductRecord, - mode: WorkspaceProductMode, - ) => void; -}) { - return ( -
-
-
-

- {title} -

-

- {description} -

-
-
- {onExport ? ( - - ) : null} - - {rows.length} 条 - -
-
- {toolbar ? ( -
- {toolbar} -
- ) : null} -
- - - - - - - - - - - - - - - - - - - - - - - {NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS.map((category) => ( - - ))} - - - - - {rows.length === 0 ? ( - - - - ) : ( - rows.map((row, index) => ( - - - - - - - - - - - - - - - - - - - - - {NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS.map((category) => ( - - ))} - - - )) - )} - -
序号图片类型 - 流程单号 - - / 开品名 - - 流程模板 - - 审批状态 - 审批人 - 类目 - - 销售类目 - 品牌设计师 - SPU负责人 - - 运营负责人 - 采购 - 需求部门 - - 开始时间 - - 结束时间 - 总耗时 - {getNonIntegratedDocumentWorkspaceCategoryLabel(category)} - - 操作 -
- 当前条件下没有产品。 -
- {index + 1} - -
- {row.previewImage ? ( - // eslint-disable-next-line @next/next/no-img-element - {`${row.spu} - ) : ( - "图" - )} -
-
- {row.typeLabel} - - {row.flowNo || "-"} - - {row.spu || "-"} - - {row.flowTemplate} - - - {row.statusLabel} - - - {row.approver} - - {row.productCategory} - - {row.salesCategory} - - {row.brand} - - {row.designer} - - {row.spuOwner} - - {row.operator} - - {row.purchaser} - - {row.demandDepartment} - - {formatNodeApprovalTime(row.startTime)} - - {formatNodeApprovalTime(row.endTime)} - - {row.totalDays} - - {formatNodeElapsedDays( - row.startTime, - row.nodeApprovedAt[category], - )} - - {row.product ? ( - (() => { - const canOpenProductForEdit = - canOpenNonIntegratedProductForEdit( - row.product!, - currentUser, - ); - const productOpenMode: WorkspaceProductMode = - row.statusKey === "scrapped" || !canOpenProductForEdit - ? "view" - : "edit"; - - return ( - - ); - })() - ) : ( - - - )} -
-
-
- ); -} - -function ProductDevelopmentProgressDashboard({ - integratedRows, - nonIntegratedProducts, - currentUser, - onOpenProductWorkspace, -}: { - integratedRows: IntegratedPendingSeriesRow[]; - nonIntegratedProducts: NonIntegratedProductRecord[]; - currentUser: IntakeProductPermissionUser; - onOpenProductWorkspace?: ( - product: NonIntegratedProductRecord, - mode: WorkspaceProductMode, - ) => void; -}) { - const [activePanel, setActivePanel] = useState("total"); - const [statusFilter, setStatusFilter] = - useState("all"); - const [activeDimension, setActiveDimension] = - useState("brand"); - const [dimensionFilter, setDimensionFilter] = - useState(null); - const [hoveredDimensionLabel, setHoveredDimensionLabel] = useState< - string | null - >(null); - const [queryDraft, setQueryDraft] = useState({ - keyword: "", - type: "all", - designer: "", - operator: "", - purchaser: "", - node: "all", - }); - const [appliedQuery, setAppliedQuery] = useState(queryDraft); - - const allRows = useMemo( - () => [ - ...nonIntegratedProducts.map((product) => - buildNonIntegratedProgressRow(product), - ), - ...integratedRows.map((row) => buildIntegratedProgressRow(row)), - ], - [integratedRows, nonIntegratedProducts], - ); - - const filteredRows = useMemo(() => { - return allRows.filter((row) => { - const keyword = appliedQuery.keyword.trim().toLowerCase(); - const designer = appliedQuery.designer.trim().toLowerCase(); - const operator = appliedQuery.operator.trim().toLowerCase(); - const purchaser = appliedQuery.purchaser.trim().toLowerCase(); - const keywordMatched = - keyword.length === 0 || - [ - row.spu, - row.flowNo, - row.productCategory, - row.salesCategory, - row.brand, - ].some((value) => value.toLowerCase().includes(keyword)); - const typeMatched = - appliedQuery.type === "all" || row.typeKey === appliedQuery.type; - const designerMatched = - designer.length === 0 || row.designer.toLowerCase().includes(designer); - const operatorMatched = - operator.length === 0 || row.operator.toLowerCase().includes(operator); - const purchaserMatched = - purchaser.length === 0 || - row.purchaser.toLowerCase().includes(purchaser); - const nodeMatched = - appliedQuery.node === "all" || row.currentNode === appliedQuery.node; - const statusMatched = - activePanel !== "total" || - rowMatchesProductProgressStatus(row, statusFilter); - const dimensionMatched = - activePanel !== "total" || - rowMatchesDimensionFilter(row, activeDimension, dimensionFilter); - const riskMatched = activePanel !== "risk" || hasProductProgressRisk(row); - - return ( - keywordMatched && - typeMatched && - designerMatched && - operatorMatched && - purchaserMatched && - nodeMatched && - statusMatched && - dimensionMatched && - riskMatched - ); - }); - }, [ - activeDimension, - activePanel, - allRows, - appliedQuery, - dimensionFilter, - statusFilter, - ]); - - const currentNodeOptions = useMemo( - () => - getUniqueNonEmptyValues(allRows.map((row) => row.currentNode)).filter( - (value) => value !== "-", - ), - [allRows], - ); - - const dimensionLabelMap: Record = { - brand: "品牌", - category: "品类", - designer: "设计师", - type: "类型", - }; - - const selectedDimensionLabel = - dimensionFilter?.dimension === activeDimension - ? dimensionFilter.value - : null; - const tableTitle = - activePanel === "total" - ? selectedDimensionLabel - ? `${getProductProgressStatusLabel(statusFilter)}明细:${selectedDimensionLabel}` - : `${getProductProgressStatusLabel(statusFilter)}明细` - : activePanel === "nodes" - ? "节点耗时明细" - : activePanel === "risk" - ? "风险明细" - : `${getProductProgressStatusLabel(statusFilter)}明细`; - - const dimensionOptions: ProductProgressDimension[] = [ - "brand", - "category", - "designer", - "type", - ]; - const dimensionCounts = getDimensionCounts(allRows, activeDimension); - const dimensionChartItems = getDimensionChartItems( - allRows, - activeDimension, - 8, - ); - const dimensionAnnotations = - getDimensionChartAnnotations(dimensionChartItems); - const dimensionBreakdownItems = dimensionCounts.slice(0, 8); - const activeDimensionPreview = - dimensionChartItems.find((item) => item.label === hoveredDimensionLabel) ?? - dimensionChartItems.find((item) => item.label === selectedDimensionLabel) ?? - null; - const selectedDimensionRows = - dimensionFilter?.dimension === activeDimension - ? allRows.filter((row) => - rowMatchesDimensionFilter(row, activeDimension, dimensionFilter), - ) - : allRows; - const selectedDimensionCount = selectedDimensionRows.length; - const selectedDimensionPercent = - allRows.length === 0 - ? 0 - : Math.round((selectedDimensionCount / allRows.length) * 100); - const highestRiskDimension = dimensionBreakdownItems - .map(([label, count]) => { - const rows = allRows.filter( - (row) => - getProductProgressDimensionValue(row, activeDimension) === label, - ); - return { label, count, risk: countRowsByStatus(rows, "risk") }; - }) - .sort((left, right) => right.risk - left.risk)[0]; - - const applyDimensionItem = (item: ProductProgressDimensionChartItem) => { - setDimensionFilter({ - dimension: activeDimension, - value: item.label, - values: item.values, - }); - }; - - const handleDimensionItemKeyDown = ( - event: KeyboardEvent, - item: ProductProgressDimensionChartItem, - ) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - applyDimensionItem(item); - }; - - const statusFilterControls = ( -
- {PRODUCT_PROGRESS_FILTERS.map((item) => { - const isActive = statusFilter === item.key; - return ( - - ); - })} -
- ); - - async function handleExportProgressRows() { - try { - await exportProductProgressRowsToExcel(filteredRows, tableTitle); - } catch (error) { - console.error("Failed to export product progress rows", error); - window.alert("导出失败,请稍后重试。"); - } - } - - return ( -
-
-
-
-
-

- 产品开发进度总览 -

-

- 普通产品和模块化产品合并查看;明细每行是一个产品,节点列显示该节点从开始到完成的耗时。 -

-
-
-
- -
-
- - - - - - -
- -
-
- -
-
- - {activePanel === "total" ? ( - <> -
-
-
-
-
-

- 结构维度 -

-

- 先选维度,点环上标注进入明细。 -

-
- - {dimensionCounts.length} 个 - -
-
-
-
- {dimensionOptions.map((dimension) => { - const isActive = activeDimension === dimension; - return ( - - ); - })} -
-
- - - {dimensionAnnotations.map((item) => { - const isSelected = - selectedDimensionLabel === item.label; - const isHovered = - hoveredDimensionLabel === item.label; - const isEmphasized = isSelected || isHovered; - return ( - applyDimensionItem(item)} - onKeyDown={(event) => - handleDimensionItemKeyDown(event, item) - } - onMouseEnter={() => - setHoveredDimensionLabel(item.label) - } - onMouseLeave={() => - setHoveredDimensionLabel(null) - } - onFocus={() => - setHoveredDimensionLabel(item.label) - } - onBlur={() => setHoveredDimensionLabel(null)} - > - - {item.label} {item.count} / {item.percent}% - - - - - - - {getShortDimensionLabel(item.label)} - - - {item.count} / {item.percent}% - - - - ); - })} - { - setDimensionFilter(null); - setHoveredDimensionLabel(null); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") - return; - event.preventDefault(); - setDimensionFilter(null); - setHoveredDimensionLabel(null); - }} - > - - - {getShortDimensionLabel( - activeDimensionPreview?.label ?? - dimensionLabelMap[activeDimension], - )} - - - {activeDimensionPreview - ? `${activeDimensionPreview.count} / ${activeDimensionPreview.percent}%` - : `${allRows.length} 个产品`} - - - -
-
-
- -
-
-
-

- 结构拆解 -

-

- 每一行看该结构项的完成、推进中和风险分布。 -

-
- - 按{dimensionLabelMap[activeDimension]} - -
-
-
- 结构项 - 状态分布 - 总数 - 风险 -
-
- {dimensionBreakdownItems.map(([label, count]) => { - const rows = allRows.filter( - (row) => - getProductProgressDimensionValue( - row, - activeDimension, - ) === label, - ); - const itemRiskCount = countRowsByStatus(rows, "risk"); - const nonRiskRows = rows.filter( - (row) => !hasProductProgressRisk(row), - ); - const itemDoneCount = countRowsByStatus( - nonRiskRows, - "done", - ); - const itemRunningCount = countRowsByStatus( - nonRiskRows, - "running", - ); - return ( - - ); - })} -
-
-
- -
-
-

- 看板判断 -

-

- 这里放需要负责人快速判断的结论。 -

-
-
-
- - {selectedDimensionLabel ?? "全部结构"} - - - 当前占比 {selectedDimensionCount} /{" "} - {selectedDimensionPercent}%,下方明细已按该项联动。 - -
-
- - 风险集中项 - - - {highestRiskDimension?.risk - ? `${highestRiskDimension.label} 有 ${highestRiskDimension.risk} 个风险,需要优先看流程异常或资料异常。` - : "当前结构项暂无流程异常或资料异常。"} - -
-
- 如果要追进度,先点占比最高的结构项;如果要查问题,优先看风险集中项。 -
-
-
-
- - ) : null} - - {activePanel === "nodes" ? ( -
- 节点看板先沿用同一张产品明细表。后续可以在这里增加“哪个节点卡住最多、哪个节点今天审批了多少”的小图。 -
- ) : null} - - {activePanel === "risk" ? ( -
- 当前只筛出流程异常和资料异常:流程开始到结束超过 45 天,或产品资料超过 90 天还未确认。 -
- ) : null} - -
- void handleExportProgressRows()} - currentUser={currentUser} - onOpenProductWorkspace={onOpenProductWorkspace} - /> -
-
-
- - -
- ); -} function EmptyDetail() { return ( @@ -6937,3137 +1973,6 @@ function EmptyDetail() { ); } -function BomDetail({ - user, - canEditLibraries, - activeBomLibrary, - materialLibrarySubstrates, - materialLibraryFinishes, - materialLibraryCategories, - materialLibraryDictionaries, - onMaterialLibraryFinishesChange, -}: { - user: IntakeClientProps["user"]; - canEditLibraries?: boolean; - activeBomLibrary: BomLibraryKey; - materialLibrarySubstrates: SubstrateDTO[]; - materialLibraryFinishes: MaterialDTO[]; - materialLibraryCategories: MaterialCategoryDTO[]; - materialLibraryDictionaries: SubstrateDictionaryBundleDTO; - onMaterialLibraryFinishesChange?: (finishes: MaterialDTO[]) => void; -}) { - const canEditBomLibraries = - canEditLibraries ?? - (user.role === "SUPER_ADMIN" || - user.role === "ADMIN" || - isIntakeNonIntegratedDesignDepartmentUser(user)); - - if (activeBomLibrary === "materialLibrary" || activeBomLibrary === "finishLibrary") { - const visibleTab = activeBomLibrary === "materialLibrary" ? "substrate" : "finish"; - return ( -
- , - }, - ] - : [ - { - label: "饰面花色", - value: materialLibraryFinishes.length, - icon: , - }, - { - label: "花色分类", - value: materialLibraryCategories.length, - icon: , - }, - ]), - ]} - /> -
- ); - } - - if (activeBomLibrary === "steelFrameLibrary") { - return ( -
-
- -
-
- ); - } - - if (activeBomLibrary === "edgeBandingLibrary") { - return ( -
-
- -
-
- ); - } - - if (activeBomLibrary === "packagingMaterialLibrary") { - return ( -
-
- -
-
- ); - } - - return ( -
-
- -
-
- ); -} - -function RawMaterialLibraryDetail({ - activeRawMaterialLibrary, - selectedMaterialName, -}: { - activeRawMaterialLibrary: RawMaterialLibraryKey; - selectedMaterialName?: string; -}) { - return ( -
-
- -
-
- ); -} - -function ReferenceEstimatorDetail({ - materialLibrarySubstrates, -}: { - materialLibrarySubstrates: SubstrateDTO[]; -}) { - return ( -
-
- -
-
- ); -} - -const PATENT_STATUS_OPTIONS: ProductPatentStatus[] = [ - "待确认", - "已有专利", - "申请中", - "申请失败", - "无专利", - "放弃维护", -]; -const PATENT_TYPE_OPTIONS: ProductPatentType[] = [ - "外观设计", - "实用新型", - "发明", - "其他", -]; -const PATENT_SALES_STATUS_OPTIONS: ProductPatentSalesStatus[] = [ - "在售", - "待上市", - "停售", -]; -const PATENT_CONTINUE_DECISION_OPTIONS: ProductPatentContinueDecision[] = [ - "待判断", - "继续缴费", - "停止维护", -]; -const PATENT_FEE_STATUS_OPTIONS: ProductPatentFeeStatus[] = [ - "", - "已缴", - "未缴", - "不缴", -]; -const PATENT_SA_REQUIRED_OPTIONS: ProductPatentSaRequired[] = ["否", "是"]; -const PATENT_OWNER_MANAGER_ROLES = new Set([ - "SUPER_ADMIN", - "ADMIN", - "SUPERVISOR", - "MANAGER", -]); -type ProductPatentBoardPanel = "status" | "fee"; -type ProductPatentBoardFilter = - | "all" - | "applying" - | "granted" - | "failed" - | "dueSoon" - | "pending" - | "overdue" - | "stopped" - | "noPatent"; -type ProductPatentBoardDimension = - | "designer" - | "status" - | "salesStatus" - | "patentType"; -type ProductPatentBoardDimensionFilter = { - dimension: ProductPatentBoardDimension; - value: string; - values?: string[]; -} | null; - -const PRODUCT_PATENT_BOARD_FILTERS: Array<{ - key: ProductPatentBoardFilter; - label: string; - description: string; - badgeType: "success" | "info" | "warning" | "error" | "default"; -}> = [ - { - key: "all", - label: "全部专利", - description: "当前专利明细总盘面。", - badgeType: "default", - }, - { - key: "applying", - label: "申请中", - description: "还在申请流程中,需要持续跟进。", - badgeType: "info", - }, - { - key: "granted", - label: "申请成功", - description: "对应明细里的已有专利。", - badgeType: "success", - }, - { - key: "failed", - label: "申请失败", - description: "需要复盘或确认是否重提。", - badgeType: "error", - }, - { - key: "dueSoon", - label: "缴费快到期", - description: "下年度年费截止日提前30天预警。", - badgeType: "warning", - }, - { - key: "pending", - label: "待确认", - description: "专利状态还没定,需要先补判断。", - badgeType: "warning", - }, - { - key: "overdue", - label: "已逾期", - description: "下年度年费截止日已过。", - badgeType: "error", - }, - { - key: "stopped", - label: "停止维护", - description: "放弃维护或续缴判断为停止维护。", - badgeType: "error", - }, - { - key: "noPatent", - label: "无专利", - description: "确认无需专利维护的产品。", - badgeType: "default", - }, -]; - -const PRODUCT_PATENT_BOARD_PANELS: Array<{ - key: ProductPatentBoardPanel; - label: string; - description: string; -}> = [ - { - key: "status", - label: "状态总览", - description: "申请中、申请成功、申请失败集中查看。", - }, - { - key: "fee", - label: "年费预警", - description: "提前30天到期和已逾期优先处理。", - }, -]; - -const PRODUCT_PATENT_BOARD_DIMENSION_LABELS: Record< - ProductPatentBoardDimension, - string -> = { - designer: "设计师", - status: "专利状态", - salesStatus: "销售状态", - patentType: "专利类型", -}; - -function parsePatentDate(value: string) { - const trimmedValue = value.trim(); - if (!trimmedValue) return null; - const parsedDate = new Date(`${trimmedValue}T00:00:00`); - return Number.isNaN(parsedDate.getTime()) ? null : parsedDate; -} - -function getDaysUntilPatentDate(value: string) { - const date = parsePatentDate(value); - if (!date) return null; - const today = new Date(); - today.setHours(0, 0, 0, 0); - return Math.ceil((date.getTime() - today.getTime()) / 86_400_000); -} - -function getPatentFeeRisk(record: ProductPatentRecord) { - if (record.patentStatus === "无专利") { - return { - label: "无专利", - tone: "neutral" as const, - sortWeight: 5, - }; - } - if ( - record.patentStatus === "放弃维护" || - record.continueDecision === "停止维护" - ) { - return { - label: "停止维护", - tone: "danger" as const, - sortWeight: 1, - }; - } - if (record.patentStatus === "申请中") { - return { - label: "申请跟进", - tone: "info" as const, - sortWeight: 4, - }; - } - if (record.patentStatus === "申请失败") { - return { - label: "申请失败", - tone: "danger" as const, - sortWeight: 1, - }; - } - if (record.patentStatus !== "已有专利") { - return { - label: "待确认", - tone: "warning" as const, - sortWeight: 2, - }; - } - - const dueDays = getDaysUntilPatentDate(record.nextFeeDueDate); - if (dueDays === null) { - return { - label: "缺截止日", - tone: "warning" as const, - sortWeight: 2, - }; - } - if (dueDays < 0) { - return { - label: "已逾期", - tone: "danger" as const, - sortWeight: 0, - }; - } - if (dueDays <= 90) { - return { - label: `${dueDays}天内到期`, - tone: "warning" as const, - sortWeight: 1, - }; - } - if (record.salesStatus === "停售") { - return { - label: "复核续缴", - tone: "warning" as const, - sortWeight: 2, - }; - } - if (record.continueDecision === "继续缴费") { - return { - label: "继续缴费", - tone: "success" as const, - sortWeight: 6, - }; - } - - return { - label: "正常维护", - tone: "success" as const, - sortWeight: 7, - }; -} - -function getPatentRiskClassName(tone: ReturnType["tone"]) { - if (tone === "danger") return "bg-[#fff1f0] text-[#b42318]"; - if (tone === "warning") return "bg-[#fff7e6] text-[#9a5b00]"; - if (tone === "info") return "bg-[#eef6ff] text-[#175cd3]"; - if (tone === "success") return "bg-[#ecfdf3] text-[#027a48]"; - return "bg-apple-fill-quaternary text-apple-label-secondary"; -} - -function getPatentFeeProgress(record: ProductPatentRecord) { - const paidCount = PRODUCT_PATENT_FEE_YEARS.filter( - (year) => record.annualFees[year] === "已缴", - ).length; - const pendingCount = PRODUCT_PATENT_FEE_YEARS.filter( - (year) => record.annualFees[year] === "未缴", - ).length; - - if (paidCount === 0 && pendingCount === 0) return "未维护"; - return `已缴 ${paidCount} / 待缴 ${pendingCount}`; -} - -function getActivePatentFeeYear( - record: ProductPatentRecord, - preferredYear?: (typeof PRODUCT_PATENT_FEE_YEARS)[number], -) { - if (preferredYear && PRODUCT_PATENT_FEE_YEARS.includes(preferredYear)) { - return preferredYear; - } - - return ( - [...PRODUCT_PATENT_FEE_YEARS] - .reverse() - .find( - (year) => - record.annualFees[year] === "已缴" || Boolean(record.annualFeeDates[year]), - ) ?? PRODUCT_PATENT_FEE_YEARS[0] - ); -} - -function getPatentFeeDateSummary(record: ProductPatentRecord) { - const latestFeeDate = [...PRODUCT_PATENT_FEE_YEARS] - .reverse() - .map((year) => ({ - year, - date: record.annualFeeDates[year]?.trim() ?? "", - })) - .find((item) => item.date); - - return latestFeeDate - ? `${latestFeeDate.year} ${latestFeeDate.date}` - : getPatentFeeProgress(record); -} - -function isPatentAnnualFeeLocked( - record: ProductPatentRecord, - year: (typeof PRODUCT_PATENT_FEE_YEARS)[number], -) { - return record.annualFees[year] === "已缴"; -} - -function isProductPatentStopped(record: ProductPatentRecord) { - return ( - record.patentStatus === "放弃维护" || - record.continueDecision === "停止维护" - ); -} - -function isProductPatentFeeTrackable(record: ProductPatentRecord) { - return record.patentStatus === "已有专利" && !isProductPatentStopped(record); -} - -function isProductPatentFeeDueSoon(record: ProductPatentRecord) { - const dueDays = getDaysUntilPatentDate(record.nextFeeDueDate); - return ( - isProductPatentFeeTrackable(record) && - dueDays !== null && - dueDays >= 0 && - dueDays <= 30 - ); -} - -function isProductPatentFeeOverdue(record: ProductPatentRecord) { - const dueDays = getDaysUntilPatentDate(record.nextFeeDueDate); - return isProductPatentFeeTrackable(record) && dueDays !== null && dueDays < 0; -} - -function getProductPatentBoardFilterLabel(filter: ProductPatentBoardFilter) { - return ( - PRODUCT_PATENT_BOARD_FILTERS.find((item) => item.key === filter)?.label ?? - "全部专利" - ); -} - -function recordMatchesProductPatentBoardFilter( - record: ProductPatentRecord, - filter: ProductPatentBoardFilter, -) { - if (filter === "all") return true; - if (filter === "applying") return record.patentStatus === "申请中"; - if (filter === "granted") return record.patentStatus === "已有专利"; - if (filter === "failed") return record.patentStatus === "申请失败"; - if (filter === "dueSoon") return isProductPatentFeeDueSoon(record); - if (filter === "pending") return record.patentStatus === "待确认"; - if (filter === "overdue") return isProductPatentFeeOverdue(record); - if (filter === "stopped") return isProductPatentStopped(record); - if (filter === "noPatent") return record.patentStatus === "无专利"; - return true; -} - -function countProductPatentRecordsByFilter( - records: ProductPatentRecord[], - filter: ProductPatentBoardFilter, -) { - return records.filter((record) => - recordMatchesProductPatentBoardFilter(record, filter), - ).length; -} - -function getProductPatentDimensionValue( - record: ProductPatentRecord, - dimension: ProductPatentBoardDimension, -) { - const valueGetter: Record< - ProductPatentBoardDimension, - (currentRecord: ProductPatentRecord) => string - > = { - designer: (currentRecord) => currentRecord.designer, - status: (currentRecord) => { - if (currentRecord.patentStatus === "已有专利") return "申请成功"; - if (currentRecord.patentStatus === "放弃维护") return "停止维护"; - return currentRecord.patentStatus; - }, - salesStatus: (currentRecord) => currentRecord.salesStatus, - patentType: (currentRecord) => currentRecord.patentType, - }; - - return valueGetter[dimension](record).trim() || "未填写"; -} - -function getProductPatentDimensionCounts( - records: ProductPatentRecord[], - dimension: ProductPatentBoardDimension, -) { - const counts = records.reduce>((current, record) => { - const label = getProductPatentDimensionValue(record, dimension); - current[label] = (current[label] ?? 0) + 1; - return current; - }, {}); - - return Object.entries(counts).sort( - (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]), - ); -} - -function getProductPatentDimensionChartItems( - records: ProductPatentRecord[], - dimension: ProductPatentBoardDimension, - limit = 8, -) { - const counts = getProductPatentDimensionCounts(records, dimension); - const total = records.length; - if (total === 0) return []; - - const visibleCount = Math.max(limit - 1, 1); - const entries = - counts.length > limit - ? [ - ...counts.slice(0, visibleCount).map(([label, count]) => ({ - label, - values: [label], - count, - })), - { - label: "其他", - values: counts.slice(visibleCount).map(([label]) => label), - count: counts - .slice(visibleCount) - .reduce((sum, [, count]) => sum + count, 0), - }, - ] - : counts.map(([label, count]) => ({ - label, - values: [label], - count, - })); - - let cursor = 0; - return entries.map((entry, index) => { - const percent = Math.round((entry.count / total) * 100); - const start = cursor; - const end = - index === entries.length - 1 ? 100 : cursor + (entry.count / total) * 100; - cursor = end; - return { - ...entry, - percent, - start, - end, - color: - PRODUCT_PROGRESS_DIMENSION_COLORS[ - index % PRODUCT_PROGRESS_DIMENSION_COLORS.length - ], - }; - }); -} - -function recordMatchesProductPatentDimensionFilter( - record: ProductPatentRecord, - dimension: ProductPatentBoardDimension, - filter: ProductPatentBoardDimensionFilter, -) { - if (filter === null || filter.dimension !== dimension) return true; - const value = getProductPatentDimensionValue(record, dimension); - return (filter.values ?? [filter.value]).includes(value); -} - -function formatPatentDueDays(record: ProductPatentRecord) { - const dueDays = getDaysUntilPatentDate(record.nextFeeDueDate); - if (dueDays === null) return "-"; - if (dueDays < 0) return `逾期${Math.abs(dueDays)}天`; - if (dueDays === 0) return "今天到期"; - return `${dueDays}天`; -} - -function formatPatentDate(value: string) { - return value.trim() || "-"; -} - -function getProductPatentRiskBadgeType( - tone: ReturnType["tone"], -): "success" | "info" | "warning" | "error" | "default" { - if (tone === "danger") return "error"; - if (tone === "warning") return "warning"; - if (tone === "info") return "info"; - if (tone === "success") return "success"; - return "default"; -} - -function PatentSummaryCard({ - label, - value, - description, -}: { - label: string; - value: number; - description: string; -}) { - return ( -
-
- {label} - - {value} - -
-

- {description} -

-
- ); -} - -function PatentField({ - label, - children, -}: { - label: string; - children: ReactNode; -}) { - return ( - - ); -} - -function ProductPatentBoardTable({ - records, - title, - description, - toolbar, -}: { - records: ProductPatentRecord[]; - title: string; - description: string; - toolbar?: ReactNode; -}) { - return ( -
-
-
-

- {title} -

-

- {description} -

-
- - {records.length} 条 - -
- {toolbar ? ( -
- {toolbar} -
- ) : null} -
- - - - - - - - - - - - - - - - - - - - - {records.length === 0 ? ( - - - - ) : ( - records.map((record, index) => { - const risk = getPatentFeeRisk(record); - return ( - - - - - - - - - - - - - - - - - ); - }) - )} - -
序号图片 - / 产品 - - 专利名称 - - 专利状态 - - 申请号 - - 专利号 - - 设计师 - - 销售状态 - - 申请日 - - 下年度截止 - - 到期判断 - - 续缴判断 - - 年费进度 -
- 当前条件下没有专利记录。 -
- {index + 1} - -
- {record.productImage ? ( - // eslint-disable-next-line @next/next/no-img-element - { - ) : ( -
-
- {record.spu || record.productDisplayName || "-"} - - {record.patentName || "-"} - - - {record.patentStatus} - - - {record.applicationNo || "-"} - - {record.patentNo || "-"} - - {record.designer || "-"} - - {record.salesStatus || "-"} - - {formatPatentDate(record.applicationDate)} - - {formatPatentDate(record.nextFeeDueDate)} - - - {risk.label} - - - {record.continueDecision || "-"} - - {getPatentFeeProgress(record)} -
-
-
- ); -} - -function ProductPatentStatusDashboard({ - products, -}: { - products: NonIntegratedProductRecord[]; -}) { - const [records, setRecords] = useState(() => - mergeProductPatentRecordsWithProducts(listProductPatentRecords(), products), - ); - const [activePanel, setActivePanel] = - useState("status"); - const [activeFilter, setActiveFilter] = - useState("all"); - const [activeDimension, setActiveDimension] = - useState("status"); - const [dimensionFilter, setDimensionFilter] = - useState(null); - const [hoveredDimensionLabel, setHoveredDimensionLabel] = useState< - string | null - >(null); - const [queryDraft, setQueryDraft] = useState<{ - keyword: string; - designer: string; - status: ProductPatentStatus | "all"; - salesStatus: ProductPatentSalesStatus | "all"; - }>({ - keyword: "", - designer: "", - status: "all", - salesStatus: "all", - }); - const [appliedQuery, setAppliedQuery] = useState(queryDraft); - - useEffect(() => { - let cancelled = false; - void (async () => { - const storedRecords = await listProductPatentRecordsFromDatabase().catch( - () => listProductPatentRecords(), - ); - const next = mergeProductPatentRecordsWithProducts(storedRecords, products); - if (cancelled) return; - setRecords(next); - void saveProductPatentRecordsToDatabase(next).catch(() => undefined); - })(); - return () => { - cancelled = true; - }; - }, [products]); - - const queryMatchedRecords = useMemo(() => { - return records.filter((record) => { - const keyword = appliedQuery.keyword.trim().toUpperCase(); - const designer = appliedQuery.designer.trim().toUpperCase(); - const keywordMatched = - keyword.length === 0 || - [ - record.productDisplayName, - record.patentName, - record.spu, - record.applicationNo, - record.patentNo, - record.designer, - record.owner, - ...record.linkedProductNames, - ...record.linkedSpus, - ...record.linkedBrands, - ].some((value) => value.toUpperCase().includes(keyword)); - const designerMatched = - designer.length === 0 || - record.designer.toUpperCase().includes(designer); - const statusMatched = - appliedQuery.status === "all" || - record.patentStatus === appliedQuery.status; - const salesStatusMatched = - appliedQuery.salesStatus === "all" || - record.salesStatus === appliedQuery.salesStatus; - - return ( - keywordMatched && - designerMatched && - statusMatched && - salesStatusMatched - ); - }); - }, [appliedQuery, records]); - - const filteredRecords = useMemo(() => { - return queryMatchedRecords.filter((record) => { - const boardFilterMatched = recordMatchesProductPatentBoardFilter( - record, - activeFilter, - ); - const dimensionMatched = - !dimensionFilter || - recordMatchesProductPatentDimensionFilter( - record, - activeDimension, - dimensionFilter, - ); - const feePanelMatched = - activePanel !== "fee" || - activeFilter !== "all" || - isProductPatentFeeDueSoon(record) || - isProductPatentFeeOverdue(record); - - return boardFilterMatched && dimensionMatched && feePanelMatched; - }); - }, [ - activeDimension, - activeFilter, - activePanel, - dimensionFilter, - queryMatchedRecords, - ]); - - const dimensionBreakdownItems = getProductPatentDimensionCounts( - queryMatchedRecords, - activeDimension, - ).slice(0, 8); - const dimensionOptions: ProductPatentBoardDimension[] = [ - "status", - "designer", - "salesStatus", - "patentType", - ]; - const dimensionCounts = getProductPatentDimensionCounts( - queryMatchedRecords, - activeDimension, - ); - const dimensionChartItems = getProductPatentDimensionChartItems( - queryMatchedRecords, - activeDimension, - 8, - ); - const dimensionAnnotations = - getDimensionChartAnnotations(dimensionChartItems); - const activeDimensionPreview = - dimensionChartItems.find((item) => item.label === hoveredDimensionLabel) ?? - dimensionChartItems.find( - (item) => item.label === dimensionFilter?.value, - ) ?? - null; - const selectedDimensionRows = - dimensionFilter?.dimension === activeDimension - ? queryMatchedRecords.filter((record) => - recordMatchesProductPatentDimensionFilter( - record, - activeDimension, - dimensionFilter, - ), - ) - : queryMatchedRecords; - const selectedDimensionCount = selectedDimensionRows.length; - const selectedDimensionPercent = - queryMatchedRecords.length === 0 - ? 0 - : Math.round((selectedDimensionCount / queryMatchedRecords.length) * 100); - const highestFeeRiskDimension = dimensionBreakdownItems - .map(([label, count]) => { - const dimensionRecords = queryMatchedRecords.filter( - (record) => - getProductPatentDimensionValue(record, activeDimension) === label, - ); - return { - label, - count, - risk: - dimensionRecords.filter(isProductPatentFeeDueSoon).length + - dimensionRecords.filter(isProductPatentFeeOverdue).length, - }; - }) - .sort((left, right) => right.risk - left.risk)[0]; - - const feeWarningRecords = queryMatchedRecords - .filter((record) => isProductPatentFeeDueSoon(record)) - .sort( - (left, right) => - (getDaysUntilPatentDate(left.nextFeeDueDate) ?? 0) - - (getDaysUntilPatentDate(right.nextFeeDueDate) ?? 0), - ); - const overdueRecords = queryMatchedRecords - .filter((record) => isProductPatentFeeOverdue(record)) - .sort( - (left, right) => - (getDaysUntilPatentDate(left.nextFeeDueDate) ?? 0) - - (getDaysUntilPatentDate(right.nextFeeDueDate) ?? 0), - ); - const tableTitle = - dimensionFilter?.dimension === activeDimension - ? `${getProductPatentBoardFilterLabel(activeFilter)}明细:${ - dimensionFilter.value - }` - : `${getProductPatentBoardFilterLabel(activeFilter)}明细`; - - const applyDimensionItem = (item: ProductProgressDimensionChartItem) => { - setDimensionFilter({ - dimension: activeDimension, - value: item.label, - values: item.values, - }); - }; - - const handleDimensionItemKeyDown = ( - event: KeyboardEvent, - item: ProductProgressDimensionChartItem, - ) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - applyDimensionItem(item); - }; - - function refreshRecords() { - void (async () => { - const storedRecords = await listProductPatentRecordsFromDatabase().catch( - () => listProductPatentRecords(), - ); - const nextRecords = mergeProductPatentRecordsWithProducts( - storedRecords, - products, - ); - await saveProductPatentRecordsToDatabase(nextRecords); - setRecords(nextRecords); - setAppliedQuery(queryDraft); - })().catch(() => { - const nextRecords = mergeProductPatentRecordsWithProducts( - listProductPatentRecords(), - products, - ); - saveProductPatentRecords(nextRecords); - setRecords(nextRecords); - setAppliedQuery(queryDraft); - }); - } - - const statusFilterControls = ( -
- {PRODUCT_PATENT_BOARD_FILTERS.map((item) => { - const isActive = activeFilter === item.key; - const count = countProductPatentRecordsByFilter( - queryMatchedRecords, - item.key, - ); - return ( - - ); - })} -
- ); - - return ( -
-
-
-

- 产品专利看板 -

-

- 按申请状态和年费截止日查看产品专利,年费预警按下年度截止日前30天计算。 -

-
- -
-
- - - - -
- -
-
- -
-
- - {activePanel === "status" ? ( -
-
-
-
-
-

- 结构维度 -

-

- 先选维度,点环上标注进入明细。 -

-
- - {dimensionCounts.length} 个 - -
-
-
-
- {dimensionOptions.map((dimension) => { - const isActive = activeDimension === dimension; - return ( - - ); - })} -
-
- {dimensionAnnotations.length > 0 ? ( - - - {dimensionAnnotations.map((item) => { - const isSelected = - dimensionFilter?.dimension === activeDimension && - dimensionFilter.value === item.label; - const isHovered = - hoveredDimensionLabel === item.label; - const isEmphasized = isSelected || isHovered; - return ( - applyDimensionItem(item)} - onKeyDown={(event) => - handleDimensionItemKeyDown(event, item) - } - onMouseEnter={() => - setHoveredDimensionLabel(item.label) - } - onMouseLeave={() => - setHoveredDimensionLabel(null) - } - onFocus={() => - setHoveredDimensionLabel(item.label) - } - onBlur={() => setHoveredDimensionLabel(null)} - > - - {item.label} {item.count} / {item.percent}% - - - - - - - {getShortDimensionLabel(item.label)} - - - {item.count} / {item.percent}% - - - - ); - })} - { - setDimensionFilter(null); - setHoveredDimensionLabel(null); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") - return; - event.preventDefault(); - setDimensionFilter(null); - setHoveredDimensionLabel(null); - }} - > - - - {getShortDimensionLabel( - activeDimensionPreview?.label ?? - PRODUCT_PATENT_BOARD_DIMENSION_LABELS[ - activeDimension - ], - )} - - - {activeDimensionPreview - ? `${activeDimensionPreview.count} / ${activeDimensionPreview.percent}%` - : `${queryMatchedRecords.length} 条专利`} - - - - ) : ( -
- 暂无结构数据 -
- )} -
-
-
- -
-
-
-

- 结构拆解 -

-

- 每一行看申请成功、申请中、申请失败和年费风险。 -

-
- - 按 - {PRODUCT_PATENT_BOARD_DIMENSION_LABELS[activeDimension]} - -
-
-
- 结构项 - 状态分布 - 总数 - 预警 -
-
- {dimensionBreakdownItems.map(([label, count]) => { - const dimensionRecords = queryMatchedRecords.filter( - (record) => - getProductPatentDimensionValue( - record, - activeDimension, - ) === label, - ); - const grantedCount = countProductPatentRecordsByFilter( - dimensionRecords, - "granted", - ); - const applyingCount = countProductPatentRecordsByFilter( - dimensionRecords, - "applying", - ); - const failedCount = countProductPatentRecordsByFilter( - dimensionRecords, - "failed", - ); - const warningCount = - countProductPatentRecordsByFilter( - dimensionRecords, - "dueSoon", - ) + - countProductPatentRecordsByFilter( - dimensionRecords, - "overdue", - ); - const otherCount = Math.max( - count - grantedCount - applyingCount - failedCount, - 0, - ); - return ( - - ); - })} - {dimensionBreakdownItems.length === 0 ? ( -

- 暂无结构数据。 -

- ) : null} -
-
-
- -
-
-

- 看板判断 -

-

- 这里放需要负责人快速判断的结论。 -

-
-
-
- - {dimensionFilter?.dimension === activeDimension - ? dimensionFilter.value - : "全部结构"} - - - 当前占比 {selectedDimensionCount} /{" "} - {selectedDimensionPercent}%,下方明细已按该项联动。 - -
-
- - 年费风险 - - - {highestFeeRiskDimension?.risk - ? `${highestFeeRiskDimension.label} 有 ${highestFeeRiskDimension.risk} 条缴费预警,需要先确认截止日和续缴判断。` - : "当前结构项暂无30天内到期或逾期。"} - -
-
- 申请状态先看申请中、申请成功、申请失败;年费问题优先点缴费快到期和已逾期。 -
-
-
-
- ) : null} - - {activePanel === "fee" ? ( -
-
-
-

- 缴费快到期 -

-

- 下年度年费缴纳截止日在30天内的专利。 -

-
-
- {feeWarningRecords.slice(0, 6).map((record) => ( - - ))} - {feeWarningRecords.length === 0 ? ( -

- 暂无30天内到期。 -

- ) : null} -
-
- -
-
-

- 已逾期 -

-

- 截止日已过的专利,优先确认是否补缴或停止维护。 -

-
-
- {overdueRecords.slice(0, 6).map((record) => ( - - ))} - {overdueRecords.length === 0 ? ( -

- 暂无逾期专利。 -

- ) : null} -
-
-
- ) : null} - -
- -
-
-
- - -
- ); -} - -export function ProductPatentDashboard({ - products, - user, - target, - readOnly = false, -}: { - products: NonIntegratedProductRecord[]; - user: IntakeClientSelectorProps["user"]; - target?: ProductPatentLedgerTarget; - readOnly?: boolean; -}) { - const [records, setRecords] = useState(() => - mergeProductPatentRecordsWithProducts(listProductPatentRecords(), products), - ); - const [selectedRecordId, setSelectedRecordId] = useState(""); - const [query, setQuery] = useState({ - spu: "", - patentNo: "", - patentName: "", - designer: "", - }); - const [statusFilter, setStatusFilter] = useState( - "全部", - ); - const [decisionFilter, setDecisionFilter] = useState< - ProductPatentContinueDecision | "全部" - >("全部"); - const [activeAnnualFeeYears, setActiveAnnualFeeYears] = useState< - Partial> - >({}); - const [saveMessage, setSaveMessage] = useState(""); - const designerOptions = useMemo(() => { - const options = new Set( - products - .flatMap((product) => [ - product.designer, - product.openingReference?.designer, - ]) - .map((value) => value?.trim() ?? "") - .filter(Boolean), - ); - const userName = user.name.trim(); - if (userName) { - options.add(userName); - } - - const sortedOptions = Array.from(options).sort((a, b) => - a.localeCompare(b, "zh-CN"), - ); - if (PATENT_OWNER_MANAGER_ROLES.has(user.role)) { - return sortedOptions; - } - - return userName ? [userName] : sortedOptions.slice(0, 1); - }, [products, user.name, user.role]); - - useEffect(() => { - let cancelled = false; - void (async () => { - const storedRecords = await listProductPatentRecordsFromDatabase().catch( - () => listProductPatentRecords(), - ); - const next = mergeProductPatentRecordsWithProducts(storedRecords, products); - if (cancelled) return; - setRecords(next); - void saveProductPatentRecordsToDatabase(next).catch(() => undefined); - })(); - return () => { - cancelled = true; - }; - }, [products]); - - useEffect(() => { - if (records.length === 0) { - setSelectedRecordId(""); - return; - } - if (!records.some((record) => record.id === selectedRecordId)) { - setSelectedRecordId(records[0].id); - } - }, [records, selectedRecordId]); - - const targetMatchedRecord = useMemo(() => { - if (!target) return null; - const normalize = (value?: string) => - value?.trim().toUpperCase().replace(/\s+/g, "") ?? ""; - const targetProductId = target.productId?.trim() ?? ""; - const targetGroupKey = target.patentGroupKey?.trim() ?? ""; - const targetApplicationNo = normalize(target.applicationNo); - const targetPatentNo = normalize(target.patentNo); - const targetSpu = normalize(target.spu); - const targetPatentName = normalize(target.patentName); - return records.find((record) => { - if ( - targetProductId && - (record.productId === targetProductId || - record.linkedProductIds.includes(targetProductId)) - ) { - return true; - } - if (targetGroupKey && record.patentGroupKey === targetGroupKey) { - return true; - } - if ( - targetApplicationNo && - normalize(record.applicationNo) === targetApplicationNo - ) { - return true; - } - if (targetPatentNo && normalize(record.patentNo) === targetPatentNo) { - return true; - } - if ( - targetSpu && - [record.spu, ...record.linkedSpus].some( - (value) => normalize(value) === targetSpu, - ) - ) { - return true; - } - if ( - targetPatentName && - normalize(record.patentName) === targetPatentName - ) { - return true; - } - - return false; - }) ?? null; - }, [records, target]); - - useEffect(() => { - if (!target) return; - - if (!targetMatchedRecord) { - setSelectedRecordId(""); - setQuery({ - spu: target.spu ?? "", - patentNo: target.patentNo || target.applicationNo || "", - patentName: target.patentName ?? "", - designer: "", - }); - return; - } - - setSelectedRecordId(targetMatchedRecord.id); - setQuery({ - spu: targetMatchedRecord.spu || target.spu || "", - patentNo: - targetMatchedRecord.patentNo || - targetMatchedRecord.applicationNo || - target.patentNo || - target.applicationNo || - "", - patentName: readOnly - ? targetMatchedRecord.patentName || target.patentName || "" - : "", - designer: readOnly ? targetMatchedRecord.designer || "" : "", - }); - }, [readOnly, target, targetMatchedRecord]); - - const filteredRecords = useMemo(() => { - const normalizedSpuQuery = query.spu.trim().toUpperCase(); - const normalizedPatentNoQuery = query.patentNo.trim().toUpperCase(); - const normalizedPatentNameQuery = query.patentName.trim().toUpperCase(); - const normalizedDesignerQuery = query.designer.trim().toUpperCase(); - return records.filter((record) => { - const spuMatched = - !normalizedSpuQuery || - [ - record.spu, - ...record.linkedSpus, - ].some((value) => value.toUpperCase().includes(normalizedSpuQuery)); - const patentNoMatched = - !normalizedPatentNoQuery || - [ - record.applicationNo, - record.patentNo, - ].some((value) => value.toUpperCase().includes(normalizedPatentNoQuery)); - const patentNameMatched = - !normalizedPatentNameQuery || - [ - record.productDisplayName, - record.patentName, - ...record.linkedProductNames, - ...record.linkedBrands, - ].some((value) => - value.toUpperCase().includes(normalizedPatentNameQuery), - ); - const designerMatched = - !normalizedDesignerQuery || - [ - record.designer, - ].some((value) => value.toUpperCase().includes(normalizedDesignerQuery)); - const statusMatched = - statusFilter === "全部" || record.patentStatus === statusFilter; - const decisionMatched = - decisionFilter === "全部" || - record.continueDecision === decisionFilter; - return ( - spuMatched && - patentNoMatched && - patentNameMatched && - designerMatched && - statusMatched && - decisionMatched - ); - }); - }, [decisionFilter, query, records, statusFilter]); - - const tableInputClass = - "h-8 w-full min-w-0 rounded-none border-0 bg-transparent px-2 text-xs text-apple-label-primary outline-none focus:bg-white focus:ring-1 focus:ring-apple-blue"; - const tableSelectClass = - "h-8 w-full min-w-0 rounded-none border-0 bg-transparent px-2 text-xs text-apple-label-primary outline-none focus:bg-white focus:ring-1 focus:ring-apple-blue"; - const [uploadingCertificateRecordIds, setUploadingCertificateRecordIds] = - useState>(() => new Set()); - const highlightedRecordRef = useRef(null); - const visiblePatentRecords = useMemo( - () => - readOnly && target - ? targetMatchedRecord - ? [targetMatchedRecord] - : [] - : filteredRecords, - [filteredRecords, readOnly, target, targetMatchedRecord], - ); - const selectedRecord = - readOnly && target - ? targetMatchedRecord - : records.find((record) => record.id === selectedRecordId) ?? - filteredRecords[0] ?? - records[0] ?? - null; - - useEffect(() => { - highlightedRecordRef.current?.scrollIntoView?.({ - block: "center", - inline: "nearest", - }); - }, [selectedRecordId, visiblePatentRecords]); - - function persistNextRecords(nextRecords: ProductPatentRecord[]) { - void saveProductPatentRecordsToDatabase(nextRecords).catch(() => { - saveProductPatentRecords(nextRecords); - setSaveMessage("本地已缓存,数据库保存失败"); - window.setTimeout(() => setSaveMessage(""), 2400); - }); - setSaveMessage("已保存"); - window.setTimeout(() => setSaveMessage(""), 1600); - } - - function updatePatentRecord( - recordId: string, - patch: Partial, - ) { - if (readOnly) return; - setRecords((current) => { - const next = current.map((record) => - record.id === recordId - ? { - ...record, - ...patch, - annualFees: patch.annualFees - ? { ...record.annualFees, ...patch.annualFees } - : record.annualFees, - annualFeeDates: patch.annualFeeDates - ? { ...record.annualFeeDates, ...patch.annualFeeDates } - : record.annualFeeDates, - updatedAt: new Date().toISOString(), - } - : record, - ); - persistNextRecords(next); - return next; - }); - } - - function handlePatentImageUpload( - recordId: string, - event: ChangeEvent, - ) { - if (readOnly) { - event.target.value = ""; - return; - } - const file = event.target.files?.[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = () => { - updatePatentRecord(recordId, { - productImage: typeof reader.result === "string" ? reader.result : "", - productImageName: file.name, - }); - }; - reader.readAsDataURL(file); - } - - function handlePatentCertificateUpload( - recordId: string, - event: ChangeEvent, - ) { - if (readOnly) { - event.target.value = ""; - return; - } - const file = event.target.files?.[0]; - event.target.value = ""; - if (!file) return; - - setUploadingCertificateRecordIds((current) => { - const next = new Set(current); - next.add(recordId); - return next; - }); - - void (async () => { - const formData = new FormData(); - formData.append("file", file); - - const response = await fetch("/api/intake/non-integrated/upload", { - method: "POST", - body: formData, - }); - const payload = (await response.json().catch(() => null)) as { - success?: boolean; - data?: { - url?: string; - fileName?: string; - mimeType?: string; - storageKey?: string; - }; - error?: { message?: string }; - } | null; - - if (!response.ok || !payload?.success || !payload.data?.url) { - throw new Error(payload?.error?.message || "专利证书上传失败"); - } - - updatePatentRecord(recordId, { - patentCertificateFileName: payload.data.fileName || file.name, - patentCertificateFileUrl: payload.data.url, - patentCertificateFileMimeType: - payload.data.mimeType || file.type || "application/octet-stream", - patentCertificateStorageKey: payload.data.storageKey || "", - }); - })().catch((error) => { - setSaveMessage( - error instanceof Error && error.message ? error.message : "专利证书上传失败", - ); - window.setTimeout(() => setSaveMessage(""), 2400); - }).finally(() => { - setUploadingCertificateRecordIds((current) => { - const next = new Set(current); - next.delete(recordId); - return next; - }); - }); - } - - function handlePatentCertificateDownload(record: ProductPatentRecord) { - if (!record.patentCertificateFileUrl) return; - - const anchor = document.createElement("a"); - anchor.href = record.patentCertificateFileUrl; - anchor.download = - record.patentCertificateFileName || - `${record.spu || record.patentNo || record.patentName || "专利"}证书`; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - } - - function updateAnnualFee( - record: ProductPatentRecord, - year: (typeof PRODUCT_PATENT_FEE_YEARS)[number], - value: ProductPatentFeeStatus, - ) { - if (isPatentAnnualFeeLocked(record, year) && value !== "已缴") { - return; - } - - setActiveAnnualFeeYears((current) => ({ - ...current, - [record.id]: year, - })); - - const nextAnnualFees = { - ...record.annualFees, - [year]: value, - }; - const nextAnnualFeeDates = { - ...record.annualFeeDates, - [year]: value === "已缴" ? (record.annualFeeDates[year] ?? "") : "", - }; - - updatePatentRecord(record.id, { - annualFees: nextAnnualFees, - annualFeeDates: nextAnnualFeeDates, - nextFeeDueDate: getNextProductPatentFeeDueDate(nextAnnualFeeDates), - }); - } - - function updateAnnualFeeDate( - record: ProductPatentRecord, - year: (typeof PRODUCT_PATENT_FEE_YEARS)[number], - value: string, - ) { - const nextAnnualFeeDates = { - ...record.annualFeeDates, - [year]: value, - }; - updatePatentRecord(record.id, { - annualFees: { - ...record.annualFees, - [year]: value ? "已缴" : record.annualFees[year] ?? "", - }, - annualFeeDates: nextAnnualFeeDates, - nextFeeDueDate: getNextProductPatentFeeDueDate(nextAnnualFeeDates), - }); - } - - return ( -
-
-
-
-

- 产品专利明细 -

-

- 按原始表格维护专利状态、专利号、年费年份和下次缴费信息。 -

-
-
- {saveMessage ? ( - - {saveMessage} - - ) : null} -
-
-
- -
-
-
- - - - - - -
-
- - - - {[ - "序列号", - "专利状态", - "图片", - "外观设计名称", - "SPU", - "是否SA", - "销售状态", - "设计师", - "申请号", - "专利号", - "申请日", - "缴费日", - "进度详情", - ...PRODUCT_PATENT_FEE_YEARS, - "下年度年费缴纳截止日", - "金额", - "专利证书", - "年度", - ].map((column) => ( - - ))} - - - - {visiblePatentRecords.length === 0 ? ( - - - - ) : ( - visiblePatentRecords.map((record, index) => ( - setSelectedRecordId(record.id)} - > - - - - - - - - - - - - - - {PRODUCT_PATENT_FEE_YEARS.map((year) => ( - - ))} - - - - - - )) - )} - -
- {column} -
- 暂无匹配的专利记录 -
- {index + 1} - - - - - - - updatePatentRecord(record.id, { - patentName: event.target.value, - }) - } - className={tableInputClass} - /> - - - updatePatentRecord(record.id, { - spu: event.target.value, - }) - } - className={tableInputClass} - /> - {record.linkedSpus.length > 1 ? ( -
- {record.linkedSpus.join(" / ")} -
- ) : null} -
- - - - - - - - updatePatentRecord(record.id, { - applicationNo: event.target.value, - }) - } - className={tableInputClass} - /> - - - updatePatentRecord(record.id, { - patentNo: event.target.value, - }) - } - className={tableInputClass} - /> - - - updatePatentRecord(record.id, { - applicationDate: event.target.value, - }) - } - className={tableInputClass} - /> - - - updatePatentRecord(record.id, { - firstFeeDate: event.target.value, - }) - } - className={tableInputClass} - /> - - {(() => { - const activeYear = getActivePatentFeeYear( - record, - activeAnnualFeeYears[record.id], - ); - return ( - - updateAnnualFeeDate( - record, - activeYear, - event.target.value, - ) - } - className={tableInputClass} - /> - ); - })()} - - - - - updatePatentRecord(record.id, { - nextFeeDueDate: event.target.value, - }) - } - className={tableInputClass} - /> - - - updatePatentRecord(record.id, { - feeAmount: event.target.value, - }) - } - className={tableInputClass} - /> - -
-
-
- {uploadingCertificateRecordIds.has(record.id) - ? "上传中" - : record.patentCertificateFileUrl - ? "已上传" - : "未上传"} -
-
- {record.patentCertificateFileName || - "暂无专利证书"} -
-
-
- {!readOnly ? ( - - ) : null} - -
-
-
- {record.nextFeeDueDate - ? `${record.patentType}专利${getPatentFeeDateSummary( - record, - )}年费` - : "-"} -
-
-
- -
- {selectedRecord ? ( -
-
-
-

- 专利信息维护 -

-

- {selectedRecord.productDisplayName || - selectedRecord.patentName || - "新增专利"} -

-
- - {getPatentFeeRisk(selectedRecord).label} - -
- -
- - -
- {selectedRecord.productDisplayName || "流程审批后自动带入"} -
- {selectedRecord.linkedSpus.length > 1 || - selectedRecord.linkedBrands.length > 1 ? ( -

- 已按同一专利合并: - {selectedRecord.linkedSpus.join(" / ")} - {selectedRecord.linkedBrands.length - ? `(${selectedRecord.linkedBrands.join("、")})` - : ""} -

- ) : null} -
-
- -
- - - - - - - - - updatePatentRecord(selectedRecord.id, { - patentName: event.target.value, - }) - } - className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs outline-none focus:border-apple-blue" - /> - - - - updatePatentRecord(selectedRecord.id, { - spu: event.target.value, - }) - } - className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs outline-none focus:border-apple-blue" - /> - - - - - - - - - - - - - updatePatentRecord(selectedRecord.id, { - applicationNo: event.target.value, - }) - } - className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs outline-none focus:border-apple-blue" - /> - - - - updatePatentRecord(selectedRecord.id, { - patentNo: event.target.value, - }) - } - className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs outline-none focus:border-apple-blue" - /> - - - - updatePatentRecord(selectedRecord.id, { - applicationDate: event.target.value, - }) - } - className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs outline-none focus:border-apple-blue" - /> - - - - updatePatentRecord(selectedRecord.id, { - authorizationDate: event.target.value, - }) - } - className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs outline-none focus:border-apple-blue" - /> - - - - updatePatentRecord(selectedRecord.id, { - nextFeeDueDate: event.target.value, - }) - } - className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs outline-none focus:border-apple-blue" - /> - - - {(() => { - const activeYear = getActivePatentFeeYear( - selectedRecord, - activeAnnualFeeYears[selectedRecord.id], - ); - return ( - - updateAnnualFeeDate( - selectedRecord, - activeYear, - event.target.value, - ) - } - className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs outline-none focus:border-apple-blue" - /> - ); - })()} - - - - updatePatentRecord(selectedRecord.id, { - feeAmount: event.target.value, - }) - } - className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs outline-none focus:border-apple-blue" - /> - - - - - - - updatePatentRecord(selectedRecord.id, { - owner: event.target.value, - }) - } - className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs outline-none focus:border-apple-blue" - /> - -
- -
-

- 年费记录 -

-
- {PRODUCT_PATENT_FEE_YEARS.map((year) => ( - - ))} -
-
- - -