feat(intake): add brand filing workspace and planning export
This commit is contained in:
parent
5f5dc2ead7
commit
cf44f1c371
BIN
furniture-3d-system/public/brand-filing/templates/索菲亚企划表.xlsx
Normal file
BIN
furniture-3d-system/public/brand-filing/templates/索菲亚企划表.xlsx
Normal file
Binary file not shown.
BIN
furniture-3d-system/public/brand-filing/templates/顾家企划表.xlsx
Normal file
BIN
furniture-3d-system/public/brand-filing/templates/顾家企划表.xlsx
Normal file
Binary file not shown.
@ -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<string, unknown>;
|
||||
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 },
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<BrandFilingWorkspace
|
||||
brand={activeWorkspaceTab.brand}
|
||||
products={nonIntegratedProducts}
|
||||
onProductSaved={onProductSaved}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
@ -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<typeof ProductPatentDashboard>;
|
||||
canViewBarcodePrint: boolean;
|
||||
barcodePrint: ComponentProps<typeof BarcodePrintDomain>;
|
||||
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 <BarcodePrintDomain {...barcodePrint} />;
|
||||
}
|
||||
|
||||
if (activeSection === "brandFiling" && canViewBrandFiling) {
|
||||
return <BrandFilingDomain />;
|
||||
}
|
||||
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { BrandFilingWorkbenchSurface } from "@/app/intake/brand-filing/BrandFilingWorkbenchSurface";
|
||||
|
||||
export function BrandFilingDomain() {
|
||||
return (
|
||||
<BrandFilingWorkbenchSurface>
|
||||
<div className="h-full" />
|
||||
</BrandFilingWorkbenchSurface>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<BrandFilingPlanningTableDialogContent
|
||||
key={dialogKey}
|
||||
brand={brand}
|
||||
product={product}
|
||||
initialRecord={initialRecord}
|
||||
initialState={initialState}
|
||||
saving={saving}
|
||||
onClose={onClose}
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<BrandFilingPlanningForm>(initialState.form);
|
||||
const [lineItems, setLineItems] = useState<BrandFilingPlanningLineItem[]>(
|
||||
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 = <K extends keyof BrandFilingPlanningForm>(
|
||||
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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="brand-filing-planning-dialog-title"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-auto bg-black/35 px-4 py-[5vh]"
|
||||
onClick={handleAttemptClose}
|
||||
>
|
||||
<div
|
||||
className="flex w-[1320px] max-w-full flex-col overflow-hidden rounded-[10px] border border-[var(--obs-shell-border)] bg-white shadow-[0_30px_80px_rgba(15,23,42,0.2)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[var(--obs-shell-border)] px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2
|
||||
id="brand-filing-planning-dialog-title"
|
||||
className="text-[16px] font-semibold text-[var(--obs-shell-title)]"
|
||||
>
|
||||
{getBrandFilingBrandLabel(brand)}企划表
|
||||
</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-[var(--obs-shell-muted)]">
|
||||
<span>{product.spu.trim() || "-"}</span>
|
||||
<span>
|
||||
{product.openingReference?.productName?.trim() ||
|
||||
product.spu.trim() ||
|
||||
"未命名产品"}
|
||||
</span>
|
||||
<span>{sequenceText}</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--obs-shell-soft-bg)] px-2 py-0.5 text-[11px] text-[var(--obs-shell-title)]">
|
||||
<BadgeCheck className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{completed ? "已确认" : "待确认"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={handleAttemptClose}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-muted)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto px-5 py-5">
|
||||
<BrandFilingPlanningTemplateSheet
|
||||
product={product}
|
||||
form={form}
|
||||
onChange={updateForm}
|
||||
lineItems={lineItems}
|
||||
onLineItemChange={updateLineItem}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-content-bg)] px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAttemptClose}
|
||||
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => onSave(form, lineItems, completed)}
|
||||
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => onSave(form, lineItems, true)}
|
||||
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
保存并确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 <div className={SECTION_BAND_CLASS}>{title}</div>;
|
||||
}
|
||||
|
||||
function LabelCell({
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={`${LABEL_CELL_CLASS} ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
function InputCell({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className = "",
|
||||
inputClassName = INPUT_CLASS,
|
||||
listId,
|
||||
options,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
listId?: string;
|
||||
options?: readonly string[];
|
||||
}) {
|
||||
return (
|
||||
<div className={`${VALUE_CELL_CLASS} ${className}`}>
|
||||
<input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
list={listId}
|
||||
className={inputClassName}
|
||||
/>
|
||||
{listId && options?.length ? (
|
||||
<datalist id={listId}>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option} />
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadonlyValueCell({
|
||||
value,
|
||||
className = "",
|
||||
textClassName = "text-center",
|
||||
emptyPlaceholder = "-",
|
||||
}: {
|
||||
value: string;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
emptyPlaceholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`${VALUE_CELL_CLASS} flex items-center justify-center ${className}`}
|
||||
>
|
||||
<div className={`w-full text-[15px] leading-tight text-slate-700 ${textClassName}`}>
|
||||
{value.trim() || emptyPlaceholder}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextareaCell({
|
||||
value,
|
||||
onChange,
|
||||
className = "",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`${VALUE_CELL_CLASS} ${className}`}>
|
||||
<textarea value={value} onChange={onChange} className={TEXTAREA_CLASS} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageCell({ imageUrl }: { imageUrl: string }) {
|
||||
return (
|
||||
<div className={`min-h-[200px] border-r border-b ${SHEET_BORDER} bg-white p-3`}>
|
||||
<div className="flex h-full min-h-[172px] items-center justify-center overflow-hidden bg-white">
|
||||
{imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="产品图片"
|
||||
className="max-h-[172px] w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-[14px] text-slate-300">暂无图片</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanSectionRow({
|
||||
displayRow,
|
||||
productHasSkuRows,
|
||||
legacyIterationNote,
|
||||
onChange,
|
||||
onLineItemChange,
|
||||
}: {
|
||||
displayRow: BrandFilingPlanningDisplayRow;
|
||||
productHasSkuRows: boolean;
|
||||
legacyIterationNote: string;
|
||||
onChange: <K extends keyof BrandFilingPlanningForm>(
|
||||
key: K,
|
||||
value: BrandFilingPlanningForm[K],
|
||||
) => void;
|
||||
onLineItemChange: (
|
||||
itemCode: string,
|
||||
key: keyof BrandFilingPlanningLineItem,
|
||||
value: string,
|
||||
) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{productHasSkuRows ? (
|
||||
<ReadonlyValueCell value={displayRow.itemCode} />
|
||||
) : (
|
||||
<InputCell
|
||||
value={displayRow.itemCode}
|
||||
onChange={(event) =>
|
||||
onLineItemChange(displayRow.itemCode, "itemCode", event.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<InputCell
|
||||
value={displayRow.retailPriceDemand}
|
||||
onChange={(event) =>
|
||||
onLineItemChange(
|
||||
displayRow.itemCode,
|
||||
"retailPriceDemand",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputCell
|
||||
value={displayRow.purchasePriceDemand}
|
||||
onChange={(event) =>
|
||||
onLineItemChange(
|
||||
displayRow.itemCode,
|
||||
"purchasePriceDemand",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{productHasSkuRows ? (
|
||||
<ReadonlyValueCell value={displayRow.sizeRequirement} />
|
||||
) : (
|
||||
<InputCell
|
||||
value={displayRow.sizeRequirement}
|
||||
onChange={(event) =>
|
||||
onLineItemChange(
|
||||
displayRow.itemCode,
|
||||
"sizeRequirement",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{productHasSkuRows ? (
|
||||
<ReadonlyValueCell
|
||||
value={displayRow.materialAndColor}
|
||||
textClassName="text-left"
|
||||
/>
|
||||
) : (
|
||||
<InputCell
|
||||
value={displayRow.materialAndColor}
|
||||
onChange={(event) =>
|
||||
onLineItemChange(
|
||||
displayRow.itemCode,
|
||||
"materialAndColor",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
inputClassName={INPUT_LEFT_CLASS}
|
||||
/>
|
||||
)}
|
||||
<InputCell
|
||||
value={legacyIterationNote}
|
||||
onChange={(event) =>
|
||||
onChange("legacyIterationNote", event.target.value)
|
||||
}
|
||||
listId="legacy-iteration-note-options"
|
||||
options={BRAND_FILING_PLANNING_FIELD_OPTIONS.legacyIterationNote}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PromotionSectionRow({
|
||||
displayRow,
|
||||
onLineItemChange,
|
||||
}: {
|
||||
displayRow: BrandFilingPlanningDisplayRow;
|
||||
onLineItemChange: (
|
||||
itemCode: string,
|
||||
key: keyof BrandFilingPlanningLineItem,
|
||||
value: string,
|
||||
) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<InputCell
|
||||
value={displayRow.seriesName}
|
||||
onChange={(event) =>
|
||||
onLineItemChange(displayRow.itemCode, "seriesName", event.target.value)
|
||||
}
|
||||
/>
|
||||
<ReadonlyValueCell value={displayRow.itemCode} />
|
||||
<ReadonlyValueCell value={displayRow.annualSalesTarget} />
|
||||
<InputCell
|
||||
value={displayRow.channel}
|
||||
onChange={(event) =>
|
||||
onLineItemChange(displayRow.itemCode, "channel", event.target.value)
|
||||
}
|
||||
/>
|
||||
<InputCell
|
||||
value={displayRow.month1Sales}
|
||||
onChange={(event) =>
|
||||
onLineItemChange(displayRow.itemCode, "month1Sales", event.target.value)
|
||||
}
|
||||
/>
|
||||
<InputCell
|
||||
value={displayRow.month2Sales}
|
||||
onChange={(event) =>
|
||||
onLineItemChange(displayRow.itemCode, "month2Sales", event.target.value)
|
||||
}
|
||||
/>
|
||||
<InputCell
|
||||
value={displayRow.month3Sales}
|
||||
onChange={(event) =>
|
||||
onLineItemChange(displayRow.itemCode, "month3Sales", event.target.value)
|
||||
}
|
||||
/>
|
||||
<InputCell
|
||||
value={displayRow.month4To6Sales}
|
||||
onChange={(event) =>
|
||||
onLineItemChange(
|
||||
displayRow.itemCode,
|
||||
"month4To6Sales",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputCell
|
||||
value={displayRow.month7To9Sales}
|
||||
onChange={(event) =>
|
||||
onLineItemChange(
|
||||
displayRow.itemCode,
|
||||
"month7To9Sales",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputCell
|
||||
value={displayRow.month10To12Sales}
|
||||
onChange={(event) =>
|
||||
onLineItemChange(
|
||||
displayRow.itemCode,
|
||||
"month10To12Sales",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrandFilingPlanningTemplateSheet({
|
||||
product,
|
||||
form,
|
||||
lineItems,
|
||||
onChange,
|
||||
onLineItemChange,
|
||||
}: {
|
||||
product: NonIntegratedProductRecord;
|
||||
form: BrandFilingPlanningForm;
|
||||
lineItems: BrandFilingPlanningLineItem[];
|
||||
onChange: <K extends keyof BrandFilingPlanningForm>(
|
||||
key: K,
|
||||
value: BrandFilingPlanningForm[K],
|
||||
) => void;
|
||||
onLineItemChange: (
|
||||
itemCode: string,
|
||||
key: keyof BrandFilingPlanningLineItem,
|
||||
value: string,
|
||||
) => void;
|
||||
}) {
|
||||
const previewImage = form.imageUrl.trim();
|
||||
const planningProductLineOptions =
|
||||
BRAND_FILING_PLANNING_FIELD_OPTIONS.planningProductLine;
|
||||
const productLineOptions = BRAND_FILING_PLANNING_FIELD_OPTIONS.productLine;
|
||||
const annualPlanStatusOptions =
|
||||
BRAND_FILING_PLANNING_FIELD_OPTIONS.annualPlanStatus;
|
||||
const developmentModeOptions =
|
||||
BRAND_FILING_PLANNING_FIELD_OPTIONS.developmentMode;
|
||||
const developmentCategoryOptions =
|
||||
BRAND_FILING_PLANNING_FIELD_OPTIONS.developmentCategory;
|
||||
const productManagerOptions =
|
||||
BRAND_FILING_PLANNING_FIELD_OPTIONS.productManager;
|
||||
const warehousingStatusOptions =
|
||||
BRAND_FILING_PLANNING_FIELD_OPTIONS.warehousingStatus;
|
||||
const activityStyleStatusOptions =
|
||||
BRAND_FILING_PLANNING_FIELD_OPTIONS.activityStyleStatus;
|
||||
const displayRows = buildBrandFilingPlanningDisplayRows({
|
||||
product,
|
||||
form,
|
||||
lineItems,
|
||||
});
|
||||
const productHasSkuRows = product.skuItems.length > 0;
|
||||
const channelSummary =
|
||||
buildBrandFilingLineItemChannelSummary(lineItems) || form.channel.trim();
|
||||
|
||||
return (
|
||||
<div className="overflow-auto rounded-[12px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] p-4">
|
||||
<div className="mx-auto min-w-[1100px] max-w-[1240px] overflow-hidden border border-slate-500/90 bg-white shadow-[0_8px_20px_rgba(15,23,42,0.06)]">
|
||||
<div className={TITLE_BAND_CLASS}>电商外协产品企划单</div>
|
||||
|
||||
<SectionBand title="基本信息" />
|
||||
<div className="grid grid-cols-[1.5fr_1.1fr_1.35fr_1.15fr_1.45fr_1.05fr]">
|
||||
<LabelCell>企划产品线</LabelCell>
|
||||
<InputCell
|
||||
value={form.planningProductLine}
|
||||
onChange={(event) =>
|
||||
onChange("planningProductLine", event.target.value)
|
||||
}
|
||||
listId="planning-product-line-options"
|
||||
options={planningProductLineOptions}
|
||||
/>
|
||||
<LabelCell>产品线</LabelCell>
|
||||
<InputCell
|
||||
value={form.productLine}
|
||||
onChange={(event) => onChange("productLine", event.target.value)}
|
||||
listId="product-line-options"
|
||||
options={productLineOptions}
|
||||
/>
|
||||
<LabelCell>是否年度规划内</LabelCell>
|
||||
<InputCell
|
||||
value={form.annualPlanStatus}
|
||||
onChange={(event) =>
|
||||
onChange("annualPlanStatus", event.target.value)
|
||||
}
|
||||
listId="annual-plan-status-options"
|
||||
options={annualPlanStatusOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1.5fr_1.1fr_1.35fr_1.15fr_1.45fr_1.05fr]">
|
||||
<LabelCell>开发方式</LabelCell>
|
||||
<InputCell
|
||||
value={form.developmentMode}
|
||||
onChange={(event) =>
|
||||
onChange("developmentMode", event.target.value)
|
||||
}
|
||||
listId="development-mode-options"
|
||||
options={developmentModeOptions}
|
||||
/>
|
||||
<LabelCell>产品系列</LabelCell>
|
||||
<InputCell
|
||||
value={form.productSeries}
|
||||
onChange={(event) => onChange("productSeries", event.target.value)}
|
||||
/>
|
||||
<LabelCell>开发类别</LabelCell>
|
||||
<InputCell
|
||||
value={form.developmentCategory}
|
||||
onChange={(event) =>
|
||||
onChange("developmentCategory", event.target.value)
|
||||
}
|
||||
listId="development-category-options"
|
||||
options={developmentCategoryOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1.55fr_1.55fr_1fr_0.95fr_1.55fr_1.55fr_1.45fr_1.45fr]">
|
||||
<LabelCell>买采供应商名称</LabelCell>
|
||||
<InputCell
|
||||
value={form.supplierName}
|
||||
onChange={(event) => onChange("supplierName", event.target.value)}
|
||||
inputClassName={INPUT_LEFT_CLASS}
|
||||
/>
|
||||
<LabelCell>是否入仓</LabelCell>
|
||||
<InputCell
|
||||
value={form.warehousingStatus}
|
||||
onChange={(event) =>
|
||||
onChange("warehousingStatus", event.target.value)
|
||||
}
|
||||
listId="warehousing-status-options"
|
||||
options={warehousingStatusOptions}
|
||||
/>
|
||||
<LabelCell>需求上市时间</LabelCell>
|
||||
<InputCell
|
||||
value={form.launchDate}
|
||||
onChange={(event) => onChange("launchDate", event.target.value)}
|
||||
/>
|
||||
<LabelCell>是否活动款</LabelCell>
|
||||
<InputCell
|
||||
value={form.activityStyleStatus}
|
||||
onChange={(event) =>
|
||||
onChange("activityStyleStatus", event.target.value)
|
||||
}
|
||||
listId="activity-style-status-options"
|
||||
options={activityStyleStatusOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionBand title="企划背景介绍" />
|
||||
<div className="grid grid-cols-[190px_minmax(0,1fr)]">
|
||||
<LabelCell className="min-h-[200px] text-[22px]">图片</LabelCell>
|
||||
<ImageCell imageUrl={previewImage} />
|
||||
<LabelCell className="min-h-[200px] text-[18px]">
|
||||
产品需求分析
|
||||
</LabelCell>
|
||||
<TextareaCell
|
||||
value={form.productDemandAnalysis}
|
||||
onChange={(event) =>
|
||||
onChange("productDemandAnalysis", event.target.value)
|
||||
}
|
||||
className="min-h-[200px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionBand title="企划方案" />
|
||||
<div className="grid grid-cols-[1.1fr_1.8fr_1.5fr_1.6fr_1.7fr_2fr]">
|
||||
<LabelCell>货号</LabelCell>
|
||||
<LabelCell>零售价需求</LabelCell>
|
||||
<LabelCell>采购价需求</LabelCell>
|
||||
<LabelCell>尺寸要求</LabelCell>
|
||||
<LabelCell>材质/颜色</LabelCell>
|
||||
<LabelCell>特殊说明(是否老款迭代)</LabelCell>
|
||||
{displayRows.map((displayRow, index) => (
|
||||
<Fragment key={`plan-${displayRow.itemCode || index}`}>
|
||||
<PlanSectionRow
|
||||
displayRow={displayRow}
|
||||
productHasSkuRows={productHasSkuRows}
|
||||
legacyIterationNote={form.legacyIterationNote}
|
||||
onChange={onChange}
|
||||
onLineItemChange={onLineItemChange}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SectionBand title="推广计划" />
|
||||
<div className="grid grid-cols-[1.35fr_1.35fr_1.2fr_0.95fr_0.9fr_0.9fr_0.9fr_1fr_1.25fr_1.4fr] auto-rows-[58px]">
|
||||
<LabelCell className="row-span-2">系列</LabelCell>
|
||||
<LabelCell className="row-span-2">货号</LabelCell>
|
||||
<LabelCell className="col-span-2">上市计划</LabelCell>
|
||||
<LabelCell className="col-span-6">上市季度销量</LabelCell>
|
||||
|
||||
<LabelCell className="whitespace-nowrap">全年销售总量</LabelCell>
|
||||
<LabelCell>渠道</LabelCell>
|
||||
<LabelCell>第1个月</LabelCell>
|
||||
<LabelCell>第2个月</LabelCell>
|
||||
<LabelCell>第3个月</LabelCell>
|
||||
<LabelCell>第4-6月</LabelCell>
|
||||
<LabelCell>第7-9月</LabelCell>
|
||||
<LabelCell>第10-12月</LabelCell>
|
||||
|
||||
{displayRows.map((displayRow, index) => (
|
||||
<Fragment key={`promotion-${displayRow.itemCode || index}`}>
|
||||
<PromotionSectionRow
|
||||
displayRow={displayRow}
|
||||
onLineItemChange={onLineItemChange}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SectionBand title="会签" />
|
||||
<div className="grid grid-cols-[1.15fr_2.2fr_1fr_2.2fr_1fr_2.25fr]">
|
||||
<LabelCell>产品经理</LabelCell>
|
||||
<InputCell
|
||||
value={form.productManager}
|
||||
onChange={(event) => onChange("productManager", event.target.value)}
|
||||
listId="product-manager-options"
|
||||
options={productManagerOptions}
|
||||
/>
|
||||
<LabelCell>企划负责人</LabelCell>
|
||||
<InputCell
|
||||
value={form.planningOwner}
|
||||
onChange={(event) => onChange("planningOwner", event.target.value)}
|
||||
/>
|
||||
<LabelCell>采购负责人</LabelCell>
|
||||
<InputCell
|
||||
value={form.purchaseOwner}
|
||||
onChange={(event) => onChange("purchaseOwner", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1.15fr_5.45fr]">
|
||||
<LabelCell>渠道</LabelCell>
|
||||
<ReadonlyValueCell
|
||||
value={channelSummary}
|
||||
emptyPlaceholder=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
import {
|
||||
getNonIntegratedProductDisplayName,
|
||||
normalizeNonIntegratedBrand,
|
||||
type NonIntegratedProductRecord,
|
||||
} from "@/app/intake/non-integrated/nonIntegratedStorage";
|
||||
import {
|
||||
getProductDevelopmentKind,
|
||||
isJstSkuImportedStockProduct,
|
||||
readNonIntegratedProductCategory,
|
||||
} from "@/app/intake/product-development/nonIntegratedProductIdentity";
|
||||
import { getNonIntegratedListPeople } from "@/app/intake/product-development/nonIntegratedProductListLogic";
|
||||
import {
|
||||
getBrandFilingBrandLabel,
|
||||
type BrandFilingBrand,
|
||||
} from "@/app/intake/brand-filing/brandFilingConfig";
|
||||
import { getBrandFilingPlanningRecord } from "@/app/intake/brand-filing/brandFilingProductData";
|
||||
import type { BrandFilingPlanningRecord } from "@/app/intake/brand-filing/brandFilingTypes";
|
||||
|
||||
export type BrandFilingRow = {
|
||||
id: string;
|
||||
product: NonIntegratedProductRecord;
|
||||
planningRecord?: BrandFilingPlanningRecord;
|
||||
sequenceNumber?: number;
|
||||
planningCompleted: boolean;
|
||||
displayName: string;
|
||||
spu: string;
|
||||
productName: string;
|
||||
category: string;
|
||||
appearanceDesigner: string;
|
||||
structureDesigner: string;
|
||||
previewImage: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function getBrandFilingNormalizedBrand(brand: BrandFilingBrand) {
|
||||
return normalizeNonIntegratedBrand(getBrandFilingBrandLabel(brand));
|
||||
}
|
||||
|
||||
function getProductPrimaryBrands(product: NonIntegratedProductRecord) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[product.brand, product.openingReference?.brand]
|
||||
.map((value) => normalizeNonIntegratedBrand(value))
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getBrandFilingProducts(
|
||||
products: NonIntegratedProductRecord[],
|
||||
brand: BrandFilingBrand,
|
||||
) {
|
||||
const normalizedBrand = getBrandFilingNormalizedBrand(brand);
|
||||
|
||||
return products.filter((product) => {
|
||||
if (getProductDevelopmentKind(product) !== "product") {
|
||||
return false;
|
||||
}
|
||||
if (isJstSkuImportedStockProduct(product)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getProductPrimaryBrands(product).includes(normalizedBrand);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildBrandFilingRows(
|
||||
products: NonIntegratedProductRecord[],
|
||||
brand: BrandFilingBrand,
|
||||
): BrandFilingRow[] {
|
||||
return getBrandFilingProducts(products, brand).map((product) => {
|
||||
const people = getNonIntegratedListPeople(product);
|
||||
const previewImage =
|
||||
product.openingReference?.productImage?.trim() || product.previewImage;
|
||||
const planningRecord = getBrandFilingPlanningRecord(product, brand);
|
||||
|
||||
return {
|
||||
id: product.id,
|
||||
product,
|
||||
planningRecord,
|
||||
sequenceNumber: planningRecord?.sequenceNumber,
|
||||
planningCompleted: planningRecord?.completed ?? false,
|
||||
displayName: getNonIntegratedProductDisplayName(product),
|
||||
spu: product.spu.trim() || "-",
|
||||
productName:
|
||||
product.openingReference?.productName?.trim() ||
|
||||
getNonIntegratedProductDisplayName(product),
|
||||
category: readNonIntegratedProductCategory(product) || "-",
|
||||
appearanceDesigner: people.appearanceDesigner.trim() || "-",
|
||||
structureDesigner: people.structureDesigner.trim() || "-",
|
||||
previewImage,
|
||||
updatedAt: product.updatedAt ?? "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertBrandFilingProductInList(
|
||||
products: NonIntegratedProductRecord[],
|
||||
nextProduct: NonIntegratedProductRecord,
|
||||
) {
|
||||
const existingIndex = products.findIndex(
|
||||
(product) => product.id === nextProduct.id,
|
||||
);
|
||||
|
||||
if (existingIndex < 0) {
|
||||
return [nextProduct, ...products];
|
||||
}
|
||||
|
||||
return products.map((product) =>
|
||||
product.id === nextProduct.id ? nextProduct : product,
|
||||
);
|
||||
}
|
||||
|
||||
export function getBrandFilingProductsByIds(
|
||||
products: NonIntegratedProductRecord[],
|
||||
productIds: string[],
|
||||
) {
|
||||
const productMap = new Map(products.map((product) => [product.id, product]));
|
||||
|
||||
return productIds
|
||||
.map((productId) => productMap.get(productId))
|
||||
.filter((product): product is NonIntegratedProductRecord => Boolean(product));
|
||||
}
|
||||
|
||||
export function formatBrandFilingSequenceNumber(value: number | undefined) {
|
||||
return value && value > 0 ? String(value) : "-";
|
||||
}
|
||||
|
||||
export function getBrandFilingPlanningStatusLabel(completed: boolean) {
|
||||
return completed ? "已完成" : "待填写";
|
||||
}
|
||||
|
||||
export function formatBrandFilingUpdatedAt(value: string) {
|
||||
const trimmedValue = value.trim();
|
||||
if (!trimmedValue) return "-";
|
||||
|
||||
const date = new Date(trimmedValue);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return trimmedValue;
|
||||
}
|
||||
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
|
||||
date.getDate(),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
@ -0,0 +1,603 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { ImagePreviewDialog } from "@/app/intake/components/IntakeSharedPanels";
|
||||
import {
|
||||
BrandFilingPlanningTableDialog,
|
||||
} from "@/app/intake/brand-filing/BrandFilingPlanningTableDialog";
|
||||
import {
|
||||
BrandFilingProductTableToolbar,
|
||||
} from "@/app/intake/brand-filing/BrandFilingProductTableToolbar";
|
||||
import { downloadBrandFilingPlanningTables } from "@/app/intake/brand-filing/brandFilingPlanningTableDownload";
|
||||
import {
|
||||
ensureBrandFilingPlanningRecord,
|
||||
getBrandFilingPlanningRecord,
|
||||
upsertBrandFilingPlanningRecord,
|
||||
} from "@/app/intake/brand-filing/brandFilingProductData";
|
||||
import {
|
||||
buildBrandFilingRows,
|
||||
formatBrandFilingSequenceNumber,
|
||||
formatBrandFilingUpdatedAt,
|
||||
getBrandFilingProductsByIds,
|
||||
getBrandFilingPlanningStatusLabel,
|
||||
upsertBrandFilingProductInList,
|
||||
type BrandFilingRow,
|
||||
} from "@/app/intake/brand-filing/BrandFilingProductHelpers";
|
||||
import {
|
||||
getBrandFilingBrandLabel,
|
||||
type BrandFilingBrand,
|
||||
} from "@/app/intake/brand-filing/brandFilingConfig";
|
||||
import type {
|
||||
BrandFilingPlanningForm,
|
||||
BrandFilingPlanningLineItem,
|
||||
} from "@/app/intake/brand-filing/brandFilingTypes";
|
||||
import type { BrandFilingPlanningTableRecordInput } from "@/app/intake/brand-filing/brandFilingPlanningTableRequest";
|
||||
import { saveSharedNonIntegratedProduct } from "@/app/intake/non-integrated/core/nonIntegratedApi";
|
||||
import {
|
||||
NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY,
|
||||
type NonIntegratedProductRecord,
|
||||
} from "@/app/intake/non-integrated/nonIntegratedStorage";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { ToastContainer, useToast } from "@/components/ui/Toast";
|
||||
|
||||
const HEADER_CELL_CLASS =
|
||||
"bg-[var(--obs-shell-soft-bg)] px-4 py-3 text-left text-xs font-medium whitespace-nowrap text-[var(--obs-shell-muted)]";
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const TEXT = {
|
||||
productCountUnit: "个产品",
|
||||
rowUnit: "条",
|
||||
index: "序号",
|
||||
planningStatus: "企划表状态",
|
||||
preview: "预览图",
|
||||
name: "名称",
|
||||
category: "产品类别",
|
||||
appearanceDesigner: "外观设计师",
|
||||
structureDesigner: "结构设计师",
|
||||
updatedAt: "更新时间",
|
||||
empty: "暂无数据",
|
||||
noPreview: "暂无",
|
||||
previous: "上一页",
|
||||
next: "下一页",
|
||||
page: "页",
|
||||
perPage: "每页",
|
||||
} as const;
|
||||
|
||||
function compareBrandFilingRows(
|
||||
left: BrandFilingRow & { sourceIndex: number },
|
||||
right: BrandFilingRow & { sourceIndex: number },
|
||||
) {
|
||||
const leftSequence = left.sequenceNumber ?? 0;
|
||||
const rightSequence = right.sequenceNumber ?? 0;
|
||||
|
||||
if (leftSequence !== rightSequence) {
|
||||
return rightSequence - leftSequence;
|
||||
}
|
||||
|
||||
const leftTime = Date.parse(left.updatedAt);
|
||||
const rightTime = Date.parse(right.updatedAt);
|
||||
const safeLeftTime = Number.isNaN(leftTime) ? -Infinity : leftTime;
|
||||
const safeRightTime = Number.isNaN(rightTime) ? -Infinity : rightTime;
|
||||
if (safeLeftTime !== safeRightTime) {
|
||||
return safeRightTime - safeLeftTime;
|
||||
}
|
||||
|
||||
return right.sourceIndex - left.sourceIndex;
|
||||
}
|
||||
|
||||
export function BrandFilingProductTable({
|
||||
brand,
|
||||
products,
|
||||
onProductSaved,
|
||||
}: {
|
||||
brand: BrandFilingBrand;
|
||||
products: NonIntegratedProductRecord[];
|
||||
onProductSaved: (product: NonIntegratedProductRecord) => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [previewImage, setPreviewImage] = useState<{
|
||||
src: string;
|
||||
alt: string;
|
||||
} | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageJumpInput, setPageJumpInput] = useState("1");
|
||||
const [isPageJumpEditing, setIsPageJumpEditing] = useState(false);
|
||||
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
|
||||
const [activePlanningProductId, setActivePlanningProductId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isSavingPlanningTable, setIsSavingPlanningTable] = useState(false);
|
||||
const [isDownloadingPlanningTable, setIsDownloadingPlanningTable] =
|
||||
useState(false);
|
||||
const selectAllRef = useRef<HTMLInputElement | null>(null);
|
||||
const [savedProductOverrides, setSavedProductOverrides] = useState<
|
||||
Record<string, NonIntegratedProductRecord>
|
||||
>({});
|
||||
const tableProductsRef = useRef(products);
|
||||
|
||||
const tableProducts = useMemo(() => {
|
||||
const sourceProductIds = new Set(products.map((product) => product.id));
|
||||
const mergedProducts = products.map((product) => {
|
||||
const override = savedProductOverrides[product.id];
|
||||
if (!override) return product;
|
||||
|
||||
return (product.updatedAt ?? "") >= (override.updatedAt ?? "")
|
||||
? product
|
||||
: override;
|
||||
});
|
||||
const detachedOverrides = Object.values(savedProductOverrides).filter(
|
||||
(product) => !sourceProductIds.has(product.id),
|
||||
);
|
||||
const nextProducts =
|
||||
detachedOverrides.length > 0
|
||||
? [...detachedOverrides, ...mergedProducts]
|
||||
: mergedProducts;
|
||||
|
||||
return nextProducts;
|
||||
}, [products, savedProductOverrides]);
|
||||
|
||||
useEffect(() => {
|
||||
tableProductsRef.current = tableProducts;
|
||||
}, [tableProducts]);
|
||||
|
||||
const applySavedProduct = useCallback(
|
||||
(savedProduct: NonIntegratedProductRecord) => {
|
||||
tableProductsRef.current = upsertBrandFilingProductInList(
|
||||
tableProductsRef.current,
|
||||
savedProduct,
|
||||
);
|
||||
setSavedProductOverrides((current) => ({
|
||||
...current,
|
||||
[savedProduct.id]: savedProduct,
|
||||
}));
|
||||
onProductSaved(savedProduct);
|
||||
},
|
||||
[onProductSaved],
|
||||
);
|
||||
|
||||
const rows = useMemo(
|
||||
() => buildBrandFilingRows(tableProducts, brand),
|
||||
[brand, tableProducts],
|
||||
);
|
||||
const orderedRows = useMemo(
|
||||
() =>
|
||||
rows
|
||||
.map((row, sourceIndex) => ({ ...row, sourceIndex }))
|
||||
.sort(compareBrandFilingRows)
|
||||
.map(({ sourceIndex: _sourceIndex, ...row }) => row),
|
||||
[rows],
|
||||
);
|
||||
const validRowIdSet = useMemo(
|
||||
() => new Set(orderedRows.map((row) => row.id)),
|
||||
[orderedRows],
|
||||
);
|
||||
const rowMap = useMemo(
|
||||
() => new Map(orderedRows.map((row) => [row.id, row])),
|
||||
[orderedRows],
|
||||
);
|
||||
const totalRows = orderedRows.length;
|
||||
const completedRows = orderedRows.filter((row) => row.planningCompleted).length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages);
|
||||
const pageStartIndex = (safeCurrentPage - 1) * PAGE_SIZE;
|
||||
const pagedRows = orderedRows.slice(pageStartIndex, pageStartIndex + PAGE_SIZE);
|
||||
const pageRowIdSet = useMemo(
|
||||
() => new Set(pagedRows.map((row) => row.id)),
|
||||
[pagedRows],
|
||||
);
|
||||
const normalizedSelectedRowIds = useMemo(
|
||||
() => selectedRowIds.filter((id) => validRowIdSet.has(id)),
|
||||
[selectedRowIds, validRowIdSet],
|
||||
);
|
||||
const selectedRowSet = useMemo(
|
||||
() => new Set(normalizedSelectedRowIds),
|
||||
[normalizedSelectedRowIds],
|
||||
);
|
||||
const visibleSelectedRowIds = useMemo(
|
||||
() => normalizedSelectedRowIds.filter((id) => pageRowIdSet.has(id)),
|
||||
[normalizedSelectedRowIds, pageRowIdSet],
|
||||
);
|
||||
const selectedRows = useMemo(
|
||||
() => orderedRows.filter((row) => selectedRowSet.has(row.id)),
|
||||
[orderedRows, selectedRowSet],
|
||||
);
|
||||
const brandLabel = getBrandFilingBrandLabel(brand);
|
||||
const pageJumpValue = isPageJumpEditing
|
||||
? pageJumpInput
|
||||
: String(safeCurrentPage);
|
||||
const allSelected =
|
||||
pagedRows.length > 0 && visibleSelectedRowIds.length === pagedRows.length;
|
||||
const partiallySelected =
|
||||
visibleSelectedRowIds.length > 0 &&
|
||||
visibleSelectedRowIds.length < pagedRows.length;
|
||||
const activePlanningRow = activePlanningProductId
|
||||
? rowMap.get(activePlanningProductId) ?? null
|
||||
: null;
|
||||
const busy = isSavingPlanningTable || isDownloadingPlanningTable;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectAllRef.current) {
|
||||
selectAllRef.current.indeterminate = partiallySelected;
|
||||
}
|
||||
}, [partiallySelected]);
|
||||
|
||||
const toggleAllRows = () => {
|
||||
setSelectedRowIds((current) => {
|
||||
const nextBase = current.filter((id) => validRowIdSet.has(id));
|
||||
if (allSelected) {
|
||||
return nextBase.filter((id) => !pageRowIdSet.has(id));
|
||||
}
|
||||
|
||||
const nextIds = new Set(nextBase);
|
||||
pagedRows.forEach((row) => nextIds.add(row.id));
|
||||
return Array.from(nextIds);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRow = (rowId: string) => {
|
||||
setSelectedRowIds((current) =>
|
||||
current.includes(rowId)
|
||||
? current.filter((id) => id !== rowId)
|
||||
: [...current.filter((id) => validRowIdSet.has(id)), rowId],
|
||||
);
|
||||
};
|
||||
|
||||
const 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 handleOpenPlanningTable = () => {
|
||||
if (selectedRows.length !== 1) {
|
||||
toast.warning("请选择 1 条产品后再填写企划表");
|
||||
return;
|
||||
}
|
||||
|
||||
setActivePlanningProductId(selectedRows[0]!.id);
|
||||
};
|
||||
|
||||
const handleSavePlanningTable = async (
|
||||
form: BrandFilingPlanningForm,
|
||||
lineItems: BrandFilingPlanningLineItem[],
|
||||
completed: boolean,
|
||||
) => {
|
||||
if (!activePlanningRow) return;
|
||||
|
||||
setIsSavingPlanningTable(true);
|
||||
try {
|
||||
const { product: nextProduct } = upsertBrandFilingPlanningRecord({
|
||||
product: activePlanningRow.product,
|
||||
products: tableProductsRef.current,
|
||||
brand,
|
||||
form,
|
||||
lineItems,
|
||||
completed,
|
||||
});
|
||||
const savedProduct = await saveSharedNonIntegratedProduct(nextProduct, {
|
||||
workspaceCategory: NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY,
|
||||
});
|
||||
applySavedProduct(savedProduct);
|
||||
toast.success(completed ? "企划表已保存并确认" : "企划表已保存");
|
||||
setActivePlanningProductId(null);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "企划表保存失败,请稍后重试",
|
||||
);
|
||||
} finally {
|
||||
setIsSavingPlanningTable(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadPlanningTable = async () => {
|
||||
const selectedProducts = getBrandFilingProductsByIds(
|
||||
tableProductsRef.current,
|
||||
normalizedSelectedRowIds,
|
||||
);
|
||||
|
||||
if (selectedProducts.length === 0) {
|
||||
toast.warning("请先选择要下载的产品");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDownloadingPlanningTable(true);
|
||||
try {
|
||||
let workingProducts = tableProductsRef.current;
|
||||
const productsNeedingRecord = selectedProducts
|
||||
.filter((product) => !getBrandFilingPlanningRecord(product, brand))
|
||||
.slice()
|
||||
.reverse();
|
||||
|
||||
for (const product of productsNeedingRecord) {
|
||||
const { product: nextProduct } = ensureBrandFilingPlanningRecord({
|
||||
product,
|
||||
products: workingProducts,
|
||||
brand,
|
||||
});
|
||||
const savedProduct = await saveSharedNonIntegratedProduct(nextProduct, {
|
||||
workspaceCategory: NON_INTEGRATED_PLANNING_LAYOUT_WORKSPACE_CATEGORY,
|
||||
});
|
||||
workingProducts = upsertBrandFilingProductInList(
|
||||
workingProducts,
|
||||
savedProduct,
|
||||
);
|
||||
applySavedProduct(savedProduct);
|
||||
}
|
||||
|
||||
const latestSelectedProducts = getBrandFilingProductsByIds(
|
||||
workingProducts,
|
||||
normalizedSelectedRowIds,
|
||||
);
|
||||
const planningRecords: BrandFilingPlanningTableRecordInput[] =
|
||||
latestSelectedProducts
|
||||
.map((product) => {
|
||||
const planningTable = getBrandFilingPlanningRecord(product, brand);
|
||||
if (!planningTable) return null;
|
||||
|
||||
return {
|
||||
productId: product.id,
|
||||
planningTable,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
record,
|
||||
): record is BrandFilingPlanningTableRecordInput => Boolean(record),
|
||||
);
|
||||
|
||||
await downloadBrandFilingPlanningTables({
|
||||
brand,
|
||||
productIds: selectedProducts.map((product) => product.id),
|
||||
planningRecords,
|
||||
});
|
||||
toast.success(
|
||||
selectedRows.length > 1 ? "企划表压缩包已开始下载" : "企划表已开始下载",
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "企划表下载失败,请稍后重试",
|
||||
);
|
||||
} finally {
|
||||
setIsDownloadingPlanningTable(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BrandFilingProductTableToolbar
|
||||
brandLabel={brandLabel}
|
||||
totalRows={totalRows}
|
||||
completedRows={completedRows}
|
||||
selectedCount={selectedRows.length}
|
||||
busy={busy}
|
||||
onOpenPlanningTable={handleOpenPlanningTable}
|
||||
onDownloadPlanningTable={handleDownloadPlanningTable}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="relative flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto pb-16">
|
||||
<table className="w-full min-w-[1220px] border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--obs-shell-border)]">
|
||||
<th className={`${HEADER_CELL_CLASS} w-[56px]`}>
|
||||
<input
|
||||
ref={selectAllRef}
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={toggleAllRows}
|
||||
aria-label="Select all"
|
||||
className="h-4 w-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-300"
|
||||
/>
|
||||
</th>
|
||||
<th className={`${HEADER_CELL_CLASS} w-[80px]`}>{TEXT.index}</th>
|
||||
<th className={`${HEADER_CELL_CLASS} w-[120px]`}>
|
||||
{TEXT.planningStatus}
|
||||
</th>
|
||||
<th className={`${HEADER_CELL_CLASS} w-[112px]`}>{TEXT.preview}</th>
|
||||
<th className={`${HEADER_CELL_CLASS} min-w-[156px]`}>SPU</th>
|
||||
<th className={`${HEADER_CELL_CLASS} min-w-[220px]`}>{TEXT.name}</th>
|
||||
<th className={`${HEADER_CELL_CLASS} min-w-[150px]`}>
|
||||
{TEXT.category}
|
||||
</th>
|
||||
<th className={`${HEADER_CELL_CLASS} min-w-[120px]`}>
|
||||
{TEXT.appearanceDesigner}
|
||||
</th>
|
||||
<th className={`${HEADER_CELL_CLASS} min-w-[120px]`}>
|
||||
{TEXT.structureDesigner}
|
||||
</th>
|
||||
<th className={`${HEADER_CELL_CLASS} min-w-[120px]`}>
|
||||
{TEXT.updatedAt}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedRows.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={10}
|
||||
className="px-4 py-12 text-center text-sm text-[var(--obs-shell-muted)]"
|
||||
>
|
||||
{TEXT.empty}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pagedRows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="border-b border-[var(--obs-shell-border)] last:border-b-0"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRowSet.has(row.id)}
|
||||
onChange={() => toggleRow(row.id)}
|
||||
aria-label={`Select ${row.productName}`}
|
||||
className="h-4 w-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-300"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-[var(--obs-shell-title)]">
|
||||
{formatBrandFilingSequenceNumber(row.sequenceNumber)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs ${
|
||||
row.planningCompleted
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "bg-[var(--obs-shell-soft-bg)] text-[var(--obs-shell-muted)]"
|
||||
}`}
|
||||
>
|
||||
{row.planningCompleted ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : null}
|
||||
{getBrandFilingPlanningStatusLabel(row.planningCompleted)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{row.previewImage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPreviewImage({
|
||||
src: row.previewImage,
|
||||
alt: `${row.displayName} ${TEXT.preview}`,
|
||||
})
|
||||
}
|
||||
className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-[10px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-soft-bg)]"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={row.previewImage}
|
||||
alt={`${row.displayName} ${TEXT.preview}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-[10px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-soft-bg)] text-[10px] text-[var(--obs-shell-muted)]">
|
||||
{TEXT.noPreview}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--obs-shell-title)]">
|
||||
{row.spu}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--obs-shell-title)]">
|
||||
{row.productName}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--obs-shell-title)]">
|
||||
{row.category}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--obs-shell-title)]">
|
||||
{row.appearanceDesigner}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--obs-shell-title)]">
|
||||
{row.structureDesigner}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--obs-shell-title)]">
|
||||
{formatBrandFilingUpdatedAt(row.updatedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalRows > 0 ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-5 z-30 flex justify-center px-4">
|
||||
<div className="pointer-events-auto flex max-w-full items-center gap-2 rounded-full border border-apple-separator bg-white/92 px-2 py-1.5 text-xs text-apple-label-secondary shadow-[0_12px_32px_rgba(15,23,42,0.12)] backdrop-blur-xl">
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
className="!h-7 !min-h-7 rounded-full px-3 text-xs"
|
||||
onClick={() =>
|
||||
setCurrentPage((page) => Math.max(1, page - 1))
|
||||
}
|
||||
disabled={safeCurrentPage === 1}
|
||||
>
|
||||
{TEXT.previous}
|
||||
</Button>
|
||||
<div className="flex min-w-0 items-center justify-center gap-1.5 whitespace-nowrap text-center">
|
||||
<span className="text-apple-label-tertiary">
|
||||
{totalRows} {TEXT.rowUnit}
|
||||
</span>
|
||||
<input
|
||||
aria-label="Jump to page"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={pageJumpValue}
|
||||
onFocus={() => {
|
||||
setIsPageJumpEditing(true);
|
||||
setPageJumpInput(String(safeCurrentPage));
|
||||
}}
|
||||
onChange={(event) =>
|
||||
setPageJumpInput(event.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
onBlur={() => {
|
||||
commitPageJump();
|
||||
setIsPageJumpEditing(false);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitPageJump();
|
||||
setIsPageJumpEditing(false);
|
||||
}
|
||||
}}
|
||||
className="h-7 w-11 rounded-full border border-apple-separator bg-apple-bg-primary px-2 text-center text-xs text-apple-label-primary outline-none focus:border-zinc-400"
|
||||
/>
|
||||
<span>
|
||||
/ {totalPages} {TEXT.page}
|
||||
</span>
|
||||
<span className="hidden text-apple-label-tertiary sm:inline">
|
||||
{TEXT.perPage} {PAGE_SIZE}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
className="!h-7 !min-h-7 rounded-full px-3 text-xs"
|
||||
onClick={() =>
|
||||
setCurrentPage((page) => Math.min(totalPages, page + 1))
|
||||
}
|
||||
disabled={safeCurrentPage === totalPages}
|
||||
>
|
||||
{TEXT.next}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrandFilingPlanningTableDialog
|
||||
isOpen={Boolean(activePlanningRow)}
|
||||
brand={brand}
|
||||
product={activePlanningRow?.product ?? null}
|
||||
initialRecord={activePlanningRow?.planningRecord}
|
||||
saving={isSavingPlanningTable}
|
||||
onClose={() => setActivePlanningProductId(null)}
|
||||
onSave={handleSavePlanningTable}
|
||||
/>
|
||||
|
||||
{previewImage ? (
|
||||
<ImagePreviewDialog
|
||||
src={previewImage.src}
|
||||
alt={previewImage.alt}
|
||||
onClose={() => setPreviewImage(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ToastContainer
|
||||
toasts={toast.toasts}
|
||||
onClose={toast.removeToast}
|
||||
position="top"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Download, SquarePen } from "lucide-react";
|
||||
|
||||
function ToolbarButton({
|
||||
disabled,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
primary = false,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
primary?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`inline-flex h-8 items-center gap-1.5 rounded-[6px] border px-3 text-[12px] font-apple-semibold transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
primary
|
||||
? "border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] text-[var(--obs-shell-action-text)] hover:bg-[var(--obs-shell-action-hover-bg)]"
|
||||
: "border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-control-text)] hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrandFilingProductTableToolbar({
|
||||
brandLabel,
|
||||
totalRows,
|
||||
completedRows,
|
||||
selectedCount,
|
||||
busy,
|
||||
onOpenPlanningTable,
|
||||
onDownloadPlanningTable,
|
||||
}: {
|
||||
brandLabel: string;
|
||||
totalRows: number;
|
||||
completedRows: number;
|
||||
selectedCount: number;
|
||||
busy: boolean;
|
||||
onOpenPlanningTable: () => void;
|
||||
onDownloadPlanningTable: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-[var(--obs-shell-border)] px-5 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--obs-shell-title)]">
|
||||
{brandLabel}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-[var(--obs-shell-muted)]">
|
||||
<span>{totalRows} 个产品</span>
|
||||
<span>{completedRows} 个已完成</span>
|
||||
<span>{selectedCount} 个已选中</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<ToolbarButton
|
||||
disabled={busy || selectedCount !== 1}
|
||||
icon={<SquarePen className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
label="填写企划表"
|
||||
onClick={onOpenPlanningTable}
|
||||
/>
|
||||
<ToolbarButton
|
||||
disabled={busy || selectedCount === 0}
|
||||
icon={<Download className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
label="下载企划表"
|
||||
onClick={onDownloadPlanningTable}
|
||||
primary
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheet } from "lucide-react";
|
||||
import { AccordionSection } from "@/components/platform/AccordionSection";
|
||||
import {
|
||||
FORMAL_SIDEBAR_SECTION_ICON_CLASS,
|
||||
FormalSidebarIcon,
|
||||
} from "@/app/intake/components/FormalSidebarIcon";
|
||||
import type { IntakeSection } from "@/app/intake/intakeWorkspaceTypes";
|
||||
import {
|
||||
BRAND_FILING_GROUP_LABEL,
|
||||
BRAND_FILING_OPTIONS,
|
||||
type BrandFilingBrand,
|
||||
} from "@/app/intake/brand-filing/brandFilingConfig";
|
||||
|
||||
export function BrandFilingWorkbenchSection({
|
||||
activeSection,
|
||||
activeBrand,
|
||||
canViewBrandFiling,
|
||||
onToggle,
|
||||
onOpenBrandFilingWorkspace,
|
||||
}: {
|
||||
activeSection: IntakeSection;
|
||||
activeBrand: BrandFilingBrand | null;
|
||||
canViewBrandFiling: boolean;
|
||||
onToggle: (section: Exclude<IntakeSection, null>) => void;
|
||||
onOpenBrandFilingWorkspace: (brand: BrandFilingBrand) => void;
|
||||
}) {
|
||||
if (!canViewBrandFiling) return null;
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
title={BRAND_FILING_GROUP_LABEL}
|
||||
compact
|
||||
icon={
|
||||
<FormalSidebarIcon
|
||||
icon={FileSpreadsheet}
|
||||
className={FORMAL_SIDEBAR_SECTION_ICON_CLASS}
|
||||
/>
|
||||
}
|
||||
expanded={activeSection === "brandFiling"}
|
||||
onToggle={() => onToggle("brandFiling")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{BRAND_FILING_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => onOpenBrandFilingWorkspace(option.key)}
|
||||
className={`flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
|
||||
activeBrand === option.key
|
||||
? "border-apple-blue bg-apple-blue/10 text-apple-blue"
|
||||
: "border-apple-separator bg-apple-bg-secondary text-apple-label-secondary hover:border-apple-blue/40 hover:bg-apple-blue/5"
|
||||
}`}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function BrandFilingWorkbenchSurface({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-[8px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] text-[var(--obs-shell-title)] shadow-[0_1px_2px_rgba(17,19,21,0.04)]">
|
||||
<div className="min-h-0 flex-1 p-4">
|
||||
<div className="flex h-full min-h-[420px] flex-col overflow-hidden rounded-[10px] border border-[var(--obs-shell-border)] bg-white">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
|
||||
import { BrandFilingProductTable } from "@/app/intake/brand-filing/BrandFilingProductTable";
|
||||
import { BrandFilingWorkbenchSurface } from "@/app/intake/brand-filing/BrandFilingWorkbenchSurface";
|
||||
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
|
||||
|
||||
export function BrandFilingWorkspace({
|
||||
brand,
|
||||
products,
|
||||
onProductSaved,
|
||||
}: {
|
||||
brand: BrandFilingBrand;
|
||||
products: NonIntegratedProductRecord[];
|
||||
onProductSaved: (product: NonIntegratedProductRecord) => void;
|
||||
}) {
|
||||
return (
|
||||
<BrandFilingWorkbenchSurface>
|
||||
<BrandFilingProductTable
|
||||
key={brand}
|
||||
brand={brand}
|
||||
products={products}
|
||||
onProductSaved={onProductSaved}
|
||||
/>
|
||||
</BrandFilingWorkbenchSurface>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
export type BrandFilingBrand = "gujia" | "suofeiya";
|
||||
|
||||
export const BRAND_FILING_GROUP_LABEL = "\u54c1\u724c\u62a5\u5907";
|
||||
|
||||
export const BRAND_FILING_OPTIONS = [
|
||||
{ key: "gujia", label: "\u987e\u5bb6" },
|
||||
{ key: "suofeiya", label: "\u7d22\u83f2\u4e9a" },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: BrandFilingBrand;
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
export function getBrandFilingBrandLabel(brand: BrandFilingBrand) {
|
||||
return (
|
||||
BRAND_FILING_OPTIONS.find((option) => option.key === brand)?.label ??
|
||||
"\u54c1\u724c"
|
||||
);
|
||||
}
|
||||
|
||||
export function getBrandFilingWorkspaceLabel(brand: BrandFilingBrand) {
|
||||
return `${getBrandFilingBrandLabel(brand)}\u54c1\u724c\u62a5\u5907`;
|
||||
}
|
||||
@ -0,0 +1,208 @@
|
||||
import { buildBrandFilingPlanningSkuRows } from "@/app/intake/brand-filing/brandFilingPlanningSkuRows";
|
||||
import {
|
||||
createEmptyBrandFilingPlanningLineItem,
|
||||
normalizeBrandFilingPlanningLineItem,
|
||||
type BrandFilingPlanningForm,
|
||||
type BrandFilingPlanningLineItem,
|
||||
type BrandFilingPlanningRecord,
|
||||
} from "@/app/intake/brand-filing/brandFilingTypes";
|
||||
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
|
||||
|
||||
export type BrandFilingPlanningDisplayRow = BrandFilingPlanningLineItem & {
|
||||
annualSalesTarget: string;
|
||||
};
|
||||
|
||||
function safeTrim(value: string | null | undefined) {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function normalizeMatchKey(value: string) {
|
||||
return safeTrim(value).toUpperCase();
|
||||
}
|
||||
|
||||
function pickNonEmptyText(...values: Array<string | null | undefined>) {
|
||||
for (const value of values) {
|
||||
const normalized = safeTrim(value);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseNumericValue(value: string) {
|
||||
const normalized = safeTrim(value).replace(/,/g, "");
|
||||
if (!normalized) return 0;
|
||||
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function calculateBrandFilingAnnualSalesTarget(
|
||||
lineItem: Pick<
|
||||
BrandFilingPlanningLineItem,
|
||||
| "month1Sales"
|
||||
| "month2Sales"
|
||||
| "month3Sales"
|
||||
| "month4To6Sales"
|
||||
| "month7To9Sales"
|
||||
| "month10To12Sales"
|
||||
>,
|
||||
) {
|
||||
const total =
|
||||
parseNumericValue(lineItem.month1Sales) +
|
||||
parseNumericValue(lineItem.month2Sales) +
|
||||
parseNumericValue(lineItem.month3Sales) +
|
||||
parseNumericValue(lineItem.month4To6Sales) +
|
||||
parseNumericValue(lineItem.month7To9Sales) +
|
||||
parseNumericValue(lineItem.month10To12Sales);
|
||||
|
||||
return total > 0 ? String(total) : "";
|
||||
}
|
||||
|
||||
export function buildBrandFilingLineItemChannelSummary(
|
||||
lineItems: BrandFilingPlanningLineItem[],
|
||||
) {
|
||||
return Array.from(
|
||||
new Set(lineItems.map((item) => safeTrim(item.channel)).filter(Boolean)),
|
||||
).join(" / ");
|
||||
}
|
||||
|
||||
export function buildBrandFilingPlanningLineItems(params: {
|
||||
product: NonIntegratedProductRecord;
|
||||
form: BrandFilingPlanningForm;
|
||||
currentRecord?: BrandFilingPlanningRecord;
|
||||
defaultPurchasePriceDemand?: (itemCode?: string) => string;
|
||||
defaultChannel?: string;
|
||||
}) {
|
||||
const {
|
||||
product,
|
||||
form,
|
||||
currentRecord,
|
||||
defaultPurchasePriceDemand,
|
||||
defaultChannel = "",
|
||||
} = params;
|
||||
const baseRows = buildBrandFilingPlanningSkuRows(product, {
|
||||
itemCode: form.itemCode,
|
||||
sizeRequirement: form.sizeRequirement,
|
||||
materialAndColor: form.materialAndColor,
|
||||
});
|
||||
const currentLineItems = currentRecord?.lineItems ?? [];
|
||||
|
||||
return baseRows.map((row) => {
|
||||
const matchedItem = currentLineItems.find(
|
||||
(item) => normalizeMatchKey(item.itemCode) === normalizeMatchKey(row.itemCode),
|
||||
);
|
||||
|
||||
return normalizeBrandFilingPlanningLineItem({
|
||||
...createEmptyBrandFilingPlanningLineItem(),
|
||||
itemCode: pickNonEmptyText(matchedItem?.itemCode, row.itemCode),
|
||||
sizeRequirement: pickNonEmptyText(
|
||||
matchedItem?.sizeRequirement,
|
||||
row.sizeRequirement,
|
||||
),
|
||||
materialAndColor: pickNonEmptyText(
|
||||
matchedItem?.materialAndColor,
|
||||
row.materialAndColor,
|
||||
),
|
||||
retailPriceDemand:
|
||||
pickNonEmptyText(matchedItem?.retailPriceDemand, form.retailPriceDemand),
|
||||
purchasePriceDemand:
|
||||
pickNonEmptyText(
|
||||
matchedItem?.purchasePriceDemand,
|
||||
defaultPurchasePriceDemand?.(row.itemCode),
|
||||
form.purchasePriceDemand,
|
||||
),
|
||||
seriesName: pickNonEmptyText(
|
||||
matchedItem?.seriesName,
|
||||
form.seriesName,
|
||||
form.productSeries,
|
||||
),
|
||||
channel: pickNonEmptyText(
|
||||
matchedItem?.channel,
|
||||
form.channel,
|
||||
defaultChannel,
|
||||
),
|
||||
month1Sales: pickNonEmptyText(matchedItem?.month1Sales, form.month1Sales),
|
||||
month2Sales: pickNonEmptyText(matchedItem?.month2Sales, form.month2Sales),
|
||||
month3Sales: pickNonEmptyText(matchedItem?.month3Sales, form.month3Sales),
|
||||
month4To6Sales:
|
||||
pickNonEmptyText(matchedItem?.month4To6Sales, form.month4To6Sales),
|
||||
month7To9Sales:
|
||||
pickNonEmptyText(matchedItem?.month7To9Sales, form.month7To9Sales),
|
||||
month10To12Sales:
|
||||
pickNonEmptyText(
|
||||
matchedItem?.month10To12Sales,
|
||||
form.month10To12Sales,
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function buildBrandFilingPlanningDisplayRows(params: {
|
||||
product: NonIntegratedProductRecord;
|
||||
form: BrandFilingPlanningForm;
|
||||
lineItems: BrandFilingPlanningLineItem[];
|
||||
}) {
|
||||
const { product, form, lineItems } = params;
|
||||
const baseRows = buildBrandFilingPlanningSkuRows(product, {
|
||||
itemCode: form.itemCode,
|
||||
sizeRequirement: form.sizeRequirement,
|
||||
materialAndColor: form.materialAndColor,
|
||||
});
|
||||
|
||||
return baseRows.map((row, index) => {
|
||||
const matchedItem =
|
||||
lineItems.find(
|
||||
(item) => normalizeMatchKey(item.itemCode) === normalizeMatchKey(row.itemCode),
|
||||
) ?? lineItems[index];
|
||||
|
||||
const normalizedItem = normalizeBrandFilingPlanningLineItem({
|
||||
...createEmptyBrandFilingPlanningLineItem(),
|
||||
...matchedItem,
|
||||
itemCode: pickNonEmptyText(matchedItem?.itemCode, row.itemCode),
|
||||
sizeRequirement: pickNonEmptyText(
|
||||
matchedItem?.sizeRequirement,
|
||||
row.sizeRequirement,
|
||||
form.sizeRequirement,
|
||||
),
|
||||
materialAndColor: pickNonEmptyText(
|
||||
matchedItem?.materialAndColor,
|
||||
row.materialAndColor,
|
||||
form.materialAndColor,
|
||||
),
|
||||
retailPriceDemand: pickNonEmptyText(
|
||||
matchedItem?.retailPriceDemand,
|
||||
form.retailPriceDemand,
|
||||
),
|
||||
purchasePriceDemand: pickNonEmptyText(
|
||||
matchedItem?.purchasePriceDemand,
|
||||
form.purchasePriceDemand,
|
||||
),
|
||||
seriesName: pickNonEmptyText(
|
||||
matchedItem?.seriesName,
|
||||
form.seriesName,
|
||||
form.productSeries,
|
||||
),
|
||||
channel: pickNonEmptyText(matchedItem?.channel, form.channel),
|
||||
month1Sales: pickNonEmptyText(matchedItem?.month1Sales, form.month1Sales),
|
||||
month2Sales: pickNonEmptyText(matchedItem?.month2Sales, form.month2Sales),
|
||||
month3Sales: pickNonEmptyText(matchedItem?.month3Sales, form.month3Sales),
|
||||
month4To6Sales: pickNonEmptyText(
|
||||
matchedItem?.month4To6Sales,
|
||||
form.month4To6Sales,
|
||||
),
|
||||
month7To9Sales: pickNonEmptyText(
|
||||
matchedItem?.month7To9Sales,
|
||||
form.month7To9Sales,
|
||||
),
|
||||
month10To12Sales: pickNonEmptyText(
|
||||
matchedItem?.month10To12Sales,
|
||||
form.month10To12Sales,
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
...normalizedItem,
|
||||
annualSalesTarget: calculateBrandFilingAnnualSalesTarget(normalizedItem),
|
||||
} satisfies BrandFilingPlanningDisplayRow;
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
import type {
|
||||
NonIntegratedProductRecord,
|
||||
NonIntegratedSkuItem,
|
||||
} from "@/app/intake/non-integrated/nonIntegratedStorage";
|
||||
|
||||
export type BrandFilingPlanningSkuRow = {
|
||||
itemCode: string;
|
||||
sizeRequirement: string;
|
||||
materialAndColor: string;
|
||||
};
|
||||
|
||||
function safeTrim(value: string | null | undefined) {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function collectUniqueValues(values: Array<string | null | undefined>) {
|
||||
return Array.from(new Set(values.map((value) => safeTrim(value)).filter(Boolean)));
|
||||
}
|
||||
|
||||
function buildSkuItemCode(skuItem: NonIntegratedSkuItem) {
|
||||
return (
|
||||
safeTrim(skuItem.skuCode) ||
|
||||
safeTrim(skuItem.barcode) ||
|
||||
safeTrim(skuItem.model) ||
|
||||
safeTrim(skuItem.specName)
|
||||
);
|
||||
}
|
||||
|
||||
function buildSkuMaterialAndColor(
|
||||
product: NonIntegratedProductRecord,
|
||||
skuItem: NonIntegratedSkuItem,
|
||||
fallbackValue: string,
|
||||
) {
|
||||
const material =
|
||||
safeTrim(skuItem.tagMaterial) ||
|
||||
safeTrim(skuItem.material) ||
|
||||
safeTrim(product.tagMaterial) ||
|
||||
safeTrim(product.openingReference?.material);
|
||||
const color = safeTrim(skuItem.color) || safeTrim(product.color);
|
||||
|
||||
return [material, color].filter(Boolean).join(" / ") || fallbackValue;
|
||||
}
|
||||
|
||||
export function resolveBrandFilingPlanningItemCode(
|
||||
product: NonIntegratedProductRecord,
|
||||
itemCode?: string,
|
||||
) {
|
||||
return (
|
||||
safeTrim(itemCode) ||
|
||||
safeTrim(product.productCode) ||
|
||||
safeTrim(product.spu) ||
|
||||
safeTrim(product.openingReference?.temporaryCode)
|
||||
);
|
||||
}
|
||||
|
||||
export function getBrandFilingMatchedSkuItemsByItemCode(
|
||||
product: NonIntegratedProductRecord,
|
||||
itemCode?: string,
|
||||
) {
|
||||
const normalizedItemCode = resolveBrandFilingPlanningItemCode(
|
||||
product,
|
||||
itemCode,
|
||||
).toUpperCase();
|
||||
if (!normalizedItemCode) return product.skuItems;
|
||||
|
||||
const matchedSkuItems = product.skuItems.filter((skuItem) => {
|
||||
const candidateValues = [
|
||||
buildSkuItemCode(skuItem),
|
||||
safeTrim(skuItem.specName),
|
||||
]
|
||||
.map((value) => value.toUpperCase())
|
||||
.filter(Boolean);
|
||||
|
||||
return candidateValues.some(
|
||||
(candidate) =>
|
||||
candidate === normalizedItemCode ||
|
||||
candidate.startsWith(normalizedItemCode) ||
|
||||
normalizedItemCode.startsWith(candidate),
|
||||
);
|
||||
});
|
||||
|
||||
return matchedSkuItems.length > 0 ? matchedSkuItems : product.skuItems;
|
||||
}
|
||||
|
||||
export function buildBrandFilingPlanningSizeRequirement(
|
||||
product: NonIntegratedProductRecord,
|
||||
itemCode?: string,
|
||||
) {
|
||||
const directValue = safeTrim(product.size) || safeTrim(product.openingReference?.size);
|
||||
if (directValue) return directValue;
|
||||
|
||||
return collectUniqueValues(
|
||||
getBrandFilingMatchedSkuItemsByItemCode(product, itemCode).map(
|
||||
(skuItem) => skuItem.size,
|
||||
),
|
||||
).join("; ");
|
||||
}
|
||||
|
||||
export function buildBrandFilingPlanningMaterialAndColor(
|
||||
product: NonIntegratedProductRecord,
|
||||
itemCode?: string,
|
||||
) {
|
||||
const baseMaterial =
|
||||
safeTrim(product.tagMaterial) || safeTrim(product.openingReference?.material);
|
||||
const baseColor = safeTrim(product.color);
|
||||
const skuValues = collectUniqueValues(
|
||||
getBrandFilingMatchedSkuItemsByItemCode(product, itemCode).map((skuItem) => {
|
||||
const material =
|
||||
safeTrim(skuItem.tagMaterial) || safeTrim(skuItem.material) || baseMaterial;
|
||||
const color = safeTrim(skuItem.color) || baseColor;
|
||||
return [material, color].filter(Boolean).join(" / ");
|
||||
}),
|
||||
);
|
||||
|
||||
if (skuValues.length > 0) {
|
||||
return skuValues.join("; ");
|
||||
}
|
||||
|
||||
return [baseMaterial, baseColor].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
export function buildBrandFilingPlanningSkuRows(
|
||||
product: NonIntegratedProductRecord,
|
||||
fallback?: {
|
||||
itemCode?: string;
|
||||
sizeRequirement?: string;
|
||||
materialAndColor?: string;
|
||||
},
|
||||
): BrandFilingPlanningSkuRow[] {
|
||||
const matchedSkuItems = product.skuItems;
|
||||
const fallbackItemCode = resolveBrandFilingPlanningItemCode(
|
||||
product,
|
||||
fallback?.itemCode,
|
||||
);
|
||||
const fallbackSize =
|
||||
safeTrim(fallback?.sizeRequirement) ||
|
||||
buildBrandFilingPlanningSizeRequirement(product, fallback?.itemCode);
|
||||
const fallbackMaterialAndColor =
|
||||
safeTrim(fallback?.materialAndColor) ||
|
||||
buildBrandFilingPlanningMaterialAndColor(product, fallback?.itemCode);
|
||||
|
||||
const skuRows = matchedSkuItems
|
||||
.map((skuItem) => {
|
||||
const itemCode = buildSkuItemCode(skuItem);
|
||||
if (!itemCode) return null;
|
||||
|
||||
return {
|
||||
itemCode,
|
||||
sizeRequirement: safeTrim(skuItem.size) || fallbackSize,
|
||||
materialAndColor: buildSkuMaterialAndColor(
|
||||
product,
|
||||
skuItem,
|
||||
fallbackMaterialAndColor,
|
||||
),
|
||||
} satisfies BrandFilingPlanningSkuRow;
|
||||
})
|
||||
.filter((row): row is BrandFilingPlanningSkuRow => row !== null);
|
||||
|
||||
const deduplicatedRows = Array.from(
|
||||
new Map(skuRows.map((row) => [row.itemCode, row])).values(),
|
||||
);
|
||||
|
||||
if (deduplicatedRows.length > 0) {
|
||||
return deduplicatedRows;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
itemCode: fallbackItemCode,
|
||||
sizeRequirement: fallbackSize,
|
||||
materialAndColor: fallbackMaterialAndColor,
|
||||
},
|
||||
].filter((row) => Boolean(row.itemCode || row.sizeRequirement || row.materialAndColor));
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
|
||||
import type {
|
||||
BrandFilingPlanningTableDownloadRequest,
|
||||
BrandFilingPlanningTableRecordInput,
|
||||
} from "@/app/intake/brand-filing/brandFilingPlanningTableRequest";
|
||||
|
||||
function resolveDownloadFileName(response: Response) {
|
||||
const contentDisposition = response.headers.get("Content-Disposition") ?? "";
|
||||
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) {
|
||||
return decodeURIComponent(utf8Match[1]);
|
||||
}
|
||||
|
||||
const basicMatch = contentDisposition.match(/filename=\"?([^\"]+)\"?/i);
|
||||
return basicMatch?.[1] ?? "brand-filing-planning-table.xlsx";
|
||||
}
|
||||
|
||||
export async function downloadBrandFilingPlanningTables(params: {
|
||||
brand: BrandFilingBrand;
|
||||
productIds: string[];
|
||||
planningRecords?: BrandFilingPlanningTableRecordInput[];
|
||||
}) {
|
||||
const payload: BrandFilingPlanningTableDownloadRequest = {
|
||||
brand: params.brand,
|
||||
productIds: params.productIds,
|
||||
...(params.planningRecords?.length
|
||||
? { planningRecords: params.planningRecords }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const response = await fetch("/api/intake/brand-filing/planning-table", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| {
|
||||
error?: { message?: string };
|
||||
}
|
||||
| null;
|
||||
throw new Error(payload?.error?.message || "企划表下载失败");
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = downloadUrl;
|
||||
anchor.download = resolveDownloadFileName(response);
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
|
||||
import type { BrandFilingPlanningRecord } from "@/app/intake/brand-filing/brandFilingTypes";
|
||||
|
||||
export type BrandFilingPlanningTableRecordInput = {
|
||||
productId: string;
|
||||
planningTable: BrandFilingPlanningRecord;
|
||||
};
|
||||
|
||||
export type BrandFilingPlanningTableDownloadRequest = {
|
||||
brand: BrandFilingBrand;
|
||||
productIds: string[];
|
||||
planningRecords?: BrandFilingPlanningTableRecordInput[];
|
||||
};
|
||||
@ -0,0 +1,183 @@
|
||||
import type { BrandFilingPlanningFieldKey } from "@/app/intake/brand-filing/brandFilingTypes";
|
||||
|
||||
export type BrandFilingPlanningFieldDefinition = {
|
||||
key: BrandFilingPlanningFieldKey;
|
||||
label: string;
|
||||
input: "text" | "textarea" | "date";
|
||||
};
|
||||
|
||||
export type BrandFilingPlanningSection = {
|
||||
title: string;
|
||||
columns: 1 | 2 | 3;
|
||||
fields: readonly BrandFilingPlanningFieldDefinition[];
|
||||
};
|
||||
|
||||
export const BRAND_FILING_PLANNING_SECTIONS: readonly BrandFilingPlanningSection[] = [
|
||||
{
|
||||
title: "基本信息",
|
||||
columns: 3,
|
||||
fields: [
|
||||
{ key: "planningProductLine", label: "企划产品线", input: "text" },
|
||||
{ key: "productLine", label: "产品线", input: "text" },
|
||||
{ key: "annualPlanStatus", label: "是否年度规划内", input: "text" },
|
||||
{ key: "developmentMode", label: "开发方式", input: "text" },
|
||||
{ key: "productSeries", label: "产品系列", input: "text" },
|
||||
{ key: "developmentCategory", label: "开发类别", input: "text" },
|
||||
{ key: "supplierName", label: "买采供应商名称", input: "text" },
|
||||
{ key: "warehousingStatus", label: "是否入仓", input: "text" },
|
||||
{ key: "launchDate", label: "需求上市时间", input: "date" },
|
||||
{ key: "activityStyleStatus", label: "是否活动款", input: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "企划背景介绍",
|
||||
columns: 1,
|
||||
fields: [
|
||||
{
|
||||
key: "productDemandAnalysis",
|
||||
label: "产品需求分析",
|
||||
input: "textarea",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "企划方案",
|
||||
columns: 2,
|
||||
fields: [
|
||||
{ key: "itemCode", label: "货号", input: "text" },
|
||||
{ key: "retailPriceDemand", label: "零售价需求", input: "text" },
|
||||
{ key: "purchasePriceDemand", label: "采购价需求", input: "text" },
|
||||
{ key: "sizeRequirement", label: "尺寸要求", input: "text" },
|
||||
{ key: "materialAndColor", label: "材质/颜色", input: "text" },
|
||||
{
|
||||
key: "legacyIterationNote",
|
||||
label: "特殊说明(是否老款迭代)",
|
||||
input: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "推广计划",
|
||||
columns: 3,
|
||||
fields: [
|
||||
{ key: "seriesName", label: "系列", input: "text" },
|
||||
{ key: "annualSalesTarget", label: "全年销售总量", input: "text" },
|
||||
{ key: "channel", label: "渠道", input: "text" },
|
||||
{ key: "month1Sales", label: "第1个月", input: "text" },
|
||||
{ key: "month2Sales", label: "第2个月", input: "text" },
|
||||
{ key: "month3Sales", label: "第3个月", input: "text" },
|
||||
{ key: "month4To6Sales", label: "第4-6月", input: "text" },
|
||||
{ key: "month7To9Sales", label: "第7-9月", input: "text" },
|
||||
{ key: "month10To12Sales", label: "第10-12月", input: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "会签",
|
||||
columns: 3,
|
||||
fields: [
|
||||
{ key: "productManager", label: "产品经理", input: "text" },
|
||||
{ key: "planningOwner", label: "企划负责人", input: "text" },
|
||||
{ key: "purchaseOwner", label: "采购负责人", input: "text" },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const BRAND_FILING_PLANNING_CELL_MAP: Record<
|
||||
Exclude<BrandFilingPlanningFieldKey, "imageUrl">,
|
||||
string
|
||||
> = {
|
||||
planningProductLine: "C3",
|
||||
productLine: "G3",
|
||||
annualPlanStatus: "K3",
|
||||
developmentMode: "C4",
|
||||
productSeries: "G4",
|
||||
developmentCategory: "K4",
|
||||
supplierName: "B5",
|
||||
warehousingStatus: "D5",
|
||||
launchDate: "G5",
|
||||
activityStyleStatus: "K5",
|
||||
productDemandAnalysis: "B8",
|
||||
itemCode: "A11",
|
||||
retailPriceDemand: "B11",
|
||||
purchasePriceDemand: "D11",
|
||||
sizeRequirement: "F11",
|
||||
materialAndColor: "H11",
|
||||
legacyIterationNote: "J11",
|
||||
seriesName: "A15",
|
||||
annualSalesTarget: "C15",
|
||||
channel: "D15",
|
||||
month1Sales: "E15",
|
||||
month2Sales: "F15",
|
||||
month3Sales: "G15",
|
||||
month4To6Sales: "H15",
|
||||
month7To9Sales: "I15",
|
||||
month10To12Sales: "K15",
|
||||
productManager: "B17",
|
||||
planningOwner: "F17",
|
||||
purchaseOwner: "J17",
|
||||
};
|
||||
|
||||
export const BRAND_FILING_PLANNING_EXTRA_CELL_MAP = {
|
||||
channelFooter: "B18",
|
||||
} as const;
|
||||
|
||||
export const BRAND_FILING_PLANNING_ROW_LAYOUT = {
|
||||
plan: {
|
||||
dataStartRow: 11,
|
||||
mergedRanges: [
|
||||
["B", "C"],
|
||||
["D", "E"],
|
||||
["F", "G"],
|
||||
["H", "I"],
|
||||
["J", "L"],
|
||||
] as const,
|
||||
},
|
||||
promotion: {
|
||||
sectionTitleRow: 12,
|
||||
headerTopRow: 13,
|
||||
headerBottomRow: 14,
|
||||
dataStartRow: 15,
|
||||
mergedRanges: [
|
||||
["I", "J"],
|
||||
["K", "L"],
|
||||
] as const,
|
||||
},
|
||||
sign: {
|
||||
sectionTitleRow: 16,
|
||||
dataRow: 17,
|
||||
channelFooterRow: 18,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const BRAND_FILING_PLANNING_FIELD_OPTIONS: Partial<
|
||||
Record<BrandFilingPlanningFieldKey, readonly string[]>
|
||||
> = {
|
||||
planningProductLine: ["客餐厅经营部", "卧室经营部"],
|
||||
productLine: [
|
||||
"皮功能",
|
||||
"布功能",
|
||||
"皮固定",
|
||||
"布固定",
|
||||
"皮床",
|
||||
"布床",
|
||||
"板木床",
|
||||
"生活家居",
|
||||
"客厅配套",
|
||||
"卧室配套",
|
||||
"餐厅配套",
|
||||
"单椅",
|
||||
],
|
||||
annualPlanStatus: ["是", "否"],
|
||||
developmentMode: ["买采产品", "全新开发产品", "达人、重点款产品"],
|
||||
developmentCategory: [
|
||||
"全新开发",
|
||||
"小改动产品",
|
||||
"直接选款产品",
|
||||
"选款改动产品",
|
||||
"特殊产品",
|
||||
],
|
||||
legacyIterationNote: ["是", "否"],
|
||||
productManager: ["静泉", "玉堂"],
|
||||
warehousingStatus: ["是", "否"],
|
||||
activityStyleStatus: ["常规款", "活动款", "直播款", "测款"],
|
||||
} as const;
|
||||
@ -0,0 +1,242 @@
|
||||
import {
|
||||
buildBrandFilingPlanningFormDefaults,
|
||||
buildBrandFilingPlanningInitialForm,
|
||||
buildBrandFilingPlanningInitialState,
|
||||
upsertBrandFilingPlanningRecord,
|
||||
} from "@/app/intake/brand-filing/brandFilingProductData";
|
||||
import {
|
||||
createEmptyBrandFilingPlanningForm,
|
||||
type BrandFilingPlanningLineItem,
|
||||
} from "@/app/intake/brand-filing/brandFilingTypes";
|
||||
import {
|
||||
createEmptyNonIntegratedOpeningReference,
|
||||
type NonIntegratedProductRecord,
|
||||
} from "@/app/intake/non-integrated/nonIntegratedStorage";
|
||||
|
||||
function createBrandFilingProduct(
|
||||
overrides: Partial<NonIntegratedProductRecord> = {},
|
||||
): NonIntegratedProductRecord {
|
||||
return {
|
||||
id: "product-1",
|
||||
spu: "GBGJ-CJ0266",
|
||||
productCode: "GBGJ-CJ0266",
|
||||
previewImage: "",
|
||||
salesChannel: "",
|
||||
salesPlatform: "",
|
||||
designer: "",
|
||||
productPlanning: "",
|
||||
purchaseOwner: "buyer-a",
|
||||
purchaser: "",
|
||||
tagMaterial: "",
|
||||
color: "",
|
||||
size: "",
|
||||
openingReference: {
|
||||
...createEmptyNonIntegratedOpeningReference(),
|
||||
temporaryCode: "GBGJ-CJ0266",
|
||||
designer: "appearance-a",
|
||||
planningOwner: "planner-a",
|
||||
reason: "demand-note",
|
||||
size: "1800*800*750",
|
||||
material: "ash-wood",
|
||||
},
|
||||
skuItems: [
|
||||
{
|
||||
id: "sku-1",
|
||||
skuCode: "GBGJ-CJ0266-A",
|
||||
barcode: "",
|
||||
model: "",
|
||||
specName: "variant-a",
|
||||
size: "",
|
||||
color: "",
|
||||
material: "",
|
||||
tagMaterial: "",
|
||||
},
|
||||
{
|
||||
id: "sku-2",
|
||||
skuCode: "GBGJ-CJ0266-B",
|
||||
barcode: "",
|
||||
model: "",
|
||||
specName: "variant-b",
|
||||
size: "",
|
||||
color: "",
|
||||
material: "",
|
||||
tagMaterial: "",
|
||||
},
|
||||
] as NonIntegratedProductRecord["skuItems"],
|
||||
factoryRows: [
|
||||
{
|
||||
skuId: "sku-1",
|
||||
supplierName: "supplier-a",
|
||||
taxIncludedUnitPrice: "120",
|
||||
},
|
||||
{
|
||||
skuId: "sku-2",
|
||||
supplierName: "supplier-a",
|
||||
taxIncludedUnitPrice: "130",
|
||||
},
|
||||
] as NonIntegratedProductRecord["factoryRows"],
|
||||
...overrides,
|
||||
} as unknown as NonIntegratedProductRecord;
|
||||
}
|
||||
|
||||
describe("brandFilingProductData", () => {
|
||||
it("uses the matched tax included unit price as purchase price demand", () => {
|
||||
const product = createBrandFilingProduct();
|
||||
|
||||
const form = buildBrandFilingPlanningFormDefaults(
|
||||
product,
|
||||
"GBGJ-CJ0266-B",
|
||||
);
|
||||
|
||||
expect(form.purchasePriceDemand).toBe("130");
|
||||
});
|
||||
|
||||
it("backfills an empty legacy purchase price demand from product factory data", () => {
|
||||
const product = createBrandFilingProduct();
|
||||
|
||||
const form = buildBrandFilingPlanningInitialForm(product, {
|
||||
completed: false,
|
||||
updatedAt: "2026-07-09T00:00:00.000Z",
|
||||
form: {
|
||||
...createEmptyBrandFilingPlanningForm(),
|
||||
itemCode: "GBGJ-CJ0266-B",
|
||||
purchasePriceDemand: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(form.purchasePriceDemand).toBe("130");
|
||||
});
|
||||
|
||||
it("builds per-item line items with matched purchase prices", () => {
|
||||
const product = createBrandFilingProduct();
|
||||
|
||||
const state = buildBrandFilingPlanningInitialState(product);
|
||||
|
||||
expect(state.lineItems).toEqual([
|
||||
expect.objectContaining({
|
||||
itemCode: "GBGJ-CJ0266-A",
|
||||
purchasePriceDemand: "120",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
itemCode: "GBGJ-CJ0266-B",
|
||||
purchasePriceDemand: "130",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves row-level planning values and derives the annual total from monthly sales", () => {
|
||||
const product = createBrandFilingProduct();
|
||||
const initialState = buildBrandFilingPlanningInitialState(product);
|
||||
const lineItems: BrandFilingPlanningLineItem[] = initialState.lineItems.map(
|
||||
(item, index) => ({
|
||||
...item,
|
||||
retailPriceDemand: index === 0 ? "1999" : "2999",
|
||||
purchasePriceDemand: index === 0 ? "120" : "130",
|
||||
seriesName: index === 0 ? "系列A" : "系列B",
|
||||
channel: index === 0 ? "顾家门店" : "电商",
|
||||
month1Sales: index === 0 ? "1" : "2",
|
||||
month2Sales: index === 0 ? "2" : "3",
|
||||
month3Sales: index === 0 ? "3" : "4",
|
||||
month4To6Sales: index === 0 ? "4" : "5",
|
||||
month7To9Sales: index === 0 ? "5" : "6",
|
||||
month10To12Sales: index === 0 ? "6" : "7",
|
||||
}),
|
||||
);
|
||||
|
||||
const { record } = upsertBrandFilingPlanningRecord({
|
||||
product,
|
||||
products: [product],
|
||||
brand: "gujia",
|
||||
form: initialState.form,
|
||||
lineItems,
|
||||
completed: false,
|
||||
});
|
||||
|
||||
expect(record.lineItems?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
itemCode: "GBGJ-CJ0266-A",
|
||||
retailPriceDemand: "1999",
|
||||
seriesName: "系列A",
|
||||
channel: "顾家门店",
|
||||
}),
|
||||
);
|
||||
expect(record.lineItems?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
itemCode: "GBGJ-CJ0266-B",
|
||||
retailPriceDemand: "2999",
|
||||
seriesName: "系列B",
|
||||
channel: "电商",
|
||||
}),
|
||||
);
|
||||
expect(record.form.channel).toBe("顾家门店 / 电商");
|
||||
expect(record.form.annualSalesTarget).toBe("21");
|
||||
});
|
||||
|
||||
it("backfills row-level values from a legacy shared form when line items are missing", () => {
|
||||
const product = createBrandFilingProduct();
|
||||
|
||||
const state = buildBrandFilingPlanningInitialState(product, {
|
||||
completed: false,
|
||||
updatedAt: "2026-07-09T00:00:00.000Z",
|
||||
form: {
|
||||
...createEmptyBrandFilingPlanningForm(),
|
||||
itemCode: "GBGJ-CJ0266-A",
|
||||
retailPriceDemand: "1888",
|
||||
month1Sales: "8",
|
||||
month2Sales: "6",
|
||||
month3Sales: "4",
|
||||
month4To6Sales: "12",
|
||||
month7To9Sales: "10",
|
||||
month10To12Sales: "9",
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.lineItems[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
itemCode: "GBGJ-CJ0266-A",
|
||||
retailPriceDemand: "1888",
|
||||
month1Sales: "8",
|
||||
month10To12Sales: "9",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears legacy auto-filled channels so users can fill real sales channels manually", () => {
|
||||
const product = createBrandFilingProduct({
|
||||
openingReference: {
|
||||
...createEmptyNonIntegratedOpeningReference(),
|
||||
temporaryCode: "GBGJ-CJ0266",
|
||||
salesCategory: "架类",
|
||||
},
|
||||
});
|
||||
|
||||
const state = buildBrandFilingPlanningInitialState(product, {
|
||||
completed: false,
|
||||
updatedAt: "2026-07-09T00:00:00.000Z",
|
||||
form: {
|
||||
...createEmptyBrandFilingPlanningForm(),
|
||||
channel: "架类",
|
||||
},
|
||||
lineItems: [
|
||||
{
|
||||
itemCode: "GBGJ-CJ0266-A",
|
||||
retailPriceDemand: "",
|
||||
purchasePriceDemand: "",
|
||||
sizeRequirement: "",
|
||||
materialAndColor: "",
|
||||
seriesName: "",
|
||||
channel: "架类",
|
||||
month1Sales: "",
|
||||
month2Sales: "",
|
||||
month3Sales: "",
|
||||
month4To6Sales: "",
|
||||
month7To9Sales: "",
|
||||
month10To12Sales: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(state.form.channel).toBe("");
|
||||
expect(state.lineItems[0]?.channel).toBe("");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,345 @@
|
||||
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
|
||||
import {
|
||||
type BrandFilingPlanningLineItem,
|
||||
createEmptyBrandFilingPlanningForm,
|
||||
normalizeBrandFilingPlanningForm,
|
||||
normalizeBrandFilingPlanningLineItems,
|
||||
type BrandFilingPlanningFieldKey,
|
||||
type BrandFilingPlanningForm,
|
||||
type BrandFilingPlanningRecord,
|
||||
type BrandFilingProductData,
|
||||
} from "@/app/intake/brand-filing/brandFilingTypes";
|
||||
import {
|
||||
calculateBrandFilingAnnualSalesTarget,
|
||||
buildBrandFilingLineItemChannelSummary,
|
||||
buildBrandFilingPlanningLineItems,
|
||||
} from "@/app/intake/brand-filing/brandFilingPlanningLineItems";
|
||||
import {
|
||||
buildBrandFilingPlanningMaterialAndColor,
|
||||
buildBrandFilingPlanningSizeRequirement,
|
||||
getBrandFilingMatchedSkuItemsByItemCode,
|
||||
resolveBrandFilingPlanningItemCode,
|
||||
} from "@/app/intake/brand-filing/brandFilingPlanningSkuRows";
|
||||
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
|
||||
import { getNonIntegratedListPeople } from "@/app/intake/product-development/nonIntegratedProductListLogic";
|
||||
|
||||
const DEFAULT_BRAND_FILING_SUPPLIER_NAME = "欧伯娜";
|
||||
const DEFAULT_BRAND_FILING_PRODUCT_MANAGER = "静泉";
|
||||
const BRAND_FILING_BACKFILL_FIELD_KEYS: readonly BrandFilingPlanningFieldKey[] = [
|
||||
"supplierName",
|
||||
"purchasePriceDemand",
|
||||
"sizeRequirement",
|
||||
"materialAndColor",
|
||||
];
|
||||
|
||||
function safeTrim(value: string | null | undefined) {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function buildLegacyAutoChannel(product: NonIntegratedProductRecord) {
|
||||
return (
|
||||
safeTrim(product.salesChannel) ||
|
||||
safeTrim(product.salesPlatform) ||
|
||||
safeTrim(product.openingReference?.salesCategory)
|
||||
);
|
||||
}
|
||||
|
||||
function clearLegacyAutoChannelValue(
|
||||
value: string,
|
||||
legacyAutoChannel: string,
|
||||
) {
|
||||
const normalizedValue = safeTrim(value);
|
||||
if (!legacyAutoChannel) {
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
return normalizedValue === legacyAutoChannel ? "" : normalizedValue;
|
||||
}
|
||||
|
||||
export function normalizeLegacyBrandFilingPlanningRecord(
|
||||
product: NonIntegratedProductRecord,
|
||||
record?: BrandFilingPlanningRecord,
|
||||
) {
|
||||
if (!record) return undefined;
|
||||
|
||||
const legacyAutoChannel = buildLegacyAutoChannel(product);
|
||||
if (!legacyAutoChannel) {
|
||||
return record;
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
form: {
|
||||
...record.form,
|
||||
channel: clearLegacyAutoChannelValue(
|
||||
record.form.channel,
|
||||
legacyAutoChannel,
|
||||
),
|
||||
},
|
||||
lineItems: (record.lineItems ?? []).map((item) => ({
|
||||
...item,
|
||||
channel: clearLegacyAutoChannelValue(item.channel, legacyAutoChannel),
|
||||
})),
|
||||
} satisfies BrandFilingPlanningRecord;
|
||||
}
|
||||
|
||||
export type BrandFilingPlanningEditorState = {
|
||||
form: BrandFilingPlanningForm;
|
||||
lineItems: BrandFilingPlanningLineItem[];
|
||||
};
|
||||
|
||||
function syncPlanningSeriesFields(form: BrandFilingPlanningForm) {
|
||||
const productSeries = safeTrim(form.productSeries);
|
||||
const seriesName = safeTrim(form.seriesName);
|
||||
|
||||
if (productSeries && !seriesName) {
|
||||
form.seriesName = productSeries;
|
||||
} else if (!productSeries && seriesName) {
|
||||
form.productSeries = seriesName;
|
||||
}
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
function buildPurchasePriceDemand(
|
||||
product: NonIntegratedProductRecord,
|
||||
itemCode?: string,
|
||||
) {
|
||||
const matchedSkuIds = new Set(
|
||||
getBrandFilingMatchedSkuItemsByItemCode(product, itemCode)
|
||||
.map((skuItem) => safeTrim(skuItem.id))
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const matchedFactoryRowValue = product.factoryRows.find(
|
||||
(row) =>
|
||||
matchedSkuIds.has(safeTrim(row.skuId)) &&
|
||||
safeTrim(row.taxIncludedUnitPrice),
|
||||
)?.taxIncludedUnitPrice;
|
||||
|
||||
if (safeTrim(matchedFactoryRowValue)) {
|
||||
return safeTrim(matchedFactoryRowValue);
|
||||
}
|
||||
|
||||
return (
|
||||
safeTrim(
|
||||
product.factoryRows.find((row) => safeTrim(row.taxIncludedUnitPrice))
|
||||
?.taxIncludedUnitPrice,
|
||||
) || ""
|
||||
);
|
||||
}
|
||||
|
||||
export function getBrandFilingPlanningRecord(
|
||||
product: NonIntegratedProductRecord,
|
||||
brand: BrandFilingBrand,
|
||||
) {
|
||||
return product.brandFilingData?.[brand]?.planningTable;
|
||||
}
|
||||
|
||||
function normalizeLegacyPlanningForm(
|
||||
product: NonIntegratedProductRecord,
|
||||
form: Partial<BrandFilingPlanningForm> | undefined,
|
||||
defaultForm: BrandFilingPlanningForm,
|
||||
) {
|
||||
if (!form) return undefined;
|
||||
|
||||
const nextForm = normalizeBrandFilingPlanningForm(form);
|
||||
const workflowSupplierName = safeTrim(product.factoryRows[0]?.supplierName);
|
||||
|
||||
if (
|
||||
workflowSupplierName &&
|
||||
safeTrim(nextForm.supplierName) === workflowSupplierName
|
||||
) {
|
||||
nextForm.supplierName = DEFAULT_BRAND_FILING_SUPPLIER_NAME;
|
||||
}
|
||||
|
||||
BRAND_FILING_BACKFILL_FIELD_KEYS.forEach((fieldKey) => {
|
||||
if (!safeTrim(nextForm[fieldKey])) {
|
||||
nextForm[fieldKey] = defaultForm[fieldKey];
|
||||
}
|
||||
});
|
||||
|
||||
return nextForm;
|
||||
}
|
||||
|
||||
export function buildBrandFilingPlanningFormDefaults(
|
||||
product: NonIntegratedProductRecord,
|
||||
itemCode?: string,
|
||||
): BrandFilingPlanningForm {
|
||||
const people = getNonIntegratedListPeople(product);
|
||||
const resolvedItemCode = resolveBrandFilingPlanningItemCode(product, itemCode);
|
||||
|
||||
return {
|
||||
...createEmptyBrandFilingPlanningForm(),
|
||||
supplierName: DEFAULT_BRAND_FILING_SUPPLIER_NAME,
|
||||
productManager: DEFAULT_BRAND_FILING_PRODUCT_MANAGER,
|
||||
launchDate: "",
|
||||
productDemandAnalysis: safeTrim(product.openingReference?.reason),
|
||||
itemCode: resolvedItemCode,
|
||||
purchasePriceDemand: buildPurchasePriceDemand(product, resolvedItemCode),
|
||||
sizeRequirement: buildBrandFilingPlanningSizeRequirement(
|
||||
product,
|
||||
resolvedItemCode,
|
||||
),
|
||||
materialAndColor: buildBrandFilingPlanningMaterialAndColor(
|
||||
product,
|
||||
resolvedItemCode,
|
||||
),
|
||||
channel: "",
|
||||
planningOwner: people.appearanceDesigner,
|
||||
purchaseOwner: people.purchaseOwner,
|
||||
imageUrl:
|
||||
safeTrim(product.openingReference?.productImage) ||
|
||||
safeTrim(product.previewImage),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBrandFilingPlanningInitialForm(
|
||||
product: NonIntegratedProductRecord,
|
||||
currentRecord?: BrandFilingPlanningRecord,
|
||||
) {
|
||||
const normalizedRecord = normalizeLegacyBrandFilingPlanningRecord(
|
||||
product,
|
||||
currentRecord,
|
||||
);
|
||||
const defaultForm = buildBrandFilingPlanningFormDefaults(
|
||||
product,
|
||||
normalizedRecord?.form.itemCode,
|
||||
);
|
||||
|
||||
return syncPlanningSeriesFields(
|
||||
normalizeBrandFilingPlanningForm({
|
||||
...defaultForm,
|
||||
...(normalizeLegacyPlanningForm(
|
||||
product,
|
||||
normalizedRecord?.form,
|
||||
defaultForm,
|
||||
) ?? {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildBrandFilingPlanningInitialState(
|
||||
product: NonIntegratedProductRecord,
|
||||
currentRecord?: BrandFilingPlanningRecord,
|
||||
): BrandFilingPlanningEditorState {
|
||||
const normalizedRecord = normalizeLegacyBrandFilingPlanningRecord(
|
||||
product,
|
||||
currentRecord,
|
||||
);
|
||||
const form = buildBrandFilingPlanningInitialForm(product, normalizedRecord);
|
||||
const lineItems = buildBrandFilingPlanningLineItems({
|
||||
product,
|
||||
form,
|
||||
currentRecord: normalizedRecord,
|
||||
defaultPurchasePriceDemand: (itemCode) =>
|
||||
buildPurchasePriceDemand(product, itemCode),
|
||||
defaultChannel: "",
|
||||
});
|
||||
|
||||
return { form, lineItems };
|
||||
}
|
||||
|
||||
export function getNextBrandFilingSequenceNumber(
|
||||
products: NonIntegratedProductRecord[],
|
||||
brand: BrandFilingBrand,
|
||||
) {
|
||||
return (
|
||||
products.reduce((maxValue, product) => {
|
||||
const sequenceNumber = getBrandFilingPlanningRecord(product, brand)?.sequenceNumber;
|
||||
return sequenceNumber && sequenceNumber > maxValue
|
||||
? sequenceNumber
|
||||
: maxValue;
|
||||
}, 0) + 1
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertBrandFilingPlanningRecord(params: {
|
||||
product: NonIntegratedProductRecord;
|
||||
products: NonIntegratedProductRecord[];
|
||||
brand: BrandFilingBrand;
|
||||
form?: Partial<BrandFilingPlanningForm>;
|
||||
lineItems?: BrandFilingPlanningLineItem[];
|
||||
completed?: boolean;
|
||||
}) {
|
||||
const { product, products, brand } = params;
|
||||
const currentRecord = getBrandFilingPlanningRecord(product, brand);
|
||||
const defaultState = buildBrandFilingPlanningInitialState(product, currentRecord);
|
||||
const nextForm = normalizeBrandFilingPlanningForm({
|
||||
...defaultState.form,
|
||||
...(params.form ?? {}),
|
||||
});
|
||||
const nextLineItems =
|
||||
params.lineItems && params.lineItems.length > 0
|
||||
? normalizeBrandFilingPlanningLineItems(params.lineItems)
|
||||
: defaultState.lineItems;
|
||||
const normalizedChannelSummary = buildBrandFilingLineItemChannelSummary(
|
||||
nextLineItems,
|
||||
);
|
||||
const firstLineItem = nextLineItems[0];
|
||||
|
||||
nextForm.itemCode = firstLineItem?.itemCode ?? nextForm.itemCode;
|
||||
nextForm.retailPriceDemand =
|
||||
firstLineItem?.retailPriceDemand ?? nextForm.retailPriceDemand;
|
||||
nextForm.purchasePriceDemand =
|
||||
firstLineItem?.purchasePriceDemand ?? nextForm.purchasePriceDemand;
|
||||
nextForm.sizeRequirement =
|
||||
firstLineItem?.sizeRequirement ?? nextForm.sizeRequirement;
|
||||
nextForm.materialAndColor =
|
||||
firstLineItem?.materialAndColor ?? nextForm.materialAndColor;
|
||||
nextForm.seriesName = firstLineItem?.seriesName ?? nextForm.seriesName;
|
||||
nextForm.channel = normalizedChannelSummary;
|
||||
nextForm.month1Sales = firstLineItem?.month1Sales ?? nextForm.month1Sales;
|
||||
nextForm.month2Sales = firstLineItem?.month2Sales ?? nextForm.month2Sales;
|
||||
nextForm.month3Sales = firstLineItem?.month3Sales ?? nextForm.month3Sales;
|
||||
nextForm.month4To6Sales =
|
||||
firstLineItem?.month4To6Sales ?? nextForm.month4To6Sales;
|
||||
nextForm.month7To9Sales =
|
||||
firstLineItem?.month7To9Sales ?? nextForm.month7To9Sales;
|
||||
nextForm.month10To12Sales =
|
||||
firstLineItem?.month10To12Sales ?? nextForm.month10To12Sales;
|
||||
nextForm.annualSalesTarget = firstLineItem
|
||||
? calculateBrandFilingAnnualSalesTarget(firstLineItem)
|
||||
: "";
|
||||
|
||||
const nextRecord: BrandFilingPlanningRecord = {
|
||||
sequenceNumber:
|
||||
currentRecord?.sequenceNumber ??
|
||||
getNextBrandFilingSequenceNumber(products, brand),
|
||||
completed:
|
||||
params.completed ??
|
||||
currentRecord?.completed ??
|
||||
false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
form: nextForm,
|
||||
lineItems: nextLineItems,
|
||||
};
|
||||
const nextBrandFilingData: BrandFilingProductData = {
|
||||
...(product.brandFilingData ?? {}),
|
||||
[brand]: {
|
||||
...(product.brandFilingData?.[brand] ?? {}),
|
||||
planningTable: nextRecord,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
record: nextRecord,
|
||||
product: {
|
||||
...product,
|
||||
brandFilingData: nextBrandFilingData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureBrandFilingPlanningRecord(params: {
|
||||
product: NonIntegratedProductRecord;
|
||||
products: NonIntegratedProductRecord[];
|
||||
brand: BrandFilingBrand;
|
||||
}) {
|
||||
return upsertBrandFilingPlanningRecord({
|
||||
...params,
|
||||
completed:
|
||||
getBrandFilingPlanningRecord(params.product, params.brand)?.completed ??
|
||||
false,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,236 @@
|
||||
export type BrandFilingPlanningFieldKey =
|
||||
| "planningProductLine"
|
||||
| "productLine"
|
||||
| "annualPlanStatus"
|
||||
| "developmentMode"
|
||||
| "productSeries"
|
||||
| "developmentCategory"
|
||||
| "supplierName"
|
||||
| "warehousingStatus"
|
||||
| "launchDate"
|
||||
| "activityStyleStatus"
|
||||
| "productDemandAnalysis"
|
||||
| "itemCode"
|
||||
| "retailPriceDemand"
|
||||
| "purchasePriceDemand"
|
||||
| "sizeRequirement"
|
||||
| "materialAndColor"
|
||||
| "legacyIterationNote"
|
||||
| "seriesName"
|
||||
| "annualSalesTarget"
|
||||
| "channel"
|
||||
| "month1Sales"
|
||||
| "month2Sales"
|
||||
| "month3Sales"
|
||||
| "month4To6Sales"
|
||||
| "month7To9Sales"
|
||||
| "month10To12Sales"
|
||||
| "productManager"
|
||||
| "planningOwner"
|
||||
| "purchaseOwner"
|
||||
| "imageUrl";
|
||||
|
||||
export type BrandFilingPlanningLineItemFieldKey =
|
||||
| "itemCode"
|
||||
| "retailPriceDemand"
|
||||
| "purchasePriceDemand"
|
||||
| "sizeRequirement"
|
||||
| "materialAndColor"
|
||||
| "seriesName"
|
||||
| "channel"
|
||||
| "month1Sales"
|
||||
| "month2Sales"
|
||||
| "month3Sales"
|
||||
| "month4To6Sales"
|
||||
| "month7To9Sales"
|
||||
| "month10To12Sales";
|
||||
|
||||
export type BrandFilingPlanningLineItem = Record<
|
||||
BrandFilingPlanningLineItemFieldKey,
|
||||
string
|
||||
>;
|
||||
|
||||
export type BrandFilingPlanningForm = Record<
|
||||
BrandFilingPlanningFieldKey,
|
||||
string
|
||||
>;
|
||||
|
||||
export type BrandFilingPlanningRecord = {
|
||||
sequenceNumber?: number;
|
||||
completed: boolean;
|
||||
updatedAt: string;
|
||||
form: BrandFilingPlanningForm;
|
||||
lineItems?: BrandFilingPlanningLineItem[];
|
||||
};
|
||||
|
||||
export type BrandFilingBrandData = {
|
||||
planningTable?: BrandFilingPlanningRecord;
|
||||
};
|
||||
|
||||
export type BrandFilingProductData = {
|
||||
gujia?: BrandFilingBrandData;
|
||||
suofeiya?: BrandFilingBrandData;
|
||||
};
|
||||
|
||||
export const BRAND_FILING_PLANNING_FIELD_KEYS = [
|
||||
"planningProductLine",
|
||||
"productLine",
|
||||
"annualPlanStatus",
|
||||
"developmentMode",
|
||||
"productSeries",
|
||||
"developmentCategory",
|
||||
"supplierName",
|
||||
"warehousingStatus",
|
||||
"launchDate",
|
||||
"activityStyleStatus",
|
||||
"productDemandAnalysis",
|
||||
"itemCode",
|
||||
"retailPriceDemand",
|
||||
"purchasePriceDemand",
|
||||
"sizeRequirement",
|
||||
"materialAndColor",
|
||||
"legacyIterationNote",
|
||||
"seriesName",
|
||||
"annualSalesTarget",
|
||||
"channel",
|
||||
"month1Sales",
|
||||
"month2Sales",
|
||||
"month3Sales",
|
||||
"month4To6Sales",
|
||||
"month7To9Sales",
|
||||
"month10To12Sales",
|
||||
"productManager",
|
||||
"planningOwner",
|
||||
"purchaseOwner",
|
||||
"imageUrl",
|
||||
] as const satisfies readonly BrandFilingPlanningFieldKey[];
|
||||
|
||||
export const BRAND_FILING_PLANNING_LINE_ITEM_FIELD_KEYS = [
|
||||
"itemCode",
|
||||
"retailPriceDemand",
|
||||
"purchasePriceDemand",
|
||||
"sizeRequirement",
|
||||
"materialAndColor",
|
||||
"seriesName",
|
||||
"channel",
|
||||
"month1Sales",
|
||||
"month2Sales",
|
||||
"month3Sales",
|
||||
"month4To6Sales",
|
||||
"month7To9Sales",
|
||||
"month10To12Sales",
|
||||
] as const satisfies readonly BrandFilingPlanningLineItemFieldKey[];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readText(value: unknown) {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readOptionalSequenceNumber(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createEmptyBrandFilingPlanningForm(): BrandFilingPlanningForm {
|
||||
return Object.fromEntries(
|
||||
BRAND_FILING_PLANNING_FIELD_KEYS.map((key) => [key, ""]),
|
||||
) as BrandFilingPlanningForm;
|
||||
}
|
||||
|
||||
export function createEmptyBrandFilingPlanningLineItem(): BrandFilingPlanningLineItem {
|
||||
return Object.fromEntries(
|
||||
BRAND_FILING_PLANNING_LINE_ITEM_FIELD_KEYS.map((key) => [key, ""]),
|
||||
) as BrandFilingPlanningLineItem;
|
||||
}
|
||||
|
||||
export function normalizeBrandFilingPlanningForm(
|
||||
value: unknown,
|
||||
): BrandFilingPlanningForm {
|
||||
const nextForm = createEmptyBrandFilingPlanningForm();
|
||||
if (!isRecord(value)) return nextForm;
|
||||
|
||||
BRAND_FILING_PLANNING_FIELD_KEYS.forEach((key) => {
|
||||
nextForm[key] = readText(value[key]);
|
||||
});
|
||||
|
||||
return nextForm;
|
||||
}
|
||||
|
||||
export function normalizeBrandFilingPlanningLineItem(
|
||||
value: unknown,
|
||||
): BrandFilingPlanningLineItem {
|
||||
const nextItem = createEmptyBrandFilingPlanningLineItem();
|
||||
if (!isRecord(value)) return nextItem;
|
||||
|
||||
BRAND_FILING_PLANNING_LINE_ITEM_FIELD_KEYS.forEach((key) => {
|
||||
nextItem[key] = readText(value[key]);
|
||||
});
|
||||
|
||||
return nextItem;
|
||||
}
|
||||
|
||||
export function normalizeBrandFilingPlanningLineItems(
|
||||
value: unknown,
|
||||
): BrandFilingPlanningLineItem[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
return value.map((entry) => normalizeBrandFilingPlanningLineItem(entry));
|
||||
}
|
||||
|
||||
export function normalizeBrandFilingPlanningRecord(
|
||||
value: unknown,
|
||||
): BrandFilingPlanningRecord | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
return {
|
||||
sequenceNumber: readOptionalSequenceNumber(value.sequenceNumber),
|
||||
completed: value.completed === true,
|
||||
updatedAt: readText(value.updatedAt),
|
||||
form: normalizeBrandFilingPlanningForm(value.form),
|
||||
lineItems: normalizeBrandFilingPlanningLineItems(value.lineItems),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBrandFilingBrandData(
|
||||
value: unknown,
|
||||
): BrandFilingBrandData | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
const planningTable = normalizeBrandFilingPlanningRecord(value.planningTable);
|
||||
if (!planningTable) return undefined;
|
||||
|
||||
return { planningTable };
|
||||
}
|
||||
|
||||
export function normalizeBrandFilingData(
|
||||
value: unknown,
|
||||
): BrandFilingProductData | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
const gujia = normalizeBrandFilingBrandData(value.gujia);
|
||||
const suofeiya = normalizeBrandFilingBrandData(value.suofeiya);
|
||||
|
||||
if (!gujia && !suofeiya) return undefined;
|
||||
|
||||
return {
|
||||
...(gujia ? { gujia } : {}),
|
||||
...(suofeiya ? { suofeiya } : {}),
|
||||
};
|
||||
}
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { BarcodePrintWorkbenchButton } from "@/app/intake/barcode-print/BarcodePrintWorkbenchButton";
|
||||
import { BrandFilingWorkbenchSection } from "@/app/intake/brand-filing/BrandFilingWorkbenchSection";
|
||||
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
|
||||
import { BomLibraryWorkbenchSection } from "@/app/intake/bom-library/BomLibraryWorkbenchSection";
|
||||
import type {
|
||||
IntakeEntryTab,
|
||||
@ -16,6 +18,7 @@ import { ReferenceEstimatorWorkbenchSection } from "@/app/intake/reference-estim
|
||||
|
||||
export function FormalIntakeWorkbench({
|
||||
activeSection,
|
||||
activeBrandFilingBrand,
|
||||
onToggle,
|
||||
pendingEntryTab,
|
||||
onPendingEntryTabChange,
|
||||
@ -29,6 +32,7 @@ export function FormalIntakeWorkbench({
|
||||
onOpenProductPatentLedger,
|
||||
onOpenProductPatentDashboard,
|
||||
onOpenBarcodePrintWorkspace,
|
||||
onOpenBrandFilingWorkspace,
|
||||
onOpenSpuRuleLibraryWorkspace,
|
||||
onOpenAdminNodePermissionWorkspace,
|
||||
canViewProductDevelopmentList,
|
||||
@ -41,9 +45,11 @@ export function FormalIntakeWorkbench({
|
||||
canViewProductPatentLedger,
|
||||
canViewProductPatentDashboard,
|
||||
canViewBarcodePrint,
|
||||
canViewBrandFiling,
|
||||
showNodePermissionSettings,
|
||||
}: {
|
||||
activeSection: IntakeSection;
|
||||
activeBrandFilingBrand: BrandFilingBrand | null;
|
||||
onToggle: (section: Exclude<IntakeSection, null>) => void;
|
||||
pendingEntryTab: IntakeEntryTab;
|
||||
onPendingEntryTabChange: (tab: IntakeEntryTab) => void;
|
||||
@ -57,6 +63,7 @@ export function FormalIntakeWorkbench({
|
||||
onOpenProductPatentLedger: () => void;
|
||||
onOpenProductPatentDashboard: () => void;
|
||||
onOpenBarcodePrintWorkspace: () => void;
|
||||
onOpenBrandFilingWorkspace: (brand: BrandFilingBrand) => void;
|
||||
onOpenSpuRuleLibraryWorkspace?: () => void;
|
||||
onOpenAdminNodePermissionWorkspace?: () => void;
|
||||
canViewProductDevelopmentList: boolean;
|
||||
@ -69,6 +76,7 @@ export function FormalIntakeWorkbench({
|
||||
canViewProductPatentLedger: boolean;
|
||||
canViewProductPatentDashboard: boolean;
|
||||
canViewBarcodePrint: boolean;
|
||||
canViewBrandFiling: boolean;
|
||||
showNodePermissionSettings: boolean;
|
||||
}) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@ -131,6 +139,13 @@ export function FormalIntakeWorkbench({
|
||||
canViewBarcodePrint={canViewBarcodePrint}
|
||||
onOpenBarcodePrintWorkspace={onOpenBarcodePrintWorkspace}
|
||||
/>
|
||||
<BrandFilingWorkbenchSection
|
||||
activeSection={activeSection}
|
||||
activeBrand={activeBrandFilingBrand}
|
||||
canViewBrandFiling={canViewBrandFiling}
|
||||
onToggle={onToggle}
|
||||
onOpenBrandFilingWorkspace={onOpenBrandFilingWorkspace}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
|
||||
import type { BomLibraryKey } from "@/app/intake/bom-library/BomLibraryDomain";
|
||||
import { IntakeEmptyDetail } from "@/app/intake/IntakeMainLayout";
|
||||
import type { IntakePrimaryDomainContentProps } from "@/app/intake/IntakePrimaryDomainContent";
|
||||
@ -155,6 +156,7 @@ export function buildIntakePrimaryDomainProps(params: {
|
||||
canViewProductPatentLedger: boolean;
|
||||
canEditProductPatentLedger: boolean;
|
||||
canViewBarcodePrint: boolean;
|
||||
canViewBrandFiling: boolean;
|
||||
}): IntakePrimaryDomainContentProps {
|
||||
return {
|
||||
activeSection: params.activeSection,
|
||||
@ -212,11 +214,13 @@ export function buildIntakePrimaryDomainProps(params: {
|
||||
products: params.nonIntegratedProducts,
|
||||
user: params.user,
|
||||
},
|
||||
canViewBrandFiling: params.canViewBrandFiling,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildIntakeRightWorkbenchProps(params: {
|
||||
activeWorkspaceTabKind: IntakeWorkspaceTab["kind"] | null;
|
||||
activeBrandFilingBrand: BrandFilingBrand | null;
|
||||
activeSection: IntakeSection;
|
||||
onToggle: (section: Exclude<IntakeSection, null>) => void;
|
||||
pendingEntryTab: IntakeEntryTab;
|
||||
@ -234,6 +238,7 @@ export function buildIntakeRightWorkbenchProps(params: {
|
||||
onOpenProductPatentLedger: () => void;
|
||||
onOpenProductPatentDashboard: () => void;
|
||||
onOpenBarcodePrintWorkspace: () => void;
|
||||
onOpenBrandFilingWorkspace: (brand: BrandFilingBrand) => void;
|
||||
onOpenSpuRuleLibraryWorkspace?: () => void;
|
||||
onOpenAdminNodePermissionWorkspace?: () => void;
|
||||
showNodePermissionSettings: boolean;
|
||||
@ -247,11 +252,13 @@ export function buildIntakeRightWorkbenchProps(params: {
|
||||
canViewProductPatentLedger: boolean;
|
||||
canViewProductPatentDashboard: boolean;
|
||||
canViewBarcodePrint: boolean;
|
||||
canViewBrandFiling: boolean;
|
||||
}): IntakeRightWorkbenchPanelProps {
|
||||
return {
|
||||
activeWorkspaceTabKind: params.activeWorkspaceTabKind,
|
||||
workbench: {
|
||||
activeSection: params.activeSection,
|
||||
activeBrandFilingBrand: params.activeBrandFilingBrand,
|
||||
onToggle: params.onToggle,
|
||||
pendingEntryTab: params.pendingEntryTab,
|
||||
onPendingEntryTabChange: params.onPendingEntryTabChange,
|
||||
@ -267,6 +274,7 @@ export function buildIntakeRightWorkbenchProps(params: {
|
||||
onOpenProductPatentLedger: params.onOpenProductPatentLedger,
|
||||
onOpenProductPatentDashboard: params.onOpenProductPatentDashboard,
|
||||
onOpenBarcodePrintWorkspace: params.onOpenBarcodePrintWorkspace,
|
||||
onOpenBrandFilingWorkspace: params.onOpenBrandFilingWorkspace,
|
||||
onOpenSpuRuleLibraryWorkspace: params.onOpenSpuRuleLibraryWorkspace,
|
||||
onOpenAdminNodePermissionWorkspace:
|
||||
params.onOpenAdminNodePermissionWorkspace,
|
||||
@ -282,6 +290,7 @@ export function buildIntakeRightWorkbenchProps(params: {
|
||||
canViewProductPatentLedger: params.canViewProductPatentLedger,
|
||||
canViewProductPatentDashboard: params.canViewProductPatentDashboard,
|
||||
canViewBarcodePrint: params.canViewBarcodePrint,
|
||||
canViewBrandFiling: params.canViewBrandFiling,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -139,6 +139,9 @@ export function serializeIntakeWorkspaceTab(
|
||||
if (tab.kind === "productPatentLedger") {
|
||||
return { id: tab.id, kind: tab.kind, target: tab.target };
|
||||
}
|
||||
if (tab.kind === "brandFiling") {
|
||||
return { id: tab.id, kind: tab.kind, brand: tab.brand };
|
||||
}
|
||||
|
||||
return { id: tab.id, kind: tab.kind };
|
||||
}
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import {
|
||||
getBrandFilingWorkspaceLabel,
|
||||
type BrandFilingBrand,
|
||||
} from "@/app/intake/brand-filing/brandFilingConfig";
|
||||
import type { BomLibraryKey } from "@/app/intake/bom-library/BomLibraryDomain";
|
||||
import { BOM_LIBRARY_OPTIONS } from "@/app/intake/bom-library/bomLibraryConfig";
|
||||
import { BARCODE_PRINT_LABEL } from "@/app/intake/barcode-print/barcodePrintConfig";
|
||||
@ -351,6 +355,21 @@ export function createBarcodePrintWorkspaceTab(
|
||||
};
|
||||
}
|
||||
|
||||
export function createBrandFilingWorkspaceTab({
|
||||
id,
|
||||
brand,
|
||||
}: {
|
||||
id: string;
|
||||
brand: BrandFilingBrand;
|
||||
}): IntakeWorkspaceTab {
|
||||
return {
|
||||
id,
|
||||
kind: "brandFiling",
|
||||
label: getBrandFilingWorkspaceLabel(brand),
|
||||
brand,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultSupplierBarcodePrintWorkspaceState(): InitialIntakeWorkspaceState {
|
||||
const defaultTab = createBarcodePrintWorkspaceTab("barcode-print:supplier:default");
|
||||
|
||||
@ -474,6 +493,12 @@ function restoreIntakeWorkspaceTab(
|
||||
label: BARCODE_PRINT_LABEL,
|
||||
};
|
||||
}
|
||||
if (tab.kind === "brandFiling") {
|
||||
return createBrandFilingWorkspaceTab({
|
||||
id: tab.id,
|
||||
brand: tab.brand,
|
||||
});
|
||||
}
|
||||
if (tab.kind === "nonIntegratedProduct") {
|
||||
const product =
|
||||
productMap.get(tab.productId) ??
|
||||
@ -663,6 +688,7 @@ export function getWorkspaceTabInitialSection(tab: IntakeWorkspaceTab | null) {
|
||||
if (tab.kind === "productPatentLedger") return "patents";
|
||||
if (tab.kind === "productPatentDashboard") return "patents";
|
||||
if (tab.kind === "barcodePrint") return "barcodePrint";
|
||||
if (tab.kind === "brandFiling") return "brandFiling";
|
||||
if (tab.kind === "bomLibrary") return "bom";
|
||||
if (tab.kind === "rawMaterialLibrary") return "rawMaterialLibrary";
|
||||
return "referenceEstimator";
|
||||
@ -749,6 +775,7 @@ export function getFormalWorkspaceTargetKeyForTab(tab: IntakeWorkspaceTab) {
|
||||
}
|
||||
if (tab.kind === "productPatentDashboard") return "productPatentDashboard";
|
||||
if (tab.kind === "barcodePrint") return "barcodePrint";
|
||||
if (tab.kind === "brandFiling") return "barcodePrint";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ export function getWorkspaceTabPageKey(tab: IntakeWorkspaceTab) {
|
||||
if (tab.kind === "adminNodePermission") return "node-permission";
|
||||
if (tab.kind === "productPatentDashboard") return "product-patent:dashboard";
|
||||
if (tab.kind === "barcodePrint") return "barcode-print";
|
||||
if (tab.kind === "brandFiling") return `brand-filing:${tab.brand}`;
|
||||
if (tab.kind === "productPatentLedger") {
|
||||
const target = tab.target;
|
||||
return [
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
|
||||
import type { BomLibraryKey } from "@/app/intake/bom-library/BomLibraryDomain";
|
||||
import type { ProductPatentLedgerTarget } from "@/app/intake/product-patent/ProductPatentDomain";
|
||||
import type { RawMaterialLibraryKey } from "@/app/intake/raw-material-library/RawMaterialLibraryDomain";
|
||||
@ -17,6 +18,7 @@ export type IntakeSection =
|
||||
| "referenceEstimator"
|
||||
| "patents"
|
||||
| "barcodePrint"
|
||||
| "brandFiling"
|
||||
| "adminNodePermission"
|
||||
| null;
|
||||
|
||||
@ -73,6 +75,12 @@ export type IntakeWorkspaceTab =
|
||||
kind: "barcodePrint";
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "brandFiling";
|
||||
label: string;
|
||||
brand: BrandFilingBrand;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "nonIntegratedProduct";
|
||||
@ -141,6 +149,11 @@ export type PersistedIntakeWorkspaceTab =
|
||||
id: string;
|
||||
kind: "barcodePrint";
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "brandFiling";
|
||||
brand: BrandFilingBrand;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "nonIntegratedProduct";
|
||||
|
||||
@ -19,6 +19,10 @@ import {
|
||||
normalizeAssetVariantUvConfig,
|
||||
type AssetVariantUvConfig,
|
||||
} from "@/lib/asset-variant-render-config";
|
||||
import {
|
||||
normalizeBrandFilingData,
|
||||
type BrandFilingProductData,
|
||||
} from "@/app/intake/brand-filing/brandFilingTypes";
|
||||
import { normalizeNonIntegratedAssetUrl } from "./nonIntegratedAssetUrl";
|
||||
import {
|
||||
normalizeAiWhiteBackgroundPartGroups,
|
||||
@ -766,6 +770,7 @@ export type NonIntegratedProductRecord = {
|
||||
id: string;
|
||||
productDevelopmentKind?: "product" | "series";
|
||||
metadata?: Record<string, unknown>;
|
||||
brandFilingData?: BrandFilingProductData;
|
||||
productTag?: string;
|
||||
spu: string;
|
||||
projectStatus: NonIntegratedProjectStatus;
|
||||
@ -2430,6 +2435,7 @@ function toProductRecord(
|
||||
normalizedPreviewImageName,
|
||||
);
|
||||
const metadata = normalizeMetadataRecord(item.metadata);
|
||||
const brandFilingData = normalizeBrandFilingData(item.brandFilingData);
|
||||
const productTag = resolveStoredProductTag(item, metadata);
|
||||
|
||||
return {
|
||||
@ -2437,6 +2443,7 @@ function toProductRecord(
|
||||
productDevelopmentKind:
|
||||
item.productDevelopmentKind === "series" ? "series" : "product",
|
||||
metadata,
|
||||
brandFilingData,
|
||||
...(productTag !== undefined ? { productTag } : {}),
|
||||
spu: item.spu,
|
||||
projectStatus: normalizeNonIntegratedProjectStatus(item.projectStatus),
|
||||
@ -2829,6 +2836,7 @@ export function createCopiedNonIntegratedProduct(
|
||||
|
||||
return {
|
||||
...sourceProduct,
|
||||
brandFilingData: undefined,
|
||||
id: buildNonIntegratedProductId(nextSpu),
|
||||
spu: nextSpu,
|
||||
projectStatus: normalizeNonIntegratedProjectStatus(
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { createEmptyNonIntegratedOpeningReference } from "../../nonIntegratedStorage";
|
||||
import { BasicInfoCreateSection } from "./BasicInfoCreateSection";
|
||||
import { createFormValuesFromProduct } from "./productFormValueHelpers";
|
||||
|
||||
const STRUCTURE_DESIGNER_LABEL = "\u7ed3\u6784\u8bbe\u8ba1\u5e08";
|
||||
|
||||
function renderBasicInfoCreateSection(
|
||||
overrides: Partial<ComponentProps<typeof BasicInfoCreateSection>> = {},
|
||||
) {
|
||||
const props: ComponentProps<typeof BasicInfoCreateSection> = {
|
||||
availableSeriesNameOptions: [],
|
||||
basicInfoDesignerOptions: ["appearance-a", "structure-a", "structure-b"],
|
||||
basicInfoPlanningOwnerOptions: ["planner-a"],
|
||||
basicInfoProductCategoryOptions: [],
|
||||
basicInfoSecondaryCategoryOptions: [],
|
||||
developmentCreateTitle: "create-product",
|
||||
developmentNameLabel: "name",
|
||||
developmentNamePlaceholder: "enter-name",
|
||||
formValues: createFormValuesFromProduct(null),
|
||||
isReadOnly: false,
|
||||
isSeriesComponentCreate: false,
|
||||
isSeriesDevelopment: false,
|
||||
isStockProductCreate: false,
|
||||
openingReference: {
|
||||
...createEmptyNonIntegratedOpeningReference(),
|
||||
structureDesigner: "structure-a",
|
||||
},
|
||||
previewFileInputRef: { current: null },
|
||||
canUseExistingSpuCreateMode: false,
|
||||
createSpuDisplayLabel: "",
|
||||
existingSpuSelectOptions: [],
|
||||
isExistingSpuCreateMode: false,
|
||||
resolvedExistingSpuValue: "",
|
||||
selectedExistingSpu: "",
|
||||
selectedExistingSpuOption: false,
|
||||
seriesCreateEstimatedSpuLabel: "",
|
||||
spuCreateMode: "auto",
|
||||
stockProductSpu: "",
|
||||
onAdaptableBrandsChange: jest.fn(),
|
||||
onExistingSpuSelection: jest.fn(),
|
||||
onOpeningReferenceFieldChange: jest.fn(),
|
||||
onPreviewImageChange: jest.fn(),
|
||||
onPreviewProductImage: jest.fn(),
|
||||
onSpuCreateModeChange: jest.fn(),
|
||||
onStockProductSpuChange: jest.fn(),
|
||||
onUpdateField: jest.fn(),
|
||||
onUpdateManualSeriesProductSpu: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
render(<BasicInfoCreateSection {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe("BasicInfoCreateSection", () => {
|
||||
it("allows editing structure designer in basic info create mode", () => {
|
||||
const props = renderBasicInfoCreateSection();
|
||||
|
||||
const structureDesignerInput = screen.getByLabelText(
|
||||
STRUCTURE_DESIGNER_LABEL,
|
||||
);
|
||||
fireEvent.focus(structureDesignerInput);
|
||||
fireEvent.mouseDown(screen.getByRole("option", { name: "structure-b" }));
|
||||
|
||||
expect(props.onOpeningReferenceFieldChange).toHaveBeenCalledWith(
|
||||
"structureDesigner",
|
||||
"structure-b",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows structure designer for stock product creation too", () => {
|
||||
renderBasicInfoCreateSection({
|
||||
isStockProductCreate: true,
|
||||
stockProductSpu: "CJ0250",
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByLabelText(STRUCTURE_DESIGNER_LABEL),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -150,7 +150,7 @@ export function BasicInfoCreateSection({
|
||||
compact
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
<div className="grid content-start gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid content-start gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
<BasicField
|
||||
label="SPU"
|
||||
required
|
||||
@ -195,6 +195,19 @@ export function BasicInfoCreateSection({
|
||||
onOpeningReferenceFieldChange("designer", value)
|
||||
}
|
||||
/>
|
||||
<BasicField
|
||||
label="结构设计师"
|
||||
type="select"
|
||||
searchableSelect
|
||||
placeholder="请输入结构设计师"
|
||||
options={designerOptions}
|
||||
value={openingReference.structureDesigner}
|
||||
readOnly={isReadOnly}
|
||||
compact
|
||||
onChange={(value) =>
|
||||
onOpeningReferenceFieldChange("structureDesigner", value)
|
||||
}
|
||||
/>
|
||||
<BasicField
|
||||
label="品牌"
|
||||
required
|
||||
@ -288,6 +301,19 @@ export function BasicInfoCreateSection({
|
||||
onOpeningReferenceFieldChange("designer", value)
|
||||
}
|
||||
/>
|
||||
<BasicField
|
||||
label="结构设计师"
|
||||
type="select"
|
||||
searchableSelect
|
||||
placeholder="请输入结构设计师"
|
||||
options={designerOptions}
|
||||
value={openingReference.structureDesigner}
|
||||
readOnly={isReadOnly}
|
||||
compact
|
||||
onChange={(value) =>
|
||||
onOpeningReferenceFieldChange("structureDesigner", value)
|
||||
}
|
||||
/>
|
||||
<BasicField
|
||||
label="品牌"
|
||||
required
|
||||
|
||||
@ -69,6 +69,7 @@ export function useIntakeClientViewModel(
|
||||
initialNodePermissionOverrides: props.initialNodePermissionOverrides,
|
||||
user: props.user,
|
||||
});
|
||||
const canViewBrandFiling = canViewBarcodePrint;
|
||||
const { initialActiveWorkspaceTab, initialWorkspaceState } =
|
||||
useIntakeInitialWorkspaceState(props);
|
||||
const { materialLibraryFinishes, setMaterialLibraryFinishes } =
|
||||
@ -163,6 +164,7 @@ export function useIntakeClientViewModel(
|
||||
closeNewNonIntegratedProductWorkspace,
|
||||
openAdminNodePermissionWorkspace,
|
||||
openBarcodePrintWorkspace,
|
||||
openBrandFilingWorkspace,
|
||||
openBomLibraryWorkspace,
|
||||
openNewNonIntegratedProductWorkspace,
|
||||
openPendingEntryWorkspace,
|
||||
@ -183,6 +185,7 @@ export function useIntakeClientViewModel(
|
||||
canManageSpuRuleLibrary: canManageSpuRules,
|
||||
canUseDesignControlledProductData,
|
||||
canViewBarcodePrint,
|
||||
canViewBrandFiling,
|
||||
canViewBomLibrary,
|
||||
canViewFormalWorkspaceByTarget,
|
||||
canViewProductPatentDashboard,
|
||||
@ -244,6 +247,7 @@ export function useIntakeClientViewModel(
|
||||
canViewProductPatentLedger,
|
||||
canEditProductPatentLedger,
|
||||
canViewBarcodePrint,
|
||||
canViewBrandFiling,
|
||||
}),
|
||||
workspace: buildIntakeWorkspacePanelProps({
|
||||
workspaceTabs,
|
||||
@ -284,6 +288,10 @@ export function useIntakeClientViewModel(
|
||||
}),
|
||||
rightWorkbench: buildIntakeRightWorkbenchProps({
|
||||
activeWorkspaceTabKind: activeWorkspaceTab?.kind ?? null,
|
||||
activeBrandFilingBrand:
|
||||
activeWorkspaceTab?.kind === "brandFiling"
|
||||
? activeWorkspaceTab.brand
|
||||
: null,
|
||||
activeSection,
|
||||
onToggle: (section) => {
|
||||
setActiveWorkspaceTabId(null);
|
||||
@ -301,6 +309,7 @@ export function useIntakeClientViewModel(
|
||||
onOpenProductPatentLedger: openProductPatentLedgerWorkspace,
|
||||
onOpenProductPatentDashboard: openProductPatentDashboardWorkspace,
|
||||
onOpenBarcodePrintWorkspace: openBarcodePrintWorkspace,
|
||||
onOpenBrandFilingWorkspace: openBrandFilingWorkspace,
|
||||
onOpenSpuRuleLibraryWorkspace: canManageSpuRules
|
||||
? openSpuRuleLibraryWorkspace
|
||||
: undefined,
|
||||
@ -318,6 +327,7 @@ export function useIntakeClientViewModel(
|
||||
canViewProductPatentLedger,
|
||||
canViewProductPatentDashboard,
|
||||
canViewBarcodePrint,
|
||||
canViewBrandFiling,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, type Dispatch, type SetStateAction } from "react";
|
||||
import type { BrandFilingBrand } from "@/app/intake/brand-filing/brandFilingConfig";
|
||||
import type { BomLibraryKey } from "@/app/intake/bom-library/BomLibraryDomain";
|
||||
import type {
|
||||
IntakeEntryTab,
|
||||
@ -13,6 +14,7 @@ import type {
|
||||
import {
|
||||
createAdminNodePermissionWorkspaceTab,
|
||||
createBarcodePrintWorkspaceTab,
|
||||
createBrandFilingWorkspaceTab,
|
||||
createBomLibraryWorkspaceTab,
|
||||
createNewNonIntegratedProductWorkspaceTab,
|
||||
createNonIntegratedProductWorkspaceTab,
|
||||
@ -43,6 +45,7 @@ type WorkspaceOpenersParams = {
|
||||
canManageSpuRuleLibrary: boolean;
|
||||
canUseDesignControlledProductData: boolean;
|
||||
canViewBarcodePrint: boolean;
|
||||
canViewBrandFiling: boolean;
|
||||
canViewBomLibrary: boolean;
|
||||
canViewFormalWorkspaceByTarget: (
|
||||
targetKey: IntakeNodePermissionTargetKey,
|
||||
@ -71,6 +74,7 @@ export function useIntakeWorkspaceOpeners({
|
||||
canManageSpuRuleLibrary,
|
||||
canUseDesignControlledProductData,
|
||||
canViewBarcodePrint,
|
||||
canViewBrandFiling,
|
||||
canViewBomLibrary,
|
||||
canViewFormalWorkspaceByTarget,
|
||||
canViewProductPatentDashboard,
|
||||
@ -466,11 +470,28 @@ export function useIntakeWorkspaceOpeners({
|
||||
);
|
||||
}, [canViewBarcodePrint, createWorkspaceTabId, openWorkspaceTab]);
|
||||
|
||||
const openBrandFilingWorkspace = useCallback(
|
||||
(brand: BrandFilingBrand) => {
|
||||
if (!canViewBrandFiling) {
|
||||
return;
|
||||
}
|
||||
|
||||
openWorkspaceTab(
|
||||
createBrandFilingWorkspaceTab({
|
||||
id: createWorkspaceTabId("brand-filing", brand),
|
||||
brand,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[canViewBrandFiling, createWorkspaceTabId, openWorkspaceTab],
|
||||
);
|
||||
|
||||
return {
|
||||
activatePendingEntryListWorkspace,
|
||||
closeNewNonIntegratedProductWorkspace,
|
||||
openAdminNodePermissionWorkspace,
|
||||
openBarcodePrintWorkspace,
|
||||
openBrandFilingWorkspace,
|
||||
openBomLibraryWorkspace,
|
||||
openNewNonIntegratedProductWorkspace,
|
||||
openPendingEntryWorkspace,
|
||||
|
||||
@ -0,0 +1,828 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import ExcelJS from "exceljs";
|
||||
import JSZip from "jszip";
|
||||
import sharp from "sharp";
|
||||
import {
|
||||
getBrandFilingBrandLabel,
|
||||
type BrandFilingBrand,
|
||||
} from "@/app/intake/brand-filing/brandFilingConfig";
|
||||
import {
|
||||
BRAND_FILING_PLANNING_CELL_MAP,
|
||||
BRAND_FILING_PLANNING_ROW_LAYOUT,
|
||||
} from "@/app/intake/brand-filing/brandFilingPlanningTableSchema";
|
||||
import {
|
||||
buildBrandFilingLineItemChannelSummary,
|
||||
buildBrandFilingPlanningDisplayRows,
|
||||
} from "@/app/intake/brand-filing/brandFilingPlanningLineItems";
|
||||
import { normalizeLegacyBrandFilingPlanningRecord } from "@/app/intake/brand-filing/brandFilingProductData";
|
||||
import type {
|
||||
BrandFilingPlanningFieldKey,
|
||||
BrandFilingPlanningRecord,
|
||||
} from "@/app/intake/brand-filing/brandFilingTypes";
|
||||
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
|
||||
import { resolveObjectStorageUrlForServerAccess } from "@/lib/storage";
|
||||
import { AppError, ErrorCode } from "@/server/core/errors";
|
||||
|
||||
const TEMPLATE_FILE_NAME_BY_BRAND: Record<BrandFilingBrand, string> = {
|
||||
gujia: "顾家企划表.xlsx",
|
||||
suofeiya: "索菲亚企划表.xlsx",
|
||||
};
|
||||
|
||||
const NUMERIC_FIELD_KEYS = new Set<BrandFilingPlanningFieldKey>([
|
||||
"retailPriceDemand",
|
||||
"purchasePriceDemand",
|
||||
"annualSalesTarget",
|
||||
"month1Sales",
|
||||
"month2Sales",
|
||||
"month3Sales",
|
||||
"month4To6Sales",
|
||||
"month7To9Sales",
|
||||
"month10To12Sales",
|
||||
]);
|
||||
|
||||
const ROW_DRIVEN_FIELD_KEYS = new Set<BrandFilingPlanningFieldKey>([
|
||||
"itemCode",
|
||||
"retailPriceDemand",
|
||||
"purchasePriceDemand",
|
||||
"sizeRequirement",
|
||||
"materialAndColor",
|
||||
"legacyIterationNote",
|
||||
"seriesName",
|
||||
"annualSalesTarget",
|
||||
"channel",
|
||||
"month1Sales",
|
||||
"month2Sales",
|
||||
"month3Sales",
|
||||
"month4To6Sales",
|
||||
"month7To9Sales",
|
||||
"month10To12Sales",
|
||||
"productManager",
|
||||
"planningOwner",
|
||||
"purchaseOwner",
|
||||
]);
|
||||
|
||||
type PlanningTableDownloadResult = {
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
bytes: Uint8Array;
|
||||
};
|
||||
|
||||
type LoadedPlanningImage = {
|
||||
buffer: Buffer;
|
||||
extension: "png";
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const PLANNING_IMAGE_AREA = {
|
||||
startColumn: 2,
|
||||
endColumn: 10,
|
||||
startRow: 7,
|
||||
endRow: 8,
|
||||
} as const;
|
||||
|
||||
function getTemplatePath(brand: BrandFilingBrand) {
|
||||
return path.join(
|
||||
process.cwd(),
|
||||
"public",
|
||||
"brand-filing",
|
||||
"templates",
|
||||
TEMPLATE_FILE_NAME_BY_BRAND[brand],
|
||||
);
|
||||
}
|
||||
|
||||
function getPlanningRecord(
|
||||
product: NonIntegratedProductRecord,
|
||||
brand: BrandFilingBrand,
|
||||
) {
|
||||
const record = product.brandFilingData?.[brand]?.planningTable;
|
||||
if (!record) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
`${product.spu || product.id} 还没有企划表数据`,
|
||||
);
|
||||
}
|
||||
return normalizeLegacyBrandFilingPlanningRecord(product, record) ?? record;
|
||||
}
|
||||
|
||||
function buildPlanningFileName(params: {
|
||||
brand: BrandFilingBrand;
|
||||
product: NonIntegratedProductRecord;
|
||||
record: BrandFilingPlanningRecord;
|
||||
}) {
|
||||
const brandLabel = getBrandFilingBrandLabel(params.brand);
|
||||
const safeSpu = params.product.spu.trim() || params.product.id;
|
||||
const sequenceText = params.record.sequenceNumber
|
||||
? String(params.record.sequenceNumber)
|
||||
: "0";
|
||||
return `${sequenceText}-${safeSpu}-${brandLabel}企划表.xlsx`;
|
||||
}
|
||||
|
||||
function coerceCellValue(key: BrandFilingPlanningFieldKey, rawValue: string) {
|
||||
const trimmedValue = rawValue.trim();
|
||||
if (!trimmedValue) return "";
|
||||
|
||||
if (key === "launchDate") {
|
||||
const date = new Date(trimmedValue);
|
||||
return Number.isNaN(date.getTime()) ? trimmedValue : date;
|
||||
}
|
||||
|
||||
if (NUMERIC_FIELD_KEYS.has(key)) {
|
||||
const numericValue = Number(trimmedValue.replace(/,/g, ""));
|
||||
return Number.isFinite(numericValue) ? numericValue : trimmedValue;
|
||||
}
|
||||
|
||||
return trimmedValue;
|
||||
}
|
||||
|
||||
async function loadPlanningImage(imageUrl: string) {
|
||||
const trimmedUrl = imageUrl.trim();
|
||||
if (!trimmedUrl) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(resolvePlanningImageUrl(trimmedUrl));
|
||||
if (!response.ok) return null;
|
||||
|
||||
const imageBuffer = Buffer.from(await response.arrayBuffer());
|
||||
const { data: pngBuffer, info } = await sharp(imageBuffer, {
|
||||
animated: true,
|
||||
})
|
||||
.rotate()
|
||||
.trim({
|
||||
background: {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
alpha: 1,
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
if (!info.width || !info.height) return null;
|
||||
|
||||
return {
|
||||
buffer: pngBuffer,
|
||||
extension: "png",
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
} satisfies LoadedPlanningImage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePlanningImageUrl(imageUrl: string) {
|
||||
const storageUrl = resolveObjectStorageUrlForServerAccess(imageUrl);
|
||||
if (storageUrl !== imageUrl) {
|
||||
return storageUrl;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(imageUrl)) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
const appBaseUrl =
|
||||
process.env.NEXT_PUBLIC_APP_URL ||
|
||||
process.env.NEXTAUTH_URL ||
|
||||
"http://127.0.0.1:3001";
|
||||
|
||||
try {
|
||||
return new URL(imageUrl, appBaseUrl).toString();
|
||||
} catch {
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function clearPlanningImageArea(worksheet: ExcelJS.Worksheet) {
|
||||
const columns = ["B", "C", "D", "E", "F", "G", "H", "I", "J"];
|
||||
for (let row = 7; row <= 8; row += 1) {
|
||||
columns.forEach((column) => {
|
||||
worksheet.getCell(`${column}${row}`).value = "";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getWorksheetColumnPixelWidth(
|
||||
worksheet: ExcelJS.Worksheet,
|
||||
columnNumber: number,
|
||||
) {
|
||||
const width = worksheet.getColumn(columnNumber).width ?? 8.43;
|
||||
return Math.max(1, Math.round(width * 7 + 5));
|
||||
}
|
||||
|
||||
function getWorksheetRowPixelHeight(
|
||||
worksheet: ExcelJS.Worksheet,
|
||||
rowNumber: number,
|
||||
) {
|
||||
const height = worksheet.getRow(rowNumber).height ?? 15;
|
||||
return Math.max(1, Math.round((height * 96) / 72));
|
||||
}
|
||||
|
||||
function getPlanningImageAreaPixels(worksheet: ExcelJS.Worksheet) {
|
||||
let width = 0;
|
||||
for (
|
||||
let columnNumber = PLANNING_IMAGE_AREA.startColumn;
|
||||
columnNumber <= PLANNING_IMAGE_AREA.endColumn;
|
||||
columnNumber += 1
|
||||
) {
|
||||
width += getWorksheetColumnPixelWidth(worksheet, columnNumber);
|
||||
}
|
||||
|
||||
let height = 0;
|
||||
for (
|
||||
let rowNumber = PLANNING_IMAGE_AREA.startRow;
|
||||
rowNumber <= PLANNING_IMAGE_AREA.endRow;
|
||||
rowNumber += 1
|
||||
) {
|
||||
height += getWorksheetRowPixelHeight(worksheet, rowNumber);
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function resolveImageAnchorCoordinate(params: {
|
||||
worksheet: ExcelJS.Worksheet;
|
||||
axis: "column" | "row";
|
||||
start: number;
|
||||
end: number;
|
||||
offset: number;
|
||||
}) {
|
||||
let coordinate = params.start - 1;
|
||||
let remainingOffset = Math.max(0, params.offset);
|
||||
|
||||
for (let index = params.start; index <= params.end; index += 1) {
|
||||
const size =
|
||||
params.axis === "column"
|
||||
? getWorksheetColumnPixelWidth(params.worksheet, index)
|
||||
: getWorksheetRowPixelHeight(params.worksheet, index);
|
||||
|
||||
if (remainingOffset >= size) {
|
||||
coordinate += 1;
|
||||
remainingOffset -= size;
|
||||
continue;
|
||||
}
|
||||
|
||||
coordinate += remainingOffset / size;
|
||||
return coordinate;
|
||||
}
|
||||
|
||||
return coordinate;
|
||||
}
|
||||
|
||||
function placePlanningImage(params: {
|
||||
worksheet: ExcelJS.Worksheet;
|
||||
workbook: ExcelJS.Workbook;
|
||||
image: LoadedPlanningImage;
|
||||
}) {
|
||||
const { worksheet, workbook, image } = params;
|
||||
const area = getPlanningImageAreaPixels(worksheet);
|
||||
const scale = Math.min(area.width / image.width, area.height / image.height);
|
||||
const renderWidth = Math.max(1, Math.round(image.width * scale));
|
||||
const renderHeight = Math.max(1, Math.round(image.height * scale));
|
||||
const offsetX = Math.max(0, Math.round((area.width - renderWidth) / 2));
|
||||
const offsetY = Math.max(0, Math.round((area.height - renderHeight) / 2));
|
||||
|
||||
const imageId = workbook.addImage({
|
||||
buffer: image.buffer as never,
|
||||
extension: image.extension,
|
||||
} as never);
|
||||
|
||||
worksheet.addImage(imageId, {
|
||||
tl: {
|
||||
col: resolveImageAnchorCoordinate({
|
||||
worksheet,
|
||||
axis: "column",
|
||||
start: PLANNING_IMAGE_AREA.startColumn,
|
||||
end: PLANNING_IMAGE_AREA.endColumn,
|
||||
offset: offsetX,
|
||||
}),
|
||||
row: resolveImageAnchorCoordinate({
|
||||
worksheet,
|
||||
axis: "row",
|
||||
start: PLANNING_IMAGE_AREA.startRow,
|
||||
end: PLANNING_IMAGE_AREA.endRow,
|
||||
offset: offsetY,
|
||||
}),
|
||||
},
|
||||
ext: {
|
||||
width: renderWidth,
|
||||
height: renderHeight,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function insertStyledRows(
|
||||
worksheet: ExcelJS.Worksheet,
|
||||
sourceRow: number,
|
||||
rowCount: number,
|
||||
) {
|
||||
if (rowCount <= 0) return;
|
||||
worksheet.duplicateRow(sourceRow, rowCount, true);
|
||||
}
|
||||
|
||||
const DYNAMIC_TEMPLATE_MERGE_REFS = [
|
||||
"B11:C11",
|
||||
"D11:E11",
|
||||
"F11:G11",
|
||||
"H11:I11",
|
||||
"J11:L11",
|
||||
"A12:L12",
|
||||
"A13:A14",
|
||||
"B13:B14",
|
||||
"C13:D13",
|
||||
"E13:L13",
|
||||
"I14:J14",
|
||||
"K14:L14",
|
||||
"A15:B15",
|
||||
"I15:J15",
|
||||
"K15:L15",
|
||||
"A16:L16",
|
||||
"B17:D17",
|
||||
"F17:H17",
|
||||
"J17:L17",
|
||||
"B18:L18",
|
||||
] as const;
|
||||
|
||||
type CellStyleSnapshot = {
|
||||
numFmt: ExcelJS.Cell["numFmt"];
|
||||
font: ExcelJS.Cell["font"];
|
||||
alignment: ExcelJS.Cell["alignment"];
|
||||
fill: ExcelJS.Cell["fill"];
|
||||
border: ExcelJS.Cell["border"];
|
||||
protection: ExcelJS.Cell["protection"];
|
||||
};
|
||||
|
||||
function cloneCellStylePart<T>(value: T): T {
|
||||
if (value == null) {
|
||||
return value;
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function captureCellStyle(cell: ExcelJS.Cell): CellStyleSnapshot {
|
||||
return {
|
||||
numFmt: cell.numFmt,
|
||||
font: cloneCellStylePart(cell.font),
|
||||
alignment: cloneCellStylePart(cell.alignment),
|
||||
fill: cloneCellStylePart(cell.fill),
|
||||
border: cloneCellStylePart(cell.border),
|
||||
protection: cloneCellStylePart(cell.protection),
|
||||
};
|
||||
}
|
||||
|
||||
function restoreCellStyle(cell: ExcelJS.Cell, snapshot: CellStyleSnapshot) {
|
||||
cell.numFmt = snapshot.numFmt;
|
||||
cell.font = cloneCellStylePart(snapshot.font);
|
||||
cell.alignment = cloneCellStylePart(snapshot.alignment);
|
||||
cell.fill = cloneCellStylePart(snapshot.fill);
|
||||
cell.border = cloneCellStylePart(snapshot.border);
|
||||
cell.protection = cloneCellStylePart(snapshot.protection);
|
||||
}
|
||||
|
||||
function parseCellAddress(address: string) {
|
||||
const match = /^([A-Z]+)(\d+)$/i.exec(address.trim());
|
||||
if (!match) {
|
||||
throw new Error(`Unsupported cell address: ${address}`);
|
||||
}
|
||||
return {
|
||||
column: match[1]!.toUpperCase(),
|
||||
row: Number.parseInt(match[2]!, 10),
|
||||
};
|
||||
}
|
||||
|
||||
function columnNameToNumber(columnName: string) {
|
||||
let value = 0;
|
||||
for (const character of columnName) {
|
||||
value = value * 26 + (character.charCodeAt(0) - 64);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function columnNumberToName(columnNumber: number) {
|
||||
let value = columnNumber;
|
||||
let columnName = "";
|
||||
|
||||
while (value > 0) {
|
||||
const remainder = (value - 1) % 26;
|
||||
columnName = String.fromCharCode(65 + remainder) + columnName;
|
||||
value = Math.floor((value - 1) / 26);
|
||||
}
|
||||
|
||||
return columnName;
|
||||
}
|
||||
|
||||
function expandRangeAddresses(ref: string) {
|
||||
const [startAddress, endAddress = startAddress] = ref.split(":");
|
||||
const start = parseCellAddress(startAddress);
|
||||
const end = parseCellAddress(endAddress);
|
||||
const addresses: string[] = [];
|
||||
|
||||
for (let row = start.row; row <= end.row; row += 1) {
|
||||
for (
|
||||
let columnNumber = columnNameToNumber(start.column);
|
||||
columnNumber <= columnNameToNumber(end.column);
|
||||
columnNumber += 1
|
||||
) {
|
||||
addresses.push(`${columnNumberToName(columnNumber)}${row}`);
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
function clearDynamicTemplateMerges(worksheet: ExcelJS.Worksheet) {
|
||||
DYNAMIC_TEMPLATE_MERGE_REFS.forEach((ref) => {
|
||||
const styleSnapshots = expandRangeAddresses(ref).map((address) => ({
|
||||
address,
|
||||
style: captureCellStyle(worksheet.getCell(address)),
|
||||
}));
|
||||
try {
|
||||
worksheet.unMergeCells(ref);
|
||||
styleSnapshots.forEach(({ address, style }) => {
|
||||
restoreCellStyle(worksheet.getCell(address), style);
|
||||
});
|
||||
} catch {
|
||||
// Skip refs that are already unmerged after row duplication.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyMergeRefs(
|
||||
worksheet: ExcelJS.Worksheet,
|
||||
mergeRefs: readonly string[],
|
||||
) {
|
||||
mergeRefs.forEach((ref) => {
|
||||
const styleSnapshots = expandRangeAddresses(ref).map((address) => ({
|
||||
address,
|
||||
style: captureCellStyle(worksheet.getCell(address)),
|
||||
}));
|
||||
worksheet.mergeCells(ref);
|
||||
styleSnapshots.forEach(({ address, style }) => {
|
||||
restoreCellStyle(worksheet.getCell(address), style);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setCellValue(
|
||||
worksheet: ExcelJS.Worksheet,
|
||||
address: string,
|
||||
key: BrandFilingPlanningFieldKey,
|
||||
rawValue: string,
|
||||
) {
|
||||
worksheet.getCell(address).value = coerceCellValue(key, rawValue);
|
||||
}
|
||||
|
||||
function setCellCenterAlignment(
|
||||
worksheet: ExcelJS.Worksheet,
|
||||
address: string,
|
||||
) {
|
||||
const cell = worksheet.getCell(address);
|
||||
cell.alignment = {
|
||||
...cell.alignment,
|
||||
horizontal: "center",
|
||||
vertical: "middle",
|
||||
};
|
||||
}
|
||||
|
||||
function applyCenterAcrossRange(
|
||||
worksheet: ExcelJS.Worksheet,
|
||||
ref: string,
|
||||
) {
|
||||
const addresses = expandRangeAddresses(ref);
|
||||
if (addresses.length === 0) return;
|
||||
|
||||
const firstCell = worksheet.getCell(addresses[0]!);
|
||||
firstCell.alignment = {
|
||||
...firstCell.alignment,
|
||||
horizontal: "centerContinuous",
|
||||
vertical: "middle",
|
||||
};
|
||||
}
|
||||
|
||||
function writePlanningSectionRows(params: {
|
||||
worksheet: ExcelJS.Worksheet;
|
||||
product: NonIntegratedProductRecord;
|
||||
record: BrandFilingPlanningRecord;
|
||||
}) {
|
||||
const { worksheet, product, record } = params;
|
||||
const displayRows = buildBrandFilingPlanningDisplayRows({
|
||||
product,
|
||||
form: record.form,
|
||||
lineItems: record.lineItems ?? [],
|
||||
});
|
||||
const rowCount = Math.max(displayRows.length, 1);
|
||||
const additionalPlanRows = rowCount - 1;
|
||||
const planDataStartRow = BRAND_FILING_PLANNING_ROW_LAYOUT.plan.dataStartRow;
|
||||
|
||||
clearDynamicTemplateMerges(worksheet);
|
||||
insertStyledRows(worksheet, planDataStartRow, additionalPlanRows);
|
||||
|
||||
const promotionSectionTitleRow =
|
||||
BRAND_FILING_PLANNING_ROW_LAYOUT.promotion.sectionTitleRow + additionalPlanRows;
|
||||
const promotionHeaderTopRow =
|
||||
BRAND_FILING_PLANNING_ROW_LAYOUT.promotion.headerTopRow + additionalPlanRows;
|
||||
const promotionHeaderBottomRow =
|
||||
BRAND_FILING_PLANNING_ROW_LAYOUT.promotion.headerBottomRow + additionalPlanRows;
|
||||
const promotionDataStartRow =
|
||||
BRAND_FILING_PLANNING_ROW_LAYOUT.promotion.dataStartRow + additionalPlanRows;
|
||||
|
||||
const additionalPromotionRows = rowCount - 1;
|
||||
insertStyledRows(
|
||||
worksheet,
|
||||
promotionDataStartRow,
|
||||
additionalPromotionRows,
|
||||
);
|
||||
|
||||
const signSectionTitleRow =
|
||||
BRAND_FILING_PLANNING_ROW_LAYOUT.sign.sectionTitleRow +
|
||||
additionalPlanRows +
|
||||
additionalPromotionRows;
|
||||
const signDataRow =
|
||||
BRAND_FILING_PLANNING_ROW_LAYOUT.sign.dataRow +
|
||||
additionalPlanRows +
|
||||
additionalPromotionRows;
|
||||
const channelFooterRow =
|
||||
BRAND_FILING_PLANNING_ROW_LAYOUT.sign.channelFooterRow +
|
||||
additionalPlanRows +
|
||||
additionalPromotionRows;
|
||||
|
||||
for (let offset = 0; offset < rowCount; offset += 1) {
|
||||
const rowNumber = planDataStartRow + offset;
|
||||
applyMergeRefs(
|
||||
worksheet,
|
||||
BRAND_FILING_PLANNING_ROW_LAYOUT.plan.mergedRanges.map(
|
||||
([startColumn, endColumn]) =>
|
||||
`${startColumn}${rowNumber}:${endColumn}${rowNumber}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
applyMergeRefs(worksheet, [
|
||||
`A${promotionSectionTitleRow}:L${promotionSectionTitleRow}`,
|
||||
`A${promotionHeaderTopRow}:A${promotionHeaderBottomRow}`,
|
||||
`B${promotionHeaderTopRow}:B${promotionHeaderBottomRow}`,
|
||||
`C${promotionHeaderTopRow}:D${promotionHeaderTopRow}`,
|
||||
]);
|
||||
applyCenterAcrossRange(
|
||||
worksheet,
|
||||
`E${promotionHeaderTopRow}:L${promotionHeaderTopRow}`,
|
||||
);
|
||||
applyCenterAcrossRange(
|
||||
worksheet,
|
||||
`I${promotionHeaderBottomRow}:J${promotionHeaderBottomRow}`,
|
||||
);
|
||||
applyCenterAcrossRange(
|
||||
worksheet,
|
||||
`K${promotionHeaderBottomRow}:L${promotionHeaderBottomRow}`,
|
||||
);
|
||||
|
||||
for (let offset = 0; offset < rowCount; offset += 1) {
|
||||
const rowNumber = promotionDataStartRow + offset;
|
||||
applyMergeRefs(
|
||||
worksheet,
|
||||
BRAND_FILING_PLANNING_ROW_LAYOUT.promotion.mergedRanges.map(
|
||||
([startColumn, endColumn]) =>
|
||||
`${startColumn}${rowNumber}:${endColumn}${rowNumber}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
applyMergeRefs(worksheet, [
|
||||
`A${signSectionTitleRow}:L${signSectionTitleRow}`,
|
||||
`B${signDataRow}:D${signDataRow}`,
|
||||
`F${signDataRow}:H${signDataRow}`,
|
||||
`J${signDataRow}:L${signDataRow}`,
|
||||
`B${channelFooterRow}:L${channelFooterRow}`,
|
||||
]);
|
||||
|
||||
displayRows.forEach((displayRow, index) => {
|
||||
const planRowNumber = planDataStartRow + index;
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`A${planRowNumber}`,
|
||||
"itemCode",
|
||||
displayRow.itemCode,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`B${planRowNumber}`,
|
||||
"retailPriceDemand",
|
||||
displayRow.retailPriceDemand,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`D${planRowNumber}`,
|
||||
"purchasePriceDemand",
|
||||
displayRow.purchasePriceDemand,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`F${planRowNumber}`,
|
||||
"sizeRequirement",
|
||||
displayRow.sizeRequirement,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`H${planRowNumber}`,
|
||||
"materialAndColor",
|
||||
displayRow.materialAndColor,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`J${planRowNumber}`,
|
||||
"legacyIterationNote",
|
||||
record.form.legacyIterationNote,
|
||||
);
|
||||
["A", "B", "D", "F", "H", "J"].forEach((column) => {
|
||||
setCellCenterAlignment(worksheet, `${column}${planRowNumber}`);
|
||||
});
|
||||
|
||||
const promotionRowNumber = promotionDataStartRow + index;
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`A${promotionRowNumber}`,
|
||||
"seriesName",
|
||||
displayRow.seriesName,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`B${promotionRowNumber}`,
|
||||
"itemCode",
|
||||
displayRow.itemCode,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`C${promotionRowNumber}`,
|
||||
"annualSalesTarget",
|
||||
displayRow.annualSalesTarget,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`D${promotionRowNumber}`,
|
||||
"channel",
|
||||
displayRow.channel,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`E${promotionRowNumber}`,
|
||||
"month1Sales",
|
||||
displayRow.month1Sales,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`F${promotionRowNumber}`,
|
||||
"month2Sales",
|
||||
displayRow.month2Sales,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`G${promotionRowNumber}`,
|
||||
"month3Sales",
|
||||
displayRow.month3Sales,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`H${promotionRowNumber}`,
|
||||
"month4To6Sales",
|
||||
displayRow.month4To6Sales,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`I${promotionRowNumber}`,
|
||||
"month7To9Sales",
|
||||
displayRow.month7To9Sales,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`K${promotionRowNumber}`,
|
||||
"month10To12Sales",
|
||||
displayRow.month10To12Sales,
|
||||
);
|
||||
["A", "B", "C", "D", "E", "F", "G", "H", "I", "K"].forEach((column) => {
|
||||
setCellCenterAlignment(worksheet, `${column}${promotionRowNumber}`);
|
||||
});
|
||||
});
|
||||
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`B${signDataRow}`,
|
||||
"productManager",
|
||||
record.form.productManager,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`F${signDataRow}`,
|
||||
"planningOwner",
|
||||
record.form.planningOwner,
|
||||
);
|
||||
setCellValue(
|
||||
worksheet,
|
||||
`J${signDataRow}`,
|
||||
"purchaseOwner",
|
||||
record.form.purchaseOwner,
|
||||
);
|
||||
["B", "F", "J"].forEach((column) => {
|
||||
setCellCenterAlignment(worksheet, `${column}${signDataRow}`);
|
||||
});
|
||||
|
||||
worksheet.getCell(`B${channelFooterRow}`).value =
|
||||
buildBrandFilingLineItemChannelSummary(record.lineItems ?? []) ||
|
||||
record.form.channel.trim();
|
||||
setCellCenterAlignment(worksheet, `B${channelFooterRow}`);
|
||||
}
|
||||
|
||||
async function buildPlanningWorkbookBytes(params: {
|
||||
brand: BrandFilingBrand;
|
||||
product: NonIntegratedProductRecord;
|
||||
record: BrandFilingPlanningRecord;
|
||||
}) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const templateBytes = await fs.readFile(getTemplatePath(params.brand));
|
||||
await workbook.xlsx.load(templateBytes as never);
|
||||
|
||||
const worksheet = workbook.getWorksheet("Sheet1") ?? workbook.worksheets[0];
|
||||
if (!worksheet) {
|
||||
throw new AppError(ErrorCode.SYSTEM_INTERNAL, "企划表模板读取失败");
|
||||
}
|
||||
|
||||
clearPlanningImageArea(worksheet);
|
||||
|
||||
Object.entries(BRAND_FILING_PLANNING_CELL_MAP).forEach(([key, address]) => {
|
||||
const fieldKey = key as Exclude<BrandFilingPlanningFieldKey, "imageUrl">;
|
||||
if (ROW_DRIVEN_FIELD_KEYS.has(fieldKey)) return;
|
||||
worksheet.getCell(address).value = coerceCellValue(
|
||||
fieldKey,
|
||||
params.record.form[fieldKey],
|
||||
);
|
||||
});
|
||||
|
||||
writePlanningSectionRows({
|
||||
worksheet,
|
||||
product: params.product,
|
||||
record: params.record,
|
||||
});
|
||||
|
||||
const planningImage = await loadPlanningImage(params.record.form.imageUrl);
|
||||
if (planningImage) {
|
||||
placePlanningImage({
|
||||
worksheet,
|
||||
workbook,
|
||||
image: planningImage,
|
||||
});
|
||||
}
|
||||
|
||||
const output = await workbook.xlsx.writeBuffer();
|
||||
return output instanceof Uint8Array ? output : new Uint8Array(output);
|
||||
}
|
||||
|
||||
export async function buildBrandFilingPlanningTableDownload(params: {
|
||||
brand: BrandFilingBrand;
|
||||
products: NonIntegratedProductRecord[];
|
||||
}): Promise<PlanningTableDownloadResult> {
|
||||
if (params.products.length === 0) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, "请先选择产品");
|
||||
}
|
||||
|
||||
const files = await Promise.all(
|
||||
params.products.map(async (product) => {
|
||||
const record = getPlanningRecord(product, params.brand);
|
||||
return {
|
||||
fileName: buildPlanningFileName({
|
||||
brand: params.brand,
|
||||
product,
|
||||
record,
|
||||
}),
|
||||
bytes: await buildPlanningWorkbookBytes({
|
||||
brand: params.brand,
|
||||
product,
|
||||
record,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (files.length === 1) {
|
||||
return {
|
||||
fileName: files[0]!.fileName,
|
||||
contentType:
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
bytes: files[0]!.bytes,
|
||||
};
|
||||
}
|
||||
|
||||
const brandLabel = getBrandFilingBrandLabel(params.brand);
|
||||
const zip = new JSZip();
|
||||
const folder = zip.folder(`${brandLabel}企划表`) ?? zip;
|
||||
|
||||
files.forEach((file) => {
|
||||
folder.file(file.fileName, file.bytes);
|
||||
});
|
||||
|
||||
return {
|
||||
fileName: `${brandLabel}企划表.zip`,
|
||||
contentType: "application/zip",
|
||||
bytes: await zip.generateAsync({ type: "uint8array" }),
|
||||
};
|
||||
}
|
||||
@ -1125,6 +1125,7 @@ function preserveWorkspaceCategoryFromProduct(
|
||||
return {
|
||||
...preservedProduct,
|
||||
planningLayoutRows: sourceProduct.planningLayoutRows,
|
||||
brandFilingData: sourceProduct.brandFilingData,
|
||||
} as SharedNonIntegratedProductRecord
|
||||
case FACTORY_QUALITY_WORKSPACE_CATEGORY:
|
||||
return {
|
||||
@ -1273,6 +1274,11 @@ function preserveWorkspaceCategoryFromPayload(
|
||||
fallbackProduct,
|
||||
'planningLayoutRows',
|
||||
),
|
||||
brandFilingData: getSourcePayloadValue(
|
||||
sourcePayload,
|
||||
fallbackProduct,
|
||||
'brandFilingData',
|
||||
),
|
||||
} as SharedNonIntegratedProductRecord
|
||||
case FACTORY_QUALITY_WORKSPACE_CATEGORY:
|
||||
return {
|
||||
@ -1562,8 +1568,14 @@ function getChangedWorkspaceCategories(
|
||||
addChangedWorkspaceCategory(
|
||||
categories,
|
||||
PLANNING_LAYOUT_WORKSPACE_CATEGORY,
|
||||
existingProduct.planningLayoutRows,
|
||||
nextProduct.planningLayoutRows,
|
||||
{
|
||||
planningLayoutRows: existingProduct.planningLayoutRows,
|
||||
brandFilingData: existingProduct.brandFilingData,
|
||||
},
|
||||
{
|
||||
planningLayoutRows: nextProduct.planningLayoutRows,
|
||||
brandFilingData: nextProduct.brandFilingData,
|
||||
},
|
||||
)
|
||||
addChangedWorkspaceCategory(
|
||||
categories,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user