feat(intake): paginate product patent ledger
This commit is contained in:
parent
c3566c4b83
commit
16b43cba11
@ -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);
|
||||
});
|
||||
});
|
||||
@ -206,7 +206,7 @@ export function buildIntakePrimaryDomainProps(params: {
|
||||
},
|
||||
canViewProductPatentLedger: params.canViewProductPatentLedger,
|
||||
productPatent: {
|
||||
products: params.nonIntegratedProducts,
|
||||
products: params.productDevelopmentProgressProducts,
|
||||
user: params.user,
|
||||
readOnly: !params.canEditProductPatentLedger,
|
||||
},
|
||||
|
||||
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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="上一页"
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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">
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user