diff --git a/furniture-3d-system/public/brand-filing/templates/索菲亚企划表.xlsx b/furniture-3d-system/public/brand-filing/templates/索菲亚企划表.xlsx new file mode 100644 index 00000000..48c3f09e Binary files /dev/null and b/furniture-3d-system/public/brand-filing/templates/索菲亚企划表.xlsx differ diff --git a/furniture-3d-system/public/brand-filing/templates/顾家企划表.xlsx b/furniture-3d-system/public/brand-filing/templates/顾家企划表.xlsx new file mode 100644 index 00000000..48c3f09e Binary files /dev/null and b/furniture-3d-system/public/brand-filing/templates/顾家企划表.xlsx differ diff --git a/furniture-3d-system/src/app/api/intake/brand-filing/planning-table/route.ts b/furniture-3d-system/src/app/api/intake/brand-filing/planning-table/route.ts new file mode 100644 index 00000000..db444a3f --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/brand-filing/planning-table/route.ts @@ -0,0 +1,173 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig"; +import { normalizeBrandFilingPlanningRecord } from "@/app/intake/brand-filing/brandFilingTypes"; +import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage"; +import type { BrandFilingPlanningTableRecordInput } from "@/app/intake/brand-filing/brandFilingPlanningTableRequest"; +import { AppError, ErrorCode } from "@/server/core/errors"; +import { handleRequest, jsonError } from "@/server/core/response"; +import { buildBrandFilingPlanningTableDownload } from "@/server/services/brand-filing-planning-table.service"; +import { intakeNonIntegratedProductService } from "@/server/services/intake-non-integrated-product.service"; + +function buildAttachmentHeaders(fileName: string, contentType: string) { + const encodedFileName = encodeURIComponent(fileName); + return { + "Content-Type": contentType, + "Content-Disposition": `attachment; filename*=UTF-8''${encodedFileName}`, + "Cache-Control": "no-store", + }; +} + +function isBrandFilingBrand(value: unknown): value is BrandFilingBrand { + return value === "gujia" || value === "suofeiya"; +} + +function parsePlanningRecordInputs( + value: unknown, +): BrandFilingPlanningTableRecordInput[] { + if (!Array.isArray(value)) return []; + + return value + .map((entry) => { + if (!entry || typeof entry !== "object") { + return null; + } + + const input = entry as Record; + if (typeof input.productId !== "string") return null; + + const planningTable = normalizeBrandFilingPlanningRecord( + input.planningTable, + ); + if (!planningTable) return null; + + return { + productId: input.productId.trim(), + planningTable, + } satisfies BrandFilingPlanningTableRecordInput; + }) + .filter( + (entry): entry is BrandFilingPlanningTableRecordInput => + Boolean(entry?.productId), + ); +} + +function readPlanningRecordTimestamp(value: string | undefined) { + if (!value) return Number.NEGATIVE_INFINITY; + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp; +} + +function applyPlanningRecordOverrides( + products: NonIntegratedProductRecord[], + brand: BrandFilingBrand, + overrides: BrandFilingPlanningTableRecordInput[], +) { + if (overrides.length === 0) return products; + + const overrideMap = new Map( + overrides.map((entry) => [entry.productId, entry.planningTable]), + ); + + return products.map((product) => { + const override = overrideMap.get(product.id); + if (!override) return product; + + const currentRecord = product.brandFilingData?.[brand]?.planningTable; + const nextPlanningTable = + readPlanningRecordTimestamp(override.updatedAt) >= + readPlanningRecordTimestamp(currentRecord?.updatedAt) + ? override + : currentRecord; + + if (!nextPlanningTable) return product; + + return { + ...product, + brandFilingData: { + ...(product.brandFilingData ?? {}), + [brand]: { + ...(product.brandFilingData?.[brand] ?? {}), + planningTable: nextPlanningTable, + }, + }, + }; + }); +} + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const rawBody = await request.json().catch(() => null); + const brand = isBrandFilingBrand(rawBody?.brand) ? rawBody.brand : null; + const productIds = Array.isArray(rawBody?.productIds) + ? rawBody.productIds.filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ) + : []; + const planningRecords = parsePlanningRecordInputs(rawBody?.planningRecords); + + if (!brand) { + return jsonError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + "缺少品牌参数", + 400, + ctx.requestId, + ); + } + + if (productIds.length === 0) { + return jsonError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + "请先选择产品", + 400, + ctx.requestId, + ); + } + + try { + const products = await Promise.all( + productIds.map((productId) => + intakeNonIntegratedProductService.getById(productId, ctx.user), + ), + ); + const missingProductId = productIds.find((_, index) => !products[index]); + if (missingProductId) { + return jsonError( + ErrorCode.PRODUCT_NOT_FOUND, + `${missingProductId} 不存在或无权限访问`, + 404, + ctx.requestId, + ); + } + + const result = await buildBrandFilingPlanningTableDownload({ + brand, + products: applyPlanningRecordOverrides( + products.filter(Boolean), + brand, + planningRecords, + ), + }); + const body = Uint8Array.from(result.bytes).buffer; + + return new NextResponse(body, { + status: 200, + headers: buildAttachmentHeaders(result.fileName, result.contentType), + }); + } catch (error) { + if (error instanceof AppError) { + return jsonError( + error.code, + error.message, + error.statusCode, + ctx.requestId, + ); + } + throw error; + } + }, + { requireAuth: true }, + ); +} diff --git a/furniture-3d-system/src/app/intake/IntakeActiveWorkspaceContent.tsx b/furniture-3d-system/src/app/intake/IntakeActiveWorkspaceContent.tsx index 8b8fb2a1..2bac2e18 100644 --- a/furniture-3d-system/src/app/intake/IntakeActiveWorkspaceContent.tsx +++ b/furniture-3d-system/src/app/intake/IntakeActiveWorkspaceContent.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { BarcodePrintWorkspace } from "@/app/intake/barcode-print/BarcodePrintWorkspace"; +import { BrandFilingWorkspace } from "@/app/intake/brand-filing/BrandFilingWorkspace"; import { BomLibraryWorkspaceContent, isBomLibraryWorkspaceTab, @@ -223,5 +224,15 @@ export function IntakeActiveWorkspaceContent({ ); } + if (activeWorkspaceTab.kind === "brandFiling") { + return ( + + ); + } + return <>{fallback}; } diff --git a/furniture-3d-system/src/app/intake/IntakePrimaryDomainContent.tsx b/furniture-3d-system/src/app/intake/IntakePrimaryDomainContent.tsx index bedb0085..808a8601 100644 --- a/furniture-3d-system/src/app/intake/IntakePrimaryDomainContent.tsx +++ b/furniture-3d-system/src/app/intake/IntakePrimaryDomainContent.tsx @@ -2,6 +2,7 @@ import type { ComponentProps, ReactNode } from "react"; import { BarcodePrintDomain } from "@/app/intake/barcode-print/BarcodePrintDomain"; +import { BrandFilingDomain } from "@/app/intake/brand-filing/BrandFilingDomain"; import { BomLibraryDomain } from "@/app/intake/bom-library/BomLibraryDomain"; import type { IntakeSection } from "@/app/intake/intakeWorkspaceTypes"; import { ProductDevelopmentDomain } from "@/app/intake/product-development/ProductDevelopmentDomain"; @@ -27,6 +28,7 @@ export type IntakePrimaryDomainContentProps = { productPatent: ComponentProps; canViewBarcodePrint: boolean; barcodePrint: ComponentProps; + canViewBrandFiling: boolean; }; export function IntakePrimaryDomainContent({ @@ -43,6 +45,7 @@ export function IntakePrimaryDomainContent({ productPatent, canViewBarcodePrint, barcodePrint, + canViewBrandFiling, }: IntakePrimaryDomainContentProps) { if (activeSection === "pending" || activeSection === "progress") { return ( @@ -73,5 +76,9 @@ export function IntakePrimaryDomainContent({ return ; } + if (activeSection === "brandFiling" && canViewBrandFiling) { + return ; + } + return <>{fallback}; } diff --git a/furniture-3d-system/src/app/intake/brand-filing/BrandFilingDomain.tsx b/furniture-3d-system/src/app/intake/brand-filing/BrandFilingDomain.tsx new file mode 100644 index 00000000..a9bc7b9b --- /dev/null +++ b/furniture-3d-system/src/app/intake/brand-filing/BrandFilingDomain.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { BrandFilingWorkbenchSurface } from "@/app/intake/brand-filing/BrandFilingWorkbenchSurface"; + +export function BrandFilingDomain() { + return ( + +
+ + ); +} diff --git a/furniture-3d-system/src/app/intake/brand-filing/BrandFilingPlanningTableDialog.tsx b/furniture-3d-system/src/app/intake/brand-filing/BrandFilingPlanningTableDialog.tsx new file mode 100644 index 00000000..ec73d9f7 --- /dev/null +++ b/furniture-3d-system/src/app/intake/brand-filing/BrandFilingPlanningTableDialog.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { BadgeCheck, X } from "lucide-react"; +import { BrandFilingPlanningTemplateSheet } from "@/app/intake/brand-filing/BrandFilingPlanningTemplateSheet"; +import { + getBrandFilingBrandLabel, + type BrandFilingBrand, +} from "@/app/intake/brand-filing/brandFilingConfig"; +import { + buildBrandFilingPlanningInitialState, + type BrandFilingPlanningEditorState, +} from "@/app/intake/brand-filing/brandFilingProductData"; +import { + type BrandFilingPlanningForm, + type BrandFilingPlanningLineItem, + type BrandFilingPlanningRecord, +} from "@/app/intake/brand-filing/brandFilingTypes"; +import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage"; + +export function BrandFilingPlanningTableDialog({ + isOpen, + brand, + product, + initialRecord, + saving, + onClose, + onSave, +}: { + isOpen: boolean; + brand: BrandFilingBrand; + product: NonIntegratedProductRecord | null; + initialRecord?: BrandFilingPlanningRecord; + saving: boolean; + onClose: () => void; + onSave: ( + form: BrandFilingPlanningForm, + lineItems: BrandFilingPlanningLineItem[], + completed: boolean, + ) => void; +}) { + if (!isOpen || !product) return null; + + const initialState = buildBrandFilingPlanningInitialState(product, initialRecord); + const dialogKey = `${product.id}:${initialRecord?.updatedAt ?? "new"}`; + + return ( + + ); +} + +function BrandFilingPlanningTableDialogContent({ + brand, + product, + initialRecord, + initialState, + saving, + onClose, + onSave, +}: { + brand: BrandFilingBrand; + product: NonIntegratedProductRecord; + initialRecord?: BrandFilingPlanningRecord; + initialState: BrandFilingPlanningEditorState; + saving: boolean; + onClose: () => void; + onSave: ( + form: BrandFilingPlanningForm, + lineItems: BrandFilingPlanningLineItem[], + completed: boolean, + ) => void; +}) { + const [form, setForm] = useState(initialState.form); + const [lineItems, setLineItems] = useState( + initialState.lineItems, + ); + const initialSnapshot = useMemo( + () => + JSON.stringify({ + form: initialState.form, + lineItems: initialState.lineItems, + }), + [initialState], + ); + const currentSnapshot = JSON.stringify({ form, lineItems }); + const isDirty = initialSnapshot !== currentSnapshot; + + const handleAttemptClose = useCallback(() => { + if (!isDirty) { + onClose(); + return; + } + + if (window.confirm("当前企划表还有未保存的内容,确定现在关闭吗?")) { + onClose(); + } + }, [isDirty, onClose]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + handleAttemptClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleAttemptClose]); + + const completed = initialRecord?.completed === true; + const sequenceText = initialRecord?.sequenceNumber + ? `序号 ${initialRecord.sequenceNumber}` + : "序号待生成"; + + const updateForm = ( + key: K, + value: BrandFilingPlanningForm[K], + ) => { + setForm((current) => { + const nextForm = { + ...current, + [key]: value, + }; + + if (key === "productSeries") { + nextForm.seriesName = current.seriesName || String(value); + } + + return nextForm; + }); + }; + + const updateLineItem = ( + itemCode: string, + key: keyof BrandFilingPlanningLineItem, + value: string, + ) => { + setLineItems((current) => + current.map((item) => + item.itemCode === itemCode + ? { + ...item, + [key]: value, + } + : item, + ), + ); + }; + + return ( +
+
event.stopPropagation()} + > +
+
+

+ {getBrandFilingBrandLabel(brand)}企划表 +

+
+ {product.spu.trim() || "-"} + + {product.openingReference?.productName?.trim() || + product.spu.trim() || + "未命名产品"} + + {sequenceText} + + +
+
+ +
+ +
+ +
+ +
+ + + +
+
+
+ ); +} diff --git a/furniture-3d-system/src/app/intake/brand-filing/BrandFilingPlanningTemplateSheet.tsx b/furniture-3d-system/src/app/intake/brand-filing/BrandFilingPlanningTemplateSheet.tsx new file mode 100644 index 00000000..b3759593 --- /dev/null +++ b/furniture-3d-system/src/app/intake/brand-filing/BrandFilingPlanningTemplateSheet.tsx @@ -0,0 +1,543 @@ +"use client"; + +import { Fragment, type ChangeEvent, type ReactNode } from "react"; +import { + buildBrandFilingLineItemChannelSummary, + buildBrandFilingPlanningDisplayRows, + type BrandFilingPlanningDisplayRow, +} from "@/app/intake/brand-filing/brandFilingPlanningLineItems"; +import { BRAND_FILING_PLANNING_FIELD_OPTIONS } from "@/app/intake/brand-filing/brandFilingPlanningTableSchema"; +import type { + BrandFilingPlanningForm, + BrandFilingPlanningLineItem, +} from "@/app/intake/brand-filing/brandFilingTypes"; +import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage"; + +const SHEET_BORDER = "border-slate-500/90"; +const SECTION_BAND_CLASS = + `flex min-h-9 items-center justify-center border-b ${SHEET_BORDER} bg-[#dceef4] px-4 text-[18px] font-semibold tracking-[0.16em] text-slate-900`; +const TITLE_BAND_CLASS = + `flex min-h-12 items-center justify-center border-b ${SHEET_BORDER} bg-white px-4 text-[26px] font-semibold tracking-[0.04em] text-slate-950`; +const LABEL_CELL_CLASS = + `flex min-h-11 items-center justify-center border-r border-b ${SHEET_BORDER} bg-white px-3 py-2 text-center text-[15px] font-semibold leading-tight text-slate-900`; +const VALUE_CELL_CLASS = + `min-h-11 border-r border-b ${SHEET_BORDER} bg-white px-3 py-2 text-[15px] text-slate-700`; +const INPUT_CLASS = + "h-full min-h-[28px] w-full bg-transparent text-center text-[15px] leading-tight text-slate-700 outline-none placeholder:text-slate-300"; +const INPUT_LEFT_CLASS = + "h-full min-h-[28px] w-full bg-transparent text-left text-[15px] leading-tight text-slate-700 outline-none placeholder:text-slate-300"; +const TEXTAREA_CLASS = + "h-full min-h-[160px] w-full resize-none bg-transparent text-left text-[15px] leading-7 text-slate-700 outline-none placeholder:text-slate-300"; + +function SectionBand({ title }: { title: string }) { + return
{title}
; +} + +function LabelCell({ + children, + className = "", +}: { + children: ReactNode; + className?: string; +}) { + return
{children}
; +} + +function InputCell({ + value, + onChange, + placeholder, + className = "", + inputClassName = INPUT_CLASS, + listId, + options, +}: { + value: string; + onChange: (event: ChangeEvent) => void; + placeholder?: string; + className?: string; + inputClassName?: string; + listId?: string; + options?: readonly string[]; +}) { + return ( +
+ + {listId && options?.length ? ( + + {options.map((option) => ( + + ) : null} +
+ ); +} + +function ReadonlyValueCell({ + value, + className = "", + textClassName = "text-center", + emptyPlaceholder = "-", +}: { + value: string; + className?: string; + textClassName?: string; + emptyPlaceholder?: string; +}) { + return ( +
+
+ {value.trim() || emptyPlaceholder} +
+
+ ); +} + +function TextareaCell({ + value, + onChange, + className = "", +}: { + value: string; + onChange: (event: ChangeEvent) => void; + className?: string; +}) { + return ( +
+