feat(intake): paginate product patent ledger

This commit is contained in:
WuYongJie 2026-08-14 14:09:23 +08:00
parent c3566c4b83
commit 16b43cba11
8 changed files with 225 additions and 7 deletions

View File

@ -0,0 +1,19 @@
import { buildIntakePrimaryDomainProps } from "./intakeClientViewProps";
import type { NonIntegratedProductRecord } from "./non-integrated/nonIntegratedStorage";
describe("buildIntakePrimaryDomainProps", () => {
it("uses the full internal product list for the product patent detail", () => {
const pagedProducts = [{ id: "page-one-product" }] as NonIntegratedProductRecord[];
const allInternalProducts = [
...pagedProducts,
{ id: "later-page-product" },
] as NonIntegratedProductRecord[];
const props = buildIntakePrimaryDomainProps({
nonIntegratedProducts: pagedProducts,
productDevelopmentProgressProducts: allInternalProducts,
} as Parameters<typeof buildIntakePrimaryDomainProps>[0]);
expect(props.productPatent.products).toBe(allInternalProducts);
});
});

View File

@ -206,7 +206,7 @@ export function buildIntakePrimaryDomainProps(params: {
},
canViewProductPatentLedger: params.canViewProductPatentLedger,
productPatent: {
products: params.nonIntegratedProducts,
products: params.productDevelopmentProgressProducts,
user: params.user,
readOnly: !params.canEditProductPatentLedger,
},

View File

@ -57,4 +57,21 @@ describe("ProductProgressPagination", () => {
expect(onPageChange).toHaveBeenCalledWith(4);
});
it("uses the compact transparent layout when floating", () => {
render(
<ProductProgressPagination
currentPage={1}
totalPages={1}
totalRows={20}
pageSize={50}
variant="floating"
onPageChange={jest.fn()}
/>,
);
expect(screen.getByTestId("product-progress-pagination")).toHaveClass(
"bg-transparent",
);
});
});

View File

@ -8,12 +8,14 @@ export function ProductProgressPagination({
totalPages,
totalRows,
pageSize,
variant = "default",
onPageChange,
}: {
currentPage: number;
totalPages: number;
totalRows: number;
pageSize: number;
variant?: "default" | "floating";
onPageChange: (page: number) => void;
}) {
const safeTotalPages = Math.max(1, totalPages);
@ -35,7 +37,14 @@ export function ProductProgressPagination({
}
return (
<div className="flex min-h-10 items-center justify-center gap-3 bg-apple-bg-secondary/45 px-3 text-[11px] font-apple-medium text-apple-label-secondary">
<div
data-testid="product-progress-pagination"
className={`flex items-center justify-center gap-3 text-[11px] font-apple-medium text-apple-label-secondary ${
variant === "floating"
? "min-h-7 bg-transparent px-1 py-0"
: "min-h-10 bg-apple-bg-secondary/45 px-3"
}`}
>
<button
type="button"
title="上一页"

View File

@ -0,0 +1,90 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import { clearProductPatentRecordsRuntimeCache } from "./productPatentStorage";
import { ProductPatentDashboard } from "./ProductPatentDashboard";
function createPatentProduct(index: number): NonIntegratedProductRecord {
return {
id: `patent-product-${index}`,
spu: `PATENT-${String(index).padStart(3, "0")}`,
formalSpuAssigned: true,
brand: "普通大货",
designer: "测试设计师",
previewImage: "",
previewImageName: "",
openingReference: {
productName: `测试专利产品 ${index}`,
productImage: "",
productImageName: "",
},
patentApplication: {
shouldApply: "申请",
patentName: `测试专利 ${index}`,
patentStatus: "待确认",
saRequired: "否",
salesStatus: "在售",
designer: "测试设计师",
applicationNo: "",
applicationDate: "",
applicationResult: "",
resultDate: "",
patentNo: "",
firstFeeDate: "",
nextFeeDueDate: "",
feeAmount: "",
owner: "",
note: "",
},
} as NonIntegratedProductRecord;
}
describe("ProductPatentDashboard pagination", () => {
const originalFetch = global.fetch;
beforeEach(() => {
clearProductPatentRecordsRuntimeCache();
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, data: { records: [] } }),
}) as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
});
it("shows 50 records first and jumps to the entered page", async () => {
const user = userEvent.setup();
const products = Array.from({ length: 51 }, (_, index) =>
createPatentProduct(index + 1),
);
render(
<ProductPatentDashboard
products={products}
user={{ id: "user-1", name: "管理员", email: "", role: "管理员" }}
/>,
);
expect(screen.getAllByTestId(/^product-patent-record-/)).toHaveLength(50);
expect(screen.getByTestId("product-patent-table-viewport")).toHaveClass(
"overflow-auto",
);
expect(screen.getByTestId("product-patent-pagination")).toHaveClass(
"absolute",
"bottom-5",
);
const pageInput = screen.getByLabelText("跳转页码");
await user.clear(pageInput);
await user.type(pageInput, "2");
await user.keyboard("{Enter}");
expect(screen.getAllByTestId(/^product-patent-record-/)).toHaveLength(1);
expect(
screen.getByTestId("product-patent-record-product-patent-group-PATENT-051"),
).toBeInTheDocument();
expect(screen.getByText("51", { selector: "td" })).toBeInTheDocument();
});
});

View File

