@vtex/faststore-plugin-buyer-portal 2.0.8 → 2.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yaml +4 -4
- package/AGENTS.md +6 -0
- package/CHANGELOG.md +23 -1
- package/docs/superpowers/plans/2026-06-26-contracts-listing-selection-search-pagination.md +1070 -0
- package/docs/superpowers/specs/2026-06-26-contracts-listing-selection-search-pagination-design.md +157 -0
- package/package.json +1 -1
- package/plugin.config.js +8 -0
- package/public/buyer-portal-icons.svg +37 -1
- package/specs/001-suma-phase-2/checklists/requirements.md +36 -0
- package/specs/001-suma-phase-2/contracts/contracts-api.md +187 -0
- package/specs/001-suma-phase-2/contracts/feature-flag.md +91 -0
- package/specs/001-suma-phase-2/data-model.md +119 -0
- package/specs/001-suma-phase-2/plan.md +123 -0
- package/specs/001-suma-phase-2/quickstart.md +156 -0
- package/specs/001-suma-phase-2/research.md +150 -0
- package/specs/001-suma-phase-2/spec.md +186 -0
- package/specs/001-suma-phase-2/tasks.md +253 -0
- package/specs/suma-add-contracts.md +224 -0
- package/specs/suma-contract-listing.md +295 -0
- package/specs/suma-contract-switching.md +201 -0
- package/specs/suma-remove-contracts.md +233 -0
- package/specs/suma-set-default-contract.md +208 -0
- package/src/features/budgets/layouts/BudgetsLayout/BudgetsLayout.tsx +113 -105
- package/src/features/buying-policies/layouts/BuyingPoliciesLayout/BuyingPoliciesLayout.tsx +60 -46
- package/src/features/contracts/clients/ContractsClient.ts +45 -0
- package/src/features/contracts/components/AddContractsDrawer/AddContractsDrawer.tsx +323 -0
- package/src/features/contracts/components/AddContractsDrawer/add-contracts-drawer.scss +282 -0
- package/src/features/contracts/components/ConfirmRemoveDrawer/ConfirmRemoveDrawer.tsx +58 -0
- package/src/features/contracts/components/ConfirmRemoveDrawer/confirm-remove-drawer.scss +18 -0
- package/src/features/contracts/components/ContractListItem/ContractListItem.tsx +120 -0
- package/src/features/contracts/components/ContractSelectAllRow/ContractSelectAllRow.tsx +37 -0
- package/src/features/contracts/components/ContractSelectAllRow/index.ts +2 -0
- package/src/features/contracts/components/ContractSelectionBar/ContractSelectionBar.tsx +47 -0
- package/src/features/contracts/components/ContractSelectionBar/index.ts +2 -0
- package/src/features/contracts/components/ContractSelectionList/ContractSelectionList.tsx +55 -0
- package/src/features/contracts/components/ContractSwitchOverlay/ContractSwitchOverlay.tsx +36 -0
- package/src/features/contracts/components/ContractSwitchOverlay/contract-switch-overlay.scss +7 -0
- package/src/features/contracts/components/ContractsListHeader/ContractsListHeader.tsx +58 -0
- package/src/features/contracts/components/ContractsListHeader/index.ts +2 -0
- package/src/features/contracts/components/ErrorRemoveDrawer/ErrorRemoveDrawer.tsx +69 -0
- package/src/features/contracts/components/UnableToRemoveContractsDrawer/UnableToRemoveContractsDrawer.tsx +52 -0
- package/src/features/contracts/components/UnableToRemoveContractsDrawer/unable-to-remove-contracts-drawer.scss +29 -0
- package/src/features/contracts/components/index.ts +14 -0
- package/src/features/contracts/hooks/__tests__/contractListingHelpers.test.ts +71 -0
- package/src/features/contracts/hooks/__tests__/contractSwitchChannelHelpers.test.ts +32 -0
- package/src/features/contracts/hooks/__tests__/useAddContracts.test.ts +63 -0
- package/src/features/contracts/hooks/__tests__/useContractSelection.test.ts +72 -0
- package/src/features/contracts/hooks/__tests__/useRemoveContracts.test.ts +130 -0
- package/src/features/contracts/hooks/__tests__/useSetDefaultContract.test.ts +82 -0
- package/src/features/contracts/hooks/contractListingHelpers.ts +45 -0
- package/src/features/contracts/hooks/contractSelectionHelpers.ts +28 -0
- package/src/features/contracts/hooks/contractSwitchChannelHelpers.ts +9 -0
- package/src/features/contracts/hooks/index.ts +19 -0
- package/src/features/contracts/hooks/useAddContracts.ts +27 -0
- package/src/features/contracts/hooks/useContractListing.ts +71 -0
- package/src/features/contracts/hooks/useContractSelection.ts +53 -0
- package/src/features/contracts/hooks/useContractSwitchChannel.ts +49 -0
- package/src/features/contracts/hooks/useListAvailableContracts.ts +27 -0
- package/src/features/contracts/hooks/useRemoveContracts.ts +21 -0
- package/src/features/contracts/hooks/useSetDefaultContract.ts +21 -0
- package/src/features/contracts/hooks/useSwitchContract.ts +76 -0
- package/src/features/contracts/layouts/ContractInformationLayout/ContractInformationLayout.tsx +316 -0
- package/src/features/contracts/layouts/ContractListingLayout/ContractListingLayout.tsx +415 -0
- package/src/features/contracts/layouts/ContractListingLayout/ContractSettingsNav.tsx +83 -0
- package/src/features/contracts/layouts/ContractListingLayout/contract-listing-layout.scss +468 -0
- package/src/features/contracts/layouts/ContractListingLayout/utils.ts +4 -0
- package/src/features/contracts/layouts/index.ts +8 -0
- package/src/features/contracts/services/__tests__/contracts-client.test.ts +148 -0
- package/src/features/contracts/services/__tests__/list-attached-contracts.service.test.ts +95 -0
- package/src/features/contracts/services/add-contracts.service.ts +27 -0
- package/src/features/contracts/services/index.ts +24 -0
- package/src/features/contracts/services/list-attached-contracts.service.ts +40 -0
- package/src/features/contracts/services/list-available-contracts.service.ts +32 -0
- package/src/features/contracts/services/remove-contract.service.ts +27 -0
- package/src/features/contracts/services/remove-contracts.service.ts +36 -0
- package/src/features/contracts/services/set-default-contract.service.ts +27 -0
- package/src/features/contracts/types/ContractData.ts +1 -0
- package/src/features/contracts/utils/__tests__/changeContractToken.test.ts +141 -0
- package/src/features/contracts/utils/changeContractToken.ts +122 -0
- package/src/features/contracts/utils/clearPersistedSessionState.ts +74 -0
- package/src/features/org-units/components/OrgUnitDetailsNavbar/OrgUnitDetailsNavbar.tsx +45 -3
- package/src/features/org-units/components/OrgUnitDetailsNavbar/org-unit-details-navbar.scss +35 -0
- package/src/features/org-units/layouts/OrgUnitsLayout/OrgUnitsLayout.tsx +54 -48
- package/src/features/roles/layout/RolesLayout/RolesLayout.tsx +32 -23
- package/src/features/shared/components/BuyerPortalProvider/BuyerPortalProvider.tsx +1 -0
- package/src/features/shared/components/HeaderInside/HeaderInside.tsx +8 -1
- package/src/features/shared/components/HeaderInside/header-inside.scss +9 -0
- package/src/features/shared/layouts/BaseTabsLayout/SidebarMenu.tsx +6 -97
- package/src/features/shared/layouts/ContractTabsLayout/ContractTabsLayout.tsx +42 -5
- package/src/features/shared/layouts/LoadingTabsLayout/LoadingTabsLayout.tsx +1 -1
- package/src/features/shared/layouts/SumaPageLayout/FullSidebarNav.tsx +124 -0
- package/src/features/shared/layouts/SumaPageLayout/SumaPageLayout.tsx +56 -0
- package/src/features/shared/layouts/SumaPageLayout/SumaSidebar.tsx +80 -0
- package/src/features/shared/layouts/SumaPageLayout/SumaSidebarDrawer.tsx +48 -0
- package/src/features/shared/layouts/SumaPageLayout/index.ts +6 -0
- package/src/features/shared/layouts/SumaPageLayout/suma-sidebar-drawer.scss +37 -0
- package/src/features/shared/layouts/index.ts +6 -0
- package/src/features/shared/services/feature-flags/__tests__/get-feature-flags.service.test.ts +71 -0
- package/src/features/shared/services/feature-flags/get-feature-flags.service.ts +1 -0
- package/src/features/shared/utils/buyerPortalRoutes.ts +9 -0
- package/src/features/shared/utils/constants.ts +1 -1
- package/src/features/shared/utils/getSumaListingSidebarLinks.ts +18 -0
- package/src/features/shared/utils/index.ts +1 -0
- package/src/features/users/layouts/UsersLayout/UsersLayout.tsx +97 -103
- package/src/pages/contract-information.tsx +128 -0
- package/src/pages/contracts.tsx +104 -0
- package/src/pages/home.tsx +10 -6
- package/src/pages/org-unit-details.tsx +17 -13
- package/src/themes/layouts.scss +2 -0
- package/.agents/skills/create-page/SKILL.md +0 -154
- package/.agents/skills/create-page/metadata.json +0 -14
- package/.specify/memory/constitution.md +0 -83
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
# Contracts Listing — Selection, Search & Local Pagination Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Make the SUMA contracts listing support checkbox multi-selection, local search, and local pagination — without the checkbox accidentally triggering a contract switch.
|
|
6
|
+
|
|
7
|
+
**Architecture:** All contracts are already loaded client-side. A new pure-helper module (`filterContracts`, `paginate`) plus a thin `useContractListing` hook drive search + pagination. Selection reuses the existing `useContractSelection`. `ContractListItem` splits its click target: the checkbox selects (stops propagation), the row body switches. New presentational components add the search/pagination header, a tri-state "Select all" row, and a floating selection bar.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** React + TypeScript, FastStore plugin, `@faststore/ui` (Icon, useUI), SCSS with `data-fs-bp-*` attributes, Vitest (node env).
|
|
10
|
+
|
|
11
|
+
## Global Constraints
|
|
12
|
+
|
|
13
|
+
- Plugin boundary: this is a plugin, no `yarn dev`; verify via `make check`.
|
|
14
|
+
- Page size: **25** contracts per page.
|
|
15
|
+
- Search: **local**, case-insensitive substring over `name` and `email`.
|
|
16
|
+
- Select-all scope: **all filtered contracts** (across pages); selection persists across page/search changes.
|
|
17
|
+
- Remove button: **no-op placeholder** (bulk delete is future).
|
|
18
|
+
- Keep the contract-switch flow (`useSwitchContract`) and ⌘/Ctrl+A select-all keyboard shortcut.
|
|
19
|
+
- Tests run in **node env** (no jsdom/renderHook) — test pure functions, not React hooks.
|
|
20
|
+
- Typecheck has ~133 pre-existing env errors from linked `@faststore/core`; bar = **no new errors in touched files**.
|
|
21
|
+
- Icons: next=`ChevronRight` (shared Icon); prev=`ChevronRight` rotated 180° via CSS; Remove leading icon=`MinusCircle` (shared Icon); clear=`@faststore/ui` `Icon name="X"`; indeterminate checkbox = CSS bar.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
### Task 1: Pure listing helpers (filter + paginate)
|
|
26
|
+
|
|
27
|
+
**Files:**
|
|
28
|
+
- Create: `src/features/contracts/hooks/contractListingHelpers.ts`
|
|
29
|
+
- Test: `src/features/contracts/hooks/__tests__/contractListingHelpers.test.ts`
|
|
30
|
+
|
|
31
|
+
**Interfaces:**
|
|
32
|
+
- Produces:
|
|
33
|
+
- `filterContracts(contracts: ContractData[], term: string): ContractData[]`
|
|
34
|
+
- `type PageResult = { pageItems: ContractData[]; from: number; to: number; total: number; hasPrev: boolean; hasNext: boolean }`
|
|
35
|
+
- `paginate(items: ContractData[], page: number, pageSize: number): PageResult`
|
|
36
|
+
|
|
37
|
+
- [ ] **Step 1: Write the failing test**
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { describe, expect, it } from "vitest";
|
|
41
|
+
|
|
42
|
+
import { filterContracts, paginate } from "../contractListingHelpers";
|
|
43
|
+
|
|
44
|
+
import type { ContractData } from "../../types";
|
|
45
|
+
|
|
46
|
+
const make = (id: string, name: string, email = ""): ContractData =>
|
|
47
|
+
({ id, name, email }) as ContractData;
|
|
48
|
+
|
|
49
|
+
const contracts = [
|
|
50
|
+
make("1", "Stellar Global", "ops@stellar.com"),
|
|
51
|
+
make("2", "Aether Energy"),
|
|
52
|
+
make("3", "Alpha Logistics", "hi@alpha.io"),
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
describe("filterContracts", () => {
|
|
56
|
+
it("returns all contracts for an empty or whitespace term", () => {
|
|
57
|
+
expect(filterContracts(contracts, "")).toHaveLength(3);
|
|
58
|
+
expect(filterContracts(contracts, " ")).toHaveLength(3);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("matches name case-insensitively", () => {
|
|
62
|
+
const result = filterContracts(contracts, "alpha");
|
|
63
|
+
expect(result.map((c) => c.id)).toEqual(["3"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("matches email case-insensitively", () => {
|
|
67
|
+
const result = filterContracts(contracts, "STELLAR.COM");
|
|
68
|
+
expect(result.map((c) => c.id)).toEqual(["1"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns empty when nothing matches", () => {
|
|
72
|
+
expect(filterContracts(contracts, "zzz")).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("paginate", () => {
|
|
77
|
+
const items = Array.from({ length: 50 }, (_, i) => make(String(i + 1), `C${i + 1}`));
|
|
78
|
+
|
|
79
|
+
it("returns the first page with correct interval and flags", () => {
|
|
80
|
+
const r = paginate(items, 1, 25);
|
|
81
|
+
expect(r.pageItems).toHaveLength(25);
|
|
82
|
+
expect([r.from, r.to, r.total]).toEqual([1, 25, 50]);
|
|
83
|
+
expect(r.hasPrev).toBe(false);
|
|
84
|
+
expect(r.hasNext).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns the last page with a partial slice", () => {
|
|
88
|
+
const r = paginate(items.slice(0, 30), 2, 25);
|
|
89
|
+
expect(r.pageItems).toHaveLength(5);
|
|
90
|
+
expect([r.from, r.to, r.total]).toEqual([26, 30, 30]);
|
|
91
|
+
expect(r.hasPrev).toBe(true);
|
|
92
|
+
expect(r.hasNext).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("clamps a page past the end to the last valid page", () => {
|
|
96
|
+
const r = paginate(items.slice(0, 30), 99, 25);
|
|
97
|
+
expect(r.from).toBe(26);
|
|
98
|
+
expect(r.to).toBe(30);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("handles an empty list", () => {
|
|
102
|
+
const r = paginate([], 1, 25);
|
|
103
|
+
expect(r.pageItems).toEqual([]);
|
|
104
|
+
expect([r.from, r.to, r.total]).toEqual([0, 0, 0]);
|
|
105
|
+
expect(r.hasPrev).toBe(false);
|
|
106
|
+
expect(r.hasNext).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
112
|
+
|
|
113
|
+
Run: `yarn vitest run src/features/contracts/hooks/__tests__/contractListingHelpers.test.ts`
|
|
114
|
+
Expected: FAIL (module/exports not found)
|
|
115
|
+
|
|
116
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import type { ContractData } from "../types";
|
|
120
|
+
|
|
121
|
+
export type PageResult = {
|
|
122
|
+
pageItems: ContractData[];
|
|
123
|
+
from: number;
|
|
124
|
+
to: number;
|
|
125
|
+
total: number;
|
|
126
|
+
hasPrev: boolean;
|
|
127
|
+
hasNext: boolean;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export function filterContracts(
|
|
131
|
+
contracts: ContractData[],
|
|
132
|
+
term: string
|
|
133
|
+
): ContractData[] {
|
|
134
|
+
const q = term.trim().toLowerCase();
|
|
135
|
+
if (!q) return contracts;
|
|
136
|
+
return contracts.filter((c) => {
|
|
137
|
+
const name = (c.name ?? "").toLowerCase();
|
|
138
|
+
const email = (c.email ?? "").toLowerCase();
|
|
139
|
+
return name.includes(q) || email.includes(q);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function paginate(
|
|
144
|
+
items: ContractData[],
|
|
145
|
+
page: number,
|
|
146
|
+
pageSize: number
|
|
147
|
+
): PageResult {
|
|
148
|
+
const total = items.length;
|
|
149
|
+
const lastPage = Math.max(1, Math.ceil(total / pageSize));
|
|
150
|
+
const safePage = Math.min(Math.max(1, page), lastPage);
|
|
151
|
+
const start = (safePage - 1) * pageSize;
|
|
152
|
+
const pageItems = items.slice(start, start + pageSize);
|
|
153
|
+
const from = total === 0 ? 0 : start + 1;
|
|
154
|
+
const to = total === 0 ? 0 : start + pageItems.length;
|
|
155
|
+
return {
|
|
156
|
+
pageItems,
|
|
157
|
+
from,
|
|
158
|
+
to,
|
|
159
|
+
total,
|
|
160
|
+
hasPrev: safePage > 1,
|
|
161
|
+
hasNext: safePage < lastPage,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
167
|
+
|
|
168
|
+
Run: `yarn vitest run src/features/contracts/hooks/__tests__/contractListingHelpers.test.ts`
|
|
169
|
+
Expected: PASS (all cases)
|
|
170
|
+
|
|
171
|
+
- [ ] **Step 5: Commit**
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
git add src/features/contracts/hooks/contractListingHelpers.ts src/features/contracts/hooks/__tests__/contractListingHelpers.test.ts
|
|
175
|
+
git commit -m "feat(suma): add contract listing filter + paginate helpers"
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
### Task 2: `useContractListing` hook
|
|
181
|
+
|
|
182
|
+
**Files:**
|
|
183
|
+
- Create: `src/features/contracts/hooks/useContractListing.ts`
|
|
184
|
+
- Modify: `src/features/contracts/hooks/index.ts`
|
|
185
|
+
|
|
186
|
+
**Interfaces:**
|
|
187
|
+
- Consumes: `filterContracts`, `paginate`, `PageResult` (Task 1).
|
|
188
|
+
- Produces:
|
|
189
|
+
```ts
|
|
190
|
+
type UseContractListingReturn = {
|
|
191
|
+
searchTerm: string;
|
|
192
|
+
setSearch: (term: string) => void;
|
|
193
|
+
page: number;
|
|
194
|
+
nextPage: () => void;
|
|
195
|
+
prevPage: () => void;
|
|
196
|
+
pageItems: ContractData[];
|
|
197
|
+
filteredIds: string[];
|
|
198
|
+
from: number;
|
|
199
|
+
to: number;
|
|
200
|
+
total: number;
|
|
201
|
+
hasPrev: boolean;
|
|
202
|
+
hasNext: boolean;
|
|
203
|
+
};
|
|
204
|
+
function useContractListing(args: { contracts: ContractData[]; pageSize?: number }): UseContractListingReturn;
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
- [ ] **Step 1: Write the hook**
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { useMemo, useState } from "react";
|
|
211
|
+
|
|
212
|
+
import { filterContracts, paginate } from "./contractListingHelpers";
|
|
213
|
+
|
|
214
|
+
import type { ContractData } from "../types";
|
|
215
|
+
|
|
216
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
217
|
+
|
|
218
|
+
export type UseContractListingReturn = {
|
|
219
|
+
searchTerm: string;
|
|
220
|
+
setSearch: (term: string) => void;
|
|
221
|
+
page: number;
|
|
222
|
+
nextPage: () => void;
|
|
223
|
+
prevPage: () => void;
|
|
224
|
+
pageItems: ContractData[];
|
|
225
|
+
filteredIds: string[];
|
|
226
|
+
from: number;
|
|
227
|
+
to: number;
|
|
228
|
+
total: number;
|
|
229
|
+
hasPrev: boolean;
|
|
230
|
+
hasNext: boolean;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export function useContractListing({
|
|
234
|
+
contracts,
|
|
235
|
+
pageSize = DEFAULT_PAGE_SIZE,
|
|
236
|
+
}: {
|
|
237
|
+
contracts: ContractData[];
|
|
238
|
+
pageSize?: number;
|
|
239
|
+
}): UseContractListingReturn {
|
|
240
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
241
|
+
const [page, setPage] = useState(1);
|
|
242
|
+
|
|
243
|
+
const filtered = useMemo(
|
|
244
|
+
() => filterContracts(contracts, searchTerm),
|
|
245
|
+
[contracts, searchTerm]
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const result = useMemo(
|
|
249
|
+
() => paginate(filtered, page, pageSize),
|
|
250
|
+
[filtered, page, pageSize]
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const setSearch = (term: string) => {
|
|
254
|
+
setSearchTerm(term);
|
|
255
|
+
setPage(1);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const nextPage = () => result.hasNext && setPage((p) => p + 1);
|
|
259
|
+
const prevPage = () => result.hasPrev && setPage((p) => Math.max(1, p - 1));
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
searchTerm,
|
|
263
|
+
setSearch,
|
|
264
|
+
page,
|
|
265
|
+
nextPage,
|
|
266
|
+
prevPage,
|
|
267
|
+
pageItems: result.pageItems,
|
|
268
|
+
filteredIds: filtered.map((c) => c.id),
|
|
269
|
+
from: result.from,
|
|
270
|
+
to: result.to,
|
|
271
|
+
total: result.total,
|
|
272
|
+
hasPrev: result.hasPrev,
|
|
273
|
+
hasNext: result.hasNext,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
- [ ] **Step 2: Export from the hooks barrel**
|
|
279
|
+
|
|
280
|
+
In `src/features/contracts/hooks/index.ts` add:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
export { useContractListing } from "./useContractListing";
|
|
284
|
+
export type { UseContractListingReturn } from "./useContractListing";
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
- [ ] **Step 3: Typecheck the new files**
|
|
288
|
+
|
|
289
|
+
Run: `yarn typecheck 2>&1 | grep -E "useContractListing|contractListingHelpers" || echo "clean"`
|
|
290
|
+
Expected: `clean`
|
|
291
|
+
|
|
292
|
+
- [ ] **Step 4: Commit**
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
git add src/features/contracts/hooks/useContractListing.ts src/features/contracts/hooks/index.ts
|
|
296
|
+
git commit -m "feat(suma): add useContractListing hook for local search + pagination"
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
### Task 3: `ContractListItem` — split checkbox (select) from row (switch)
|
|
302
|
+
|
|
303
|
+
**Files:**
|
|
304
|
+
- Modify: `src/features/contracts/components/ContractListItem/ContractListItem.tsx`
|
|
305
|
+
|
|
306
|
+
**Interfaces:**
|
|
307
|
+
- Produces (new prop contract):
|
|
308
|
+
```ts
|
|
309
|
+
type ContractListItemProps = {
|
|
310
|
+
contract: ContractData;
|
|
311
|
+
isSelected: boolean;
|
|
312
|
+
orgUnitId: string;
|
|
313
|
+
onSelect: (id: string, event: React.MouseEvent) => void; // replaces onModifierClick/onShiftClick
|
|
314
|
+
};
|
|
315
|
+
```
|
|
316
|
+
- Consumes: `useSwitchContract` (existing), `useUI` (existing).
|
|
317
|
+
|
|
318
|
+
- [ ] **Step 1: Replace the props and handlers**
|
|
319
|
+
|
|
320
|
+
Full new file content:
|
|
321
|
+
|
|
322
|
+
```tsx
|
|
323
|
+
import { useUI } from "@faststore/ui";
|
|
324
|
+
|
|
325
|
+
import { LetterHighlight } from "../../../shared/components";
|
|
326
|
+
import Icon from "../../../shared/components/Icon/Icon";
|
|
327
|
+
import { useSwitchContract } from "../../hooks";
|
|
328
|
+
|
|
329
|
+
import type { ContractData } from "../../types";
|
|
330
|
+
|
|
331
|
+
export type ContractListItemProps = {
|
|
332
|
+
contract: ContractData;
|
|
333
|
+
isSelected: boolean;
|
|
334
|
+
orgUnitId: string;
|
|
335
|
+
onSelect: (id: string, event: React.MouseEvent) => void;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
export const ContractListItem = ({
|
|
339
|
+
contract,
|
|
340
|
+
isSelected,
|
|
341
|
+
orgUnitId,
|
|
342
|
+
onSelect,
|
|
343
|
+
}: ContractListItemProps) => {
|
|
344
|
+
const displayName = contract.name ?? contract.email;
|
|
345
|
+
const { pushToast } = useUI();
|
|
346
|
+
const { switchContract, loading } = useSwitchContract();
|
|
347
|
+
|
|
348
|
+
const handleCheckboxClick = (event: React.MouseEvent) => {
|
|
349
|
+
event.stopPropagation();
|
|
350
|
+
event.preventDefault();
|
|
351
|
+
onSelect(contract.id, event);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const handleRowClick = async () => {
|
|
355
|
+
if (loading) return;
|
|
356
|
+
const switched = await switchContract(contract.id, { orgUnitId });
|
|
357
|
+
if (!switched) {
|
|
358
|
+
pushToast({
|
|
359
|
+
message: "Failed to switch contract. Please try again.",
|
|
360
|
+
status: "ERROR",
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<div
|
|
367
|
+
data-fs-bp-contract-list-item
|
|
368
|
+
data-fs-bp-contract-list-item-selected={isSelected || undefined}
|
|
369
|
+
data-fs-bp-contract-list-item-loading={loading || undefined}
|
|
370
|
+
onClick={handleRowClick}
|
|
371
|
+
role="row"
|
|
372
|
+
aria-selected={isSelected}
|
|
373
|
+
aria-busy={loading || undefined}
|
|
374
|
+
>
|
|
375
|
+
<div
|
|
376
|
+
data-fs-bp-contract-list-item-checkbox
|
|
377
|
+
role="checkbox"
|
|
378
|
+
aria-checked={isSelected}
|
|
379
|
+
aria-label={`Select ${displayName ?? "contract"}`}
|
|
380
|
+
tabIndex={0}
|
|
381
|
+
onClick={handleCheckboxClick}
|
|
382
|
+
>
|
|
383
|
+
{isSelected && <Icon name="Check" width={12} height={12} />}
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
<LetterHighlight size="small" letter={displayName ?? ""} />
|
|
387
|
+
|
|
388
|
+
<span data-fs-bp-contract-list-item-name>{displayName}</span>
|
|
389
|
+
|
|
390
|
+
{contract.isDefault && (
|
|
391
|
+
<span
|
|
392
|
+
data-fs-bp-contract-list-item-default-badge
|
|
393
|
+
aria-label="Default contract"
|
|
394
|
+
>
|
|
395
|
+
<Icon name="Default" width={20} height={20} />
|
|
396
|
+
</span>
|
|
397
|
+
)}
|
|
398
|
+
</div>
|
|
399
|
+
);
|
|
400
|
+
};
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
- [ ] **Step 2: Lint + typecheck this file**
|
|
404
|
+
|
|
405
|
+
Run: `yarn lint 2>&1 | tail -3 && yarn typecheck 2>&1 | grep ContractListItem || echo "clean"`
|
|
406
|
+
Expected: lint `Done`, typecheck `clean`
|
|
407
|
+
|
|
408
|
+
- [ ] **Step 3: Commit**
|
|
409
|
+
|
|
410
|
+
```bash
|
|
411
|
+
git add src/features/contracts/components/ContractListItem/ContractListItem.tsx
|
|
412
|
+
git commit -m "feat(suma): split contract row checkbox-select from row-switch"
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
### Task 4: `ContractsListHeader` — search + local pagination
|
|
418
|
+
|
|
419
|
+
**Files:**
|
|
420
|
+
- Create: `src/features/contracts/components/ContractsListHeader/ContractsListHeader.tsx`
|
|
421
|
+
- Create: `src/features/contracts/components/ContractsListHeader/index.ts`
|
|
422
|
+
|
|
423
|
+
**Interfaces:**
|
|
424
|
+
- Consumes: `InternalSearch` (`src/features/shared/components`), `Icon` (`@faststore/ui`).
|
|
425
|
+
- Produces:
|
|
426
|
+
```ts
|
|
427
|
+
type ContractsListHeaderProps = {
|
|
428
|
+
searchTerm: string;
|
|
429
|
+
onSearch: (term: string) => void;
|
|
430
|
+
from: number;
|
|
431
|
+
to: number;
|
|
432
|
+
total: number;
|
|
433
|
+
hasPrev: boolean;
|
|
434
|
+
hasNext: boolean;
|
|
435
|
+
onPrev: () => void;
|
|
436
|
+
onNext: () => void;
|
|
437
|
+
};
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
- [ ] **Step 1: Write the component**
|
|
441
|
+
|
|
442
|
+
`ContractsListHeader.tsx`:
|
|
443
|
+
|
|
444
|
+
```tsx
|
|
445
|
+
import { Icon } from "@faststore/ui";
|
|
446
|
+
|
|
447
|
+
import { InternalSearch } from "../../../shared/components";
|
|
448
|
+
|
|
449
|
+
export type ContractsListHeaderProps = {
|
|
450
|
+
searchTerm: string;
|
|
451
|
+
onSearch: (term: string) => void;
|
|
452
|
+
from: number;
|
|
453
|
+
to: number;
|
|
454
|
+
total: number;
|
|
455
|
+
hasPrev: boolean;
|
|
456
|
+
hasNext: boolean;
|
|
457
|
+
onPrev: () => void;
|
|
458
|
+
onNext: () => void;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
export const ContractsListHeader = ({
|
|
462
|
+
searchTerm,
|
|
463
|
+
onSearch,
|
|
464
|
+
from,
|
|
465
|
+
to,
|
|
466
|
+
total,
|
|
467
|
+
hasPrev,
|
|
468
|
+
hasNext,
|
|
469
|
+
onPrev,
|
|
470
|
+
onNext,
|
|
471
|
+
}: ContractsListHeaderProps) => {
|
|
472
|
+
return (
|
|
473
|
+
<div data-fs-bp-contracts-list-header>
|
|
474
|
+
<div data-fs-bp-contracts-list-header-search>
|
|
475
|
+
<InternalSearch defaultValue={searchTerm} textSearch={onSearch} />
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
<div data-fs-bp-contracts-list-header-pagination>
|
|
479
|
+
<span data-fs-bp-contracts-list-header-interval>
|
|
480
|
+
{from} — {to} of {total}
|
|
481
|
+
</span>
|
|
482
|
+
<button
|
|
483
|
+
type="button"
|
|
484
|
+
data-fs-bp-contracts-list-header-prev
|
|
485
|
+
onClick={onPrev}
|
|
486
|
+
disabled={!hasPrev}
|
|
487
|
+
aria-label="Previous page"
|
|
488
|
+
>
|
|
489
|
+
<Icon name="ChevronRight" width={20} height={20} />
|
|
490
|
+
</button>
|
|
491
|
+
<button
|
|
492
|
+
type="button"
|
|
493
|
+
data-fs-bp-contracts-list-header-next
|
|
494
|
+
onClick={onNext}
|
|
495
|
+
disabled={!hasNext}
|
|
496
|
+
aria-label="Next page"
|
|
497
|
+
>
|
|
498
|
+
<Icon name="ChevronRight" width={20} height={20} />
|
|
499
|
+
</button>
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
);
|
|
503
|
+
};
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
`index.ts`:
|
|
507
|
+
|
|
508
|
+
```ts
|
|
509
|
+
export { ContractsListHeader } from "./ContractsListHeader";
|
|
510
|
+
export type { ContractsListHeaderProps } from "./ContractsListHeader";
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
- [ ] **Step 2: Lint + typecheck**
|
|
514
|
+
|
|
515
|
+
Run: `yarn lint 2>&1 | tail -3 && yarn typecheck 2>&1 | grep ContractsListHeader || echo "clean"`
|
|
516
|
+
Expected: lint `Done`, typecheck `clean`
|
|
517
|
+
|
|
518
|
+
- [ ] **Step 3: Commit**
|
|
519
|
+
|
|
520
|
+
```bash
|
|
521
|
+
git add src/features/contracts/components/ContractsListHeader/
|
|
522
|
+
git commit -m "feat(suma): add contracts list header with search + pagination"
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
### Task 5: `ContractSelectAllRow` — tri-state select all
|
|
528
|
+
|
|
529
|
+
**Files:**
|
|
530
|
+
- Create: `src/features/contracts/components/ContractSelectAllRow/ContractSelectAllRow.tsx`
|
|
531
|
+
- Create: `src/features/contracts/components/ContractSelectAllRow/index.ts`
|
|
532
|
+
|
|
533
|
+
**Interfaces:**
|
|
534
|
+
- Produces:
|
|
535
|
+
```ts
|
|
536
|
+
type ContractSelectAllRowProps = {
|
|
537
|
+
total: number; // count of filtered contracts (label + all-selected check)
|
|
538
|
+
selectedCount: number; // how many of the filtered are selected
|
|
539
|
+
onToggle: () => void; // layout decides select-all vs clear
|
|
540
|
+
};
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
- [ ] **Step 1: Write the component**
|
|
544
|
+
|
|
545
|
+
`ContractSelectAllRow.tsx`:
|
|
546
|
+
|
|
547
|
+
```tsx
|
|
548
|
+
import Icon from "../../../shared/components/Icon/Icon";
|
|
549
|
+
|
|
550
|
+
export type ContractSelectAllRowProps = {
|
|
551
|
+
total: number;
|
|
552
|
+
selectedCount: number;
|
|
553
|
+
onToggle: () => void;
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
export const ContractSelectAllRow = ({
|
|
557
|
+
total,
|
|
558
|
+
selectedCount,
|
|
559
|
+
onToggle,
|
|
560
|
+
}: ContractSelectAllRowProps) => {
|
|
561
|
+
const allSelected = total > 0 && selectedCount >= total;
|
|
562
|
+
const indeterminate = selectedCount > 0 && !allSelected;
|
|
563
|
+
|
|
564
|
+
return (
|
|
565
|
+
<div data-fs-bp-contract-select-all-row onClick={onToggle}>
|
|
566
|
+
<div
|
|
567
|
+
data-fs-bp-contract-select-all-checkbox
|
|
568
|
+
data-fs-bp-contract-select-all-checkbox-checked={allSelected || undefined}
|
|
569
|
+
data-fs-bp-contract-select-all-checkbox-indeterminate={
|
|
570
|
+
indeterminate || undefined
|
|
571
|
+
}
|
|
572
|
+
role="checkbox"
|
|
573
|
+
aria-checked={indeterminate ? "mixed" : allSelected}
|
|
574
|
+
aria-label="Select all contracts"
|
|
575
|
+
tabIndex={0}
|
|
576
|
+
>
|
|
577
|
+
{allSelected && <Icon name="Check" width={12} height={12} />}
|
|
578
|
+
</div>
|
|
579
|
+
<span data-fs-bp-contract-select-all-label>Select all ({total})</span>
|
|
580
|
+
</div>
|
|
581
|
+
);
|
|
582
|
+
};
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
(The indeterminate state renders a CSS minus bar via `[data-fs-bp-contract-select-all-checkbox-indeterminate]` — Task 7.)
|
|
586
|
+
|
|
587
|
+
`index.ts`:
|
|
588
|
+
|
|
589
|
+
```ts
|
|
590
|
+
export { ContractSelectAllRow } from "./ContractSelectAllRow";
|
|
591
|
+
export type { ContractSelectAllRowProps } from "./ContractSelectAllRow";
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
- [ ] **Step 2: Lint + typecheck**
|
|
595
|
+
|
|
596
|
+
Run: `yarn lint 2>&1 | tail -3 && yarn typecheck 2>&1 | grep ContractSelectAllRow || echo "clean"`
|
|
597
|
+
Expected: lint `Done`, typecheck `clean`
|
|
598
|
+
|
|
599
|
+
- [ ] **Step 3: Commit**
|
|
600
|
+
|
|
601
|
+
```bash
|
|
602
|
+
git add src/features/contracts/components/ContractSelectAllRow/
|
|
603
|
+
git commit -m "feat(suma): add tri-state select-all row"
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
---
|
|
607
|
+
|
|
608
|
+
### Task 6: `ContractSelectionBar` — floating selection pill
|
|
609
|
+
|
|
610
|
+
**Files:**
|
|
611
|
+
- Create: `src/features/contracts/components/ContractSelectionBar/ContractSelectionBar.tsx`
|
|
612
|
+
- Create: `src/features/contracts/components/ContractSelectionBar/index.ts`
|
|
613
|
+
|
|
614
|
+
**Interfaces:**
|
|
615
|
+
- Produces:
|
|
616
|
+
```ts
|
|
617
|
+
type ContractSelectionBarProps = {
|
|
618
|
+
selectedCount: number;
|
|
619
|
+
onClear: () => void;
|
|
620
|
+
onRemove: () => void; // no-op placeholder from the layout for now
|
|
621
|
+
canRemove?: boolean; // hide Remove for single-contract orgs (default true)
|
|
622
|
+
};
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
- [ ] **Step 1: Write the component**
|
|
626
|
+
|
|
627
|
+
`ContractSelectionBar.tsx`:
|
|
628
|
+
|
|
629
|
+
```tsx
|
|
630
|
+
import { Icon as UIIcon } from "@faststore/ui";
|
|
631
|
+
|
|
632
|
+
import Icon from "../../../shared/components/Icon/Icon";
|
|
633
|
+
|
|
634
|
+
export type ContractSelectionBarProps = {
|
|
635
|
+
selectedCount: number;
|
|
636
|
+
onClear: () => void;
|
|
637
|
+
onRemove: () => void;
|
|
638
|
+
canRemove?: boolean;
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
export const ContractSelectionBar = ({
|
|
642
|
+
selectedCount,
|
|
643
|
+
onClear,
|
|
644
|
+
onRemove,
|
|
645
|
+
canRemove = true,
|
|
646
|
+
}: ContractSelectionBarProps) => {
|
|
647
|
+
if (selectedCount === 0) return null;
|
|
648
|
+
|
|
649
|
+
return (
|
|
650
|
+
<div data-fs-bp-contract-selection-bar role="toolbar">
|
|
651
|
+
<button
|
|
652
|
+
type="button"
|
|
653
|
+
data-fs-bp-contract-selection-bar-clear
|
|
654
|
+
onClick={onClear}
|
|
655
|
+
aria-label="Clear selection"
|
|
656
|
+
>
|
|
657
|
+
<UIIcon name="X" width={20} height={20} />
|
|
658
|
+
</button>
|
|
659
|
+
|
|
660
|
+
<span data-fs-bp-contract-selection-bar-count>
|
|
661
|
+
{selectedCount} selected
|
|
662
|
+
</span>
|
|
663
|
+
|
|
664
|
+
{canRemove && (
|
|
665
|
+
<button
|
|
666
|
+
type="button"
|
|
667
|
+
data-fs-bp-contract-selection-bar-remove
|
|
668
|
+
onClick={onRemove}
|
|
669
|
+
>
|
|
670
|
+
<Icon name="MinusCircle" width={20} height={20} />
|
|
671
|
+
Remove
|
|
672
|
+
</button>
|
|
673
|
+
)}
|
|
674
|
+
</div>
|
|
675
|
+
);
|
|
676
|
+
};
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
`index.ts`:
|
|
680
|
+
|
|
681
|
+
```ts
|
|
682
|
+
export { ContractSelectionBar } from "./ContractSelectionBar";
|
|
683
|
+
export type { ContractSelectionBarProps } from "./ContractSelectionBar";
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
- [ ] **Step 2: Lint + typecheck**
|
|
687
|
+
|
|
688
|
+
Run: `yarn lint 2>&1 | tail -3 && yarn typecheck 2>&1 | grep ContractSelectionBar || echo "clean"`
|
|
689
|
+
Expected: lint `Done`, typecheck `clean`
|
|
690
|
+
|
|
691
|
+
- [ ] **Step 3: Commit**
|
|
692
|
+
|
|
693
|
+
```bash
|
|
694
|
+
git add src/features/contracts/components/ContractSelectionBar/
|
|
695
|
+
git commit -m "feat(suma): add floating contract selection bar"
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
---
|
|
699
|
+
|
|
700
|
+
### Task 7: Wire it together — `ContractSelectionList` + `ContractListingLayout` + SCSS
|
|
701
|
+
|
|
702
|
+
**Files:**
|
|
703
|
+
- Modify: `src/features/contracts/components/ContractSelectionList/ContractSelectionList.tsx`
|
|
704
|
+
- Modify: `src/features/contracts/layouts/ContractListingLayout/ContractListingLayout.tsx`
|
|
705
|
+
- Modify: `src/features/contracts/layouts/ContractListingLayout/contract-listing-layout.scss`
|
|
706
|
+
|
|
707
|
+
**Interfaces:**
|
|
708
|
+
- Consumes: `useContractListing` (Task 2), `useContractSelection` (existing),
|
|
709
|
+
`ContractsListHeader` (4), `ContractSelectAllRow` (5), `ContractSelectionBar` (6),
|
|
710
|
+
`ContractListItem` (3, `onSelect`).
|
|
711
|
+
|
|
712
|
+
- [ ] **Step 1: Slim `ContractSelectionList` to render page items + onSelect**
|
|
713
|
+
|
|
714
|
+
Full new content:
|
|
715
|
+
|
|
716
|
+
```tsx
|
|
717
|
+
import { ContractListItem } from "../ContractListItem/ContractListItem";
|
|
718
|
+
|
|
719
|
+
import type { ContractData } from "../../types";
|
|
720
|
+
|
|
721
|
+
export type ContractSelectionListProps = {
|
|
722
|
+
contracts: ContractData[];
|
|
723
|
+
orgUnitId: string;
|
|
724
|
+
selectedIds: Set<string>;
|
|
725
|
+
onKeyDown: (event: React.KeyboardEvent) => void;
|
|
726
|
+
onSelect: (id: string, event: React.MouseEvent) => void;
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
export const ContractSelectionList = ({
|
|
730
|
+
contracts,
|
|
731
|
+
orgUnitId,
|
|
732
|
+
selectedIds,
|
|
733
|
+
onKeyDown,
|
|
734
|
+
onSelect,
|
|
735
|
+
}: ContractSelectionListProps) => {
|
|
736
|
+
if (contracts.length === 0) {
|
|
737
|
+
return (
|
|
738
|
+
<div data-fs-bp-contract-selection-list-empty>
|
|
739
|
+
<p>No contracts found for this organizational unit.</p>
|
|
740
|
+
</div>
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return (
|
|
745
|
+
<div
|
|
746
|
+
data-fs-bp-contract-selection-list
|
|
747
|
+
onKeyDown={onKeyDown}
|
|
748
|
+
tabIndex={0}
|
|
749
|
+
role="grid"
|
|
750
|
+
aria-label="Contracts"
|
|
751
|
+
>
|
|
752
|
+
<div data-fs-bp-contract-selection-list-rows>
|
|
753
|
+
{contracts.map((contract) => (
|
|
754
|
+
<ContractListItem
|
|
755
|
+
key={contract.id}
|
|
756
|
+
contract={contract}
|
|
757
|
+
isSelected={selectedIds.has(contract.id)}
|
|
758
|
+
orgUnitId={orgUnitId}
|
|
759
|
+
onSelect={onSelect}
|
|
760
|
+
/>
|
|
761
|
+
))}
|
|
762
|
+
</div>
|
|
763
|
+
</div>
|
|
764
|
+
);
|
|
765
|
+
};
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
- [ ] **Step 2: Update `ContractListingLayout` imports**
|
|
769
|
+
|
|
770
|
+
Add to the contracts component imports and hooks import:
|
|
771
|
+
|
|
772
|
+
```tsx
|
|
773
|
+
import { ContractSelectionList } from "../../components/ContractSelectionList/ContractSelectionList";
|
|
774
|
+
import { ContractsListHeader } from "../../components/ContractsListHeader";
|
|
775
|
+
import { ContractSelectAllRow } from "../../components/ContractSelectAllRow";
|
|
776
|
+
import { ContractSelectionBar } from "../../components/ContractSelectionBar";
|
|
777
|
+
import {
|
|
778
|
+
useContractSelection,
|
|
779
|
+
useContractSwitchChannel,
|
|
780
|
+
useContractListing,
|
|
781
|
+
} from "../../hooks";
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
- [ ] **Step 3: Replace selection wiring in `ContractListingLayout`**
|
|
785
|
+
|
|
786
|
+
Replace the `handleModifierClickWithContext` / `handleShiftClickWithContext` block and the destructure of `useContractSelection` with listing + selection wiring driven by filtered IDs:
|
|
787
|
+
|
|
788
|
+
```tsx
|
|
789
|
+
const {
|
|
790
|
+
selectedIds,
|
|
791
|
+
handleModifierClick,
|
|
792
|
+
handleShiftClick,
|
|
793
|
+
handleSelectAll,
|
|
794
|
+
clearSelection,
|
|
795
|
+
} = useContractSelection();
|
|
796
|
+
|
|
797
|
+
const {
|
|
798
|
+
searchTerm,
|
|
799
|
+
setSearch,
|
|
800
|
+
pageItems,
|
|
801
|
+
filteredIds,
|
|
802
|
+
from,
|
|
803
|
+
to,
|
|
804
|
+
total,
|
|
805
|
+
hasPrev,
|
|
806
|
+
hasNext,
|
|
807
|
+
nextPage,
|
|
808
|
+
prevPage,
|
|
809
|
+
} = useContractListing({ contracts });
|
|
810
|
+
|
|
811
|
+
const selectedInFiltered = filteredIds.filter((id) =>
|
|
812
|
+
selectedIds.has(id)
|
|
813
|
+
).length;
|
|
814
|
+
|
|
815
|
+
const handleSelect = (id: string, event: React.MouseEvent) => {
|
|
816
|
+
if (event.shiftKey) {
|
|
817
|
+
handleShiftClick(id, filteredIds);
|
|
818
|
+
} else {
|
|
819
|
+
handleModifierClick(id);
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
const handleToggleSelectAll = () => {
|
|
824
|
+
if (selectedInFiltered > 0) {
|
|
825
|
+
clearSelection();
|
|
826
|
+
} else {
|
|
827
|
+
handleSelectAll(filteredIds);
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
832
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "a") {
|
|
833
|
+
event.preventDefault();
|
|
834
|
+
handleSelectAll(filteredIds);
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
(Remove the now-unused `allIds` const and old handlers.)
|
|
840
|
+
|
|
841
|
+
- [ ] **Step 4: Replace the multi-contract JSX branch**
|
|
842
|
+
|
|
843
|
+
Replace the `<ContractSelectionList .../>` branch (the `: (` else of `isSingleContract`) with:
|
|
844
|
+
|
|
845
|
+
```tsx
|
|
846
|
+
<>
|
|
847
|
+
<ContractsListHeader
|
|
848
|
+
searchTerm={searchTerm}
|
|
849
|
+
onSearch={setSearch}
|
|
850
|
+
from={from}
|
|
851
|
+
to={to}
|
|
852
|
+
total={total}
|
|
853
|
+
hasPrev={hasPrev}
|
|
854
|
+
hasNext={hasNext}
|
|
855
|
+
onPrev={prevPage}
|
|
856
|
+
onNext={nextPage}
|
|
857
|
+
/>
|
|
858
|
+
<ContractSelectAllRow
|
|
859
|
+
total={total}
|
|
860
|
+
selectedCount={selectedInFiltered}
|
|
861
|
+
onToggle={handleToggleSelectAll}
|
|
862
|
+
/>
|
|
863
|
+
<ContractSelectionList
|
|
864
|
+
contracts={pageItems}
|
|
865
|
+
orgUnitId={orgUnit.id}
|
|
866
|
+
selectedIds={selectedIds}
|
|
867
|
+
onKeyDown={handleKeyDown}
|
|
868
|
+
onSelect={handleSelect}
|
|
869
|
+
/>
|
|
870
|
+
<ContractSelectionBar
|
|
871
|
+
selectedCount={selectedIds.size}
|
|
872
|
+
onClear={clearSelection}
|
|
873
|
+
onRemove={() => {
|
|
874
|
+
// TODO: bulk contract removal (future feature)
|
|
875
|
+
}}
|
|
876
|
+
/>
|
|
877
|
+
</>
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
- [ ] **Step 5: Add SCSS**
|
|
881
|
+
|
|
882
|
+
In `contract-listing-layout.scss`, inside `[data-fs-bp-contract-listing-section] { … }`, replace the old `[data-fs-bp-contract-selection-toolbar]` block with the following new blocks (header, select-all row, indeterminate checkbox, floating bar):
|
|
883
|
+
|
|
884
|
+
```scss
|
|
885
|
+
[data-fs-bp-contracts-list-header] {
|
|
886
|
+
display: flex;
|
|
887
|
+
align-items: center;
|
|
888
|
+
justify-content: space-between;
|
|
889
|
+
gap: var(--fs-bp-gap-4);
|
|
890
|
+
padding: var(--fs-bp-padding-4) 0;
|
|
891
|
+
|
|
892
|
+
[data-fs-bp-contracts-list-header-search] {
|
|
893
|
+
flex: 1;
|
|
894
|
+
max-width: 22rem;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
[data-fs-bp-contracts-list-header-pagination] {
|
|
898
|
+
display: flex;
|
|
899
|
+
align-items: center;
|
|
900
|
+
gap: var(--fs-bp-gap-2);
|
|
901
|
+
|
|
902
|
+
[data-fs-bp-contracts-list-header-interval] {
|
|
903
|
+
font-size: var(--fs-bp-text-body);
|
|
904
|
+
color: var(--fs-bp-color-neutral-7);
|
|
905
|
+
white-space: nowrap;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
button {
|
|
909
|
+
display: inline-flex;
|
|
910
|
+
align-items: center;
|
|
911
|
+
justify-content: center;
|
|
912
|
+
background: none;
|
|
913
|
+
border: none;
|
|
914
|
+
padding: var(--fs-bp-padding-1);
|
|
915
|
+
cursor: pointer;
|
|
916
|
+
color: var(--fs-bp-color-neutral-8);
|
|
917
|
+
border-radius: var(--fs-bp-border-radius-full);
|
|
918
|
+
|
|
919
|
+
&:hover:not(:disabled) {
|
|
920
|
+
background-color: var(--fs-bp-color-neutral-1);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
&:disabled {
|
|
924
|
+
color: var(--fs-bp-color-neutral-3);
|
|
925
|
+
cursor: default;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
[data-fs-bp-contracts-list-header-prev] [data-fs-icon] {
|
|
930
|
+
transform: rotate(180deg);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
[data-fs-bp-contract-select-all-row] {
|
|
936
|
+
display: flex;
|
|
937
|
+
align-items: center;
|
|
938
|
+
gap: var(--fs-bp-gap-3);
|
|
939
|
+
padding: var(--fs-bp-padding-3) var(--fs-bp-padding-2);
|
|
940
|
+
cursor: pointer;
|
|
941
|
+
user-select: none;
|
|
942
|
+
|
|
943
|
+
[data-fs-bp-contract-select-all-checkbox] {
|
|
944
|
+
flex-shrink: 0;
|
|
945
|
+
width: 1.125rem;
|
|
946
|
+
height: 1.125rem;
|
|
947
|
+
border: 2px solid #9e9e9e;
|
|
948
|
+
border-radius: 2px;
|
|
949
|
+
display: flex;
|
|
950
|
+
align-items: center;
|
|
951
|
+
justify-content: center;
|
|
952
|
+
color: var(--fs-bp-color-white);
|
|
953
|
+
|
|
954
|
+
&[data-fs-bp-contract-select-all-checkbox-checked],
|
|
955
|
+
&[data-fs-bp-contract-select-all-checkbox-indeterminate] {
|
|
956
|
+
background-color: var(--fs-bp-color-brand);
|
|
957
|
+
border-color: var(--fs-bp-color-brand);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
&[data-fs-bp-contract-select-all-checkbox-indeterminate]::after {
|
|
961
|
+
content: "";
|
|
962
|
+
width: 0.625rem;
|
|
963
|
+
height: 2px;
|
|
964
|
+
background-color: var(--fs-bp-color-white);
|
|
965
|
+
border-radius: 1px;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
[data-fs-bp-contract-select-all-label] {
|
|
970
|
+
font-size: var(--fs-bp-text-body);
|
|
971
|
+
font-weight: var(--fs-bp-weight-medium);
|
|
972
|
+
color: var(--fs-bp-color-neutral-7);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
[data-fs-bp-contract-list-item-checkbox] {
|
|
977
|
+
cursor: pointer;
|
|
978
|
+
|
|
979
|
+
&:focus-visible {
|
|
980
|
+
outline: 2px solid var(--fs-bp-color-brand);
|
|
981
|
+
outline-offset: 2px;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
[data-fs-bp-contract-selection-bar] {
|
|
986
|
+
position: fixed;
|
|
987
|
+
left: 50%;
|
|
988
|
+
bottom: var(--fs-bp-spacing-6);
|
|
989
|
+
transform: translateX(-50%);
|
|
990
|
+
z-index: 10;
|
|
991
|
+
display: flex;
|
|
992
|
+
align-items: center;
|
|
993
|
+
gap: var(--fs-bp-gap-6);
|
|
994
|
+
padding: var(--fs-bp-padding-3) var(--fs-bp-padding-6);
|
|
995
|
+
background-color: var(--fs-bp-color-white);
|
|
996
|
+
border-radius: var(--fs-bp-border-radius-full);
|
|
997
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16);
|
|
998
|
+
|
|
999
|
+
[data-fs-bp-contract-selection-bar-clear] {
|
|
1000
|
+
display: inline-flex;
|
|
1001
|
+
background: none;
|
|
1002
|
+
border: none;
|
|
1003
|
+
padding: 0;
|
|
1004
|
+
cursor: pointer;
|
|
1005
|
+
color: var(--fs-bp-color-neutral-7);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
[data-fs-bp-contract-selection-bar-count] {
|
|
1009
|
+
font-size: var(--fs-bp-text-body);
|
|
1010
|
+
font-weight: var(--fs-bp-weight-medium);
|
|
1011
|
+
color: var(--fs-bp-color-black);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
[data-fs-bp-contract-selection-bar-remove] {
|
|
1015
|
+
display: inline-flex;
|
|
1016
|
+
align-items: center;
|
|
1017
|
+
gap: var(--fs-bp-gap-2);
|
|
1018
|
+
background: none;
|
|
1019
|
+
border: none;
|
|
1020
|
+
padding: 0;
|
|
1021
|
+
cursor: pointer;
|
|
1022
|
+
font-size: var(--fs-bp-text-body);
|
|
1023
|
+
font-weight: var(--fs-bp-weight-medium);
|
|
1024
|
+
color: #d32f2f;
|
|
1025
|
+
|
|
1026
|
+
&:hover {
|
|
1027
|
+
text-decoration: underline;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
- [ ] **Step 6: Full check**
|
|
1034
|
+
|
|
1035
|
+
Run: `yarn lint 2>&1 | tail -3 && yarn vitest run 2>&1 | tail -6 && yarn typecheck 2>&1 | grep -E "contracts/(components|layouts|hooks)" | grep -vE "pre-existing" || echo "no new contracts errors"`
|
|
1036
|
+
Expected: lint `Done`, all tests pass, no new typecheck errors in touched contracts files.
|
|
1037
|
+
|
|
1038
|
+
- [ ] **Step 7: Commit**
|
|
1039
|
+
|
|
1040
|
+
```bash
|
|
1041
|
+
git add src/features/contracts/
|
|
1042
|
+
git commit -m "feat(suma): wire contracts listing search, pagination, selection UI"
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
---
|
|
1046
|
+
|
|
1047
|
+
### Task 8: Final verification + changelog
|
|
1048
|
+
|
|
1049
|
+
**Files:**
|
|
1050
|
+
- Possibly: changelog entry (per repo convention — check `docs`/CI `changelog.yml`).
|
|
1051
|
+
|
|
1052
|
+
- [ ] **Step 1: Run the full gate**
|
|
1053
|
+
|
|
1054
|
+
Run: `make check 2>&1 | tail -30`
|
|
1055
|
+
Expected: lint + tests pass; typecheck error count equals the pre-existing baseline (133) with **no** entries referencing the new/edited contracts files.
|
|
1056
|
+
|
|
1057
|
+
- [ ] **Step 2: Confirm no new typecheck errors in touched files**
|
|
1058
|
+
|
|
1059
|
+
Run: `yarn typecheck 2>&1 | grep -E "contractListingHelpers|useContractListing|ContractListItem|ContractsListHeader|ContractSelectAllRow|ContractSelectionBar|ContractSelectionList|ContractListingLayout" || echo "TOUCHED FILES CLEAN"`
|
|
1060
|
+
Expected: `TOUCHED FILES CLEAN`
|
|
1061
|
+
|
|
1062
|
+
- [ ] **Step 3: Manual verification (when linked to `b2bfaststoredev.store`)**
|
|
1063
|
+
|
|
1064
|
+
Walk the 9 checks in the design doc's "Manual verification" section: pagination, search-resets-page, checkbox-selects-without-switch, shift-range, tri-state select-all + clear, selection persistence across page/search, row-body switch still works, Remove inert.
|
|
1065
|
+
|
|
1066
|
+
## Self-Review
|
|
1067
|
+
|
|
1068
|
+
- **Spec coverage:** click split (Task 3) · search (2,4) · pagination 25/prev-next (1,2,4) · select-all all-filtered + indeterminate (5,7) · floating bar + no-op Remove (6,7) · persistence (selection Set independent of paging, 7) · styling (7) · tests for helpers (1). All spec sections covered.
|
|
1069
|
+
- **Placeholder scan:** the only intentional placeholder is the Remove `onClick` no-op, explicitly required by the spec and marked with a TODO comment. No vague steps.
|
|
1070
|
+
- **Type consistency:** `onSelect(id, event)` is defined in Task 3 and consumed identically in Tasks 7. `filteredIds: string[]`, `from/to/total/hasPrev/hasNext` from Task 2 match the props passed in Task 7. `selectedCount`/`total`/`onToggle` (Task 5) and `selectedCount`/`onClear`/`onRemove` (Task 6) match the layout wiring in Task 7.
|