@ -3,6 +3,7 @@
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
import { Download, ShieldCheck, Upload } from "lucide-react";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import { ProductProgressPagination } from "@/app/intake/product-development/progress-dashboard/ProductProgressPagination";
import {
getNextProductPatentFeeDueDate,
listProductPatentRecords,
@ -34,6 +35,10 @@ import {
isPatentAnnualFeeLocked,
} from "./productPatentBoardHelpers";
import { PatentField } from "./ProductPatentComponents";
import {
getProductPatentPage,
PRODUCT_PATENT_PAGE_SIZE,
} from "./productPatentPagination";
import type { ProductPatentDomainUser, ProductPatentLedgerTarget } from "./productPatentTypes";
export function ProductPatentDashboard({
@ -63,6 +68,7 @@ export function ProductPatentDashboard({
const [decisionFilter, setDecisionFilter] = useState<
ProductPatentContinueDecision | "全部"
>("全部");
const [pageByFilter, setPageByFilter] = useState<Record<string, number>>({});
const [activeAnnualFeeYears, setActiveAnnualFeeYears] = useState<
Partial<Record<string, (typeof PRODUCT_PATENT_FEE_YEARS)[number]>>
>({});
@ -262,6 +268,24 @@ export function ProductPatentDashboard({
: filteredRecords,
[filteredRecords, readOnly, target, targetMatchedRecord],
);
const paginationKey = useMemo(
() =>
JSON.stringify({
spu: query.spu,
patentNo: query.patentNo,
patentName: query.patentName,
designer: query.designer,
statusFilter,
decisionFilter,
}),
[decisionFilter, query, statusFilter],
);
const currentPage = pageByFilter[paginationKey] ?? 1;
const patentPage = useMemo(
() => getProductPatentPage(visiblePatentRecords, currentPage),
[currentPage, visiblePatentRecords],
);
const selectedRecord =
readOnly && target
? targetMatchedRecord
@ -480,8 +504,8 @@ export function ProductPatentDashboard({
</div>
</div>
<div className="min-h-0 flex-1 p-4">
<section className="flex min-h-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-secondary/20">
<div className="min-h-0 flex-1 overflow-hidden p-4">
<section className="relative flex h-full min-h-0 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-secondary/20">
<div className="grid gap-2 border-b border-apple-separator p-3 md:grid-cols-[repeat(4,minmax(130px,1fr))_160px_160px]">
<label className="block text-xs text-apple-label-secondary">
SPU
@ -586,7 +610,10 @@ export function ProductPatentDashboard({
</select>
</label>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<div
data-testid="product-patent-table-viewport"
className="min-h-0 flex-1 overflow-auto pb-16"
>
<table className="min-w-[3060px] w-full border-collapse text-left text-xs">
<thead className="sticky top-0 z-10 bg-apple-bg-primary text-apple-label-secondary">
<tr className="border-b border-apple-separator">
@ -630,7 +657,7 @@ export function ProductPatentDashboard({
</td>
</tr>
) : (
visiblePatentRecords.map((record, index) => (
patentPage.records.map((record, index) => (
<tr
key={record.id}
ref={
@ -647,7 +674,7 @@ export function ProductPatentDashboard({
onFocus={() => setSelectedRecordId(record.id)}
>
<td className="w-[64px] border-r border-apple-separator px-2 py-1 text-apple-label-secondary">
{index + 1}
{patentPage.rowNumberStart + index + 1}
</td>
<td className="w-[112px] border-r border-apple-separator p-0">
<select
@ -1020,6 +1047,26 @@ export function ProductPatentDashboard({
</tbody>
</table>
</div>
<div
data-testid="product-patent-pagination"
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 rounded-full border border-apple-separator bg-white/92 px-2 py-1.5 shadow-[0_12px_32px_rgba(15,23,42,0.12)] backdrop-blur-xl">
<ProductProgressPagination
currentPage={patentPage.currentPage}
totalPages={patentPage.totalPages}
totalRows={visiblePatentRecords.length}
pageSize={PRODUCT_PATENT_PAGE_SIZE}
variant="floating"
onPageChange={(page) =>
setPageByFilter((current) => ({
...current,
[paginationKey]: page,
}))
}
/>
</div>
</div>
</section>
<section className="hidden">

View File

@ -0,0 +1,22 @@
import { getProductPatentPage, PRODUCT_PATENT_PAGE_SIZE } from "./productPatentPagination";
describe("getProductPatentPage", () => {
it("shows 50 records per page and numbers the second page from 51", () => {
const records = Array.from({ length: 51 }, (_, index) => index + 1);
const page = getProductPatentPage(records, 2);
expect(PRODUCT_PATENT_PAGE_SIZE).toBe(50);
expect(page.totalPages).toBe(2);
expect(page.currentPage).toBe(2);
expect(page.rowNumberStart).toBe(50);
expect(page.records).toEqual([51]);
});
it("keeps a manually entered page number within the available range", () => {
const records = Array.from({ length: 51 }, (_, index) => index + 1);
expect(getProductPatentPage(records, 99).currentPage).toBe(2);
expect(getProductPatentPage(records, 0).currentPage).toBe(1);
});
});

View File

@ -0,0 +1,14 @@
export const PRODUCT_PATENT_PAGE_SIZE = 50;
export function getProductPatentPage<T>(records: readonly T[], currentPage: number) {
const totalPages = Math.max(1, Math.ceil(records.length / PRODUCT_PATENT_PAGE_SIZE));
const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages);
const rowNumberStart = (safeCurrentPage - 1) * PRODUCT_PATENT_PAGE_SIZE;
return {
currentPage: safeCurrentPage,
totalPages,
rowNumberStart,
records: records.slice(rowNumberStart, rowNumberStart + PRODUCT_PATENT_PAGE_SIZE),
};
}