@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.
Files changed (112) hide show
  1. package/.github/workflows/release.yaml +4 -4
  2. package/AGENTS.md +6 -0
  3. package/CHANGELOG.md +23 -1
  4. package/docs/superpowers/plans/2026-06-26-contracts-listing-selection-search-pagination.md +1070 -0
  5. package/docs/superpowers/specs/2026-06-26-contracts-listing-selection-search-pagination-design.md +157 -0
  6. package/package.json +1 -1
  7. package/plugin.config.js +8 -0
  8. package/public/buyer-portal-icons.svg +37 -1
  9. package/specs/001-suma-phase-2/checklists/requirements.md +36 -0
  10. package/specs/001-suma-phase-2/contracts/contracts-api.md +187 -0
  11. package/specs/001-suma-phase-2/contracts/feature-flag.md +91 -0
  12. package/specs/001-suma-phase-2/data-model.md +119 -0
  13. package/specs/001-suma-phase-2/plan.md +123 -0
  14. package/specs/001-suma-phase-2/quickstart.md +156 -0
  15. package/specs/001-suma-phase-2/research.md +150 -0
  16. package/specs/001-suma-phase-2/spec.md +186 -0
  17. package/specs/001-suma-phase-2/tasks.md +253 -0
  18. package/specs/suma-add-contracts.md +224 -0
  19. package/specs/suma-contract-listing.md +295 -0
  20. package/specs/suma-contract-switching.md +201 -0
  21. package/specs/suma-remove-contracts.md +233 -0
  22. package/specs/suma-set-default-contract.md +208 -0
  23. package/src/features/budgets/layouts/BudgetsLayout/BudgetsLayout.tsx +113 -105
  24. package/src/features/buying-policies/layouts/BuyingPoliciesLayout/BuyingPoliciesLayout.tsx +60 -46
  25. package/src/features/contracts/clients/ContractsClient.ts +45 -0
  26. package/src/features/contracts/components/AddContractsDrawer/AddContractsDrawer.tsx +323 -0
  27. package/src/features/contracts/components/AddContractsDrawer/add-contracts-drawer.scss +282 -0
  28. package/src/features/contracts/components/ConfirmRemoveDrawer/ConfirmRemoveDrawer.tsx +58 -0
  29. package/src/features/contracts/components/ConfirmRemoveDrawer/confirm-remove-drawer.scss +18 -0
  30. package/src/features/contracts/components/ContractListItem/ContractListItem.tsx +120 -0
  31. package/src/features/contracts/components/ContractSelectAllRow/ContractSelectAllRow.tsx +37 -0
  32. package/src/features/contracts/components/ContractSelectAllRow/index.ts +2 -0
  33. package/src/features/contracts/components/ContractSelectionBar/ContractSelectionBar.tsx +47 -0
  34. package/src/features/contracts/components/ContractSelectionBar/index.ts +2 -0
  35. package/src/features/contracts/components/ContractSelectionList/ContractSelectionList.tsx +55 -0
  36. package/src/features/contracts/components/ContractSwitchOverlay/ContractSwitchOverlay.tsx +36 -0
  37. package/src/features/contracts/components/ContractSwitchOverlay/contract-switch-overlay.scss +7 -0
  38. package/src/features/contracts/components/ContractsListHeader/ContractsListHeader.tsx +58 -0
  39. package/src/features/contracts/components/ContractsListHeader/index.ts +2 -0
  40. package/src/features/contracts/components/ErrorRemoveDrawer/ErrorRemoveDrawer.tsx +69 -0
  41. package/src/features/contracts/components/UnableToRemoveContractsDrawer/UnableToRemoveContractsDrawer.tsx +52 -0
  42. package/src/features/contracts/components/UnableToRemoveContractsDrawer/unable-to-remove-contracts-drawer.scss +29 -0
  43. package/src/features/contracts/components/index.ts +14 -0
  44. package/src/features/contracts/hooks/__tests__/contractListingHelpers.test.ts +71 -0
  45. package/src/features/contracts/hooks/__tests__/contractSwitchChannelHelpers.test.ts +32 -0
  46. package/src/features/contracts/hooks/__tests__/useAddContracts.test.ts +63 -0
  47. package/src/features/contracts/hooks/__tests__/useContractSelection.test.ts +72 -0
  48. package/src/features/contracts/hooks/__tests__/useRemoveContracts.test.ts +130 -0
  49. package/src/features/contracts/hooks/__tests__/useSetDefaultContract.test.ts +82 -0
  50. package/src/features/contracts/hooks/contractListingHelpers.ts +45 -0
  51. package/src/features/contracts/hooks/contractSelectionHelpers.ts +28 -0
  52. package/src/features/contracts/hooks/contractSwitchChannelHelpers.ts +9 -0
  53. package/src/features/contracts/hooks/index.ts +19 -0
  54. package/src/features/contracts/hooks/useAddContracts.ts +27 -0
  55. package/src/features/contracts/hooks/useContractListing.ts +71 -0
  56. package/src/features/contracts/hooks/useContractSelection.ts +53 -0
  57. package/src/features/contracts/hooks/useContractSwitchChannel.ts +49 -0
  58. package/src/features/contracts/hooks/useListAvailableContracts.ts +27 -0
  59. package/src/features/contracts/hooks/useRemoveContracts.ts +21 -0
  60. package/src/features/contracts/hooks/useSetDefaultContract.ts +21 -0
  61. package/src/features/contracts/hooks/useSwitchContract.ts +76 -0
  62. package/src/features/contracts/layouts/ContractInformationLayout/ContractInformationLayout.tsx +316 -0
  63. package/src/features/contracts/layouts/ContractListingLayout/ContractListingLayout.tsx +415 -0
  64. package/src/features/contracts/layouts/ContractListingLayout/ContractSettingsNav.tsx +83 -0
  65. package/src/features/contracts/layouts/ContractListingLayout/contract-listing-layout.scss +468 -0
  66. package/src/features/contracts/layouts/ContractListingLayout/utils.ts +4 -0
  67. package/src/features/contracts/layouts/index.ts +8 -0
  68. package/src/features/contracts/services/__tests__/contracts-client.test.ts +148 -0
  69. package/src/features/contracts/services/__tests__/list-attached-contracts.service.test.ts +95 -0
  70. package/src/features/contracts/services/add-contracts.service.ts +27 -0
  71. package/src/features/contracts/services/index.ts +24 -0
  72. package/src/features/contracts/services/list-attached-contracts.service.ts +40 -0
  73. package/src/features/contracts/services/list-available-contracts.service.ts +32 -0
  74. package/src/features/contracts/services/remove-contract.service.ts +27 -0
  75. package/src/features/contracts/services/remove-contracts.service.ts +36 -0
  76. package/src/features/contracts/services/set-default-contract.service.ts +27 -0
  77. package/src/features/contracts/types/ContractData.ts +1 -0
  78. package/src/features/contracts/utils/__tests__/changeContractToken.test.ts +141 -0
  79. package/src/features/contracts/utils/changeContractToken.ts +122 -0
  80. package/src/features/contracts/utils/clearPersistedSessionState.ts +74 -0
  81. package/src/features/org-units/components/OrgUnitDetailsNavbar/OrgUnitDetailsNavbar.tsx +45 -3
  82. package/src/features/org-units/components/OrgUnitDetailsNavbar/org-unit-details-navbar.scss +35 -0
  83. package/src/features/org-units/layouts/OrgUnitsLayout/OrgUnitsLayout.tsx +54 -48
  84. package/src/features/roles/layout/RolesLayout/RolesLayout.tsx +32 -23
  85. package/src/features/shared/components/BuyerPortalProvider/BuyerPortalProvider.tsx +1 -0
  86. package/src/features/shared/components/HeaderInside/HeaderInside.tsx +8 -1
  87. package/src/features/shared/components/HeaderInside/header-inside.scss +9 -0
  88. package/src/features/shared/layouts/BaseTabsLayout/SidebarMenu.tsx +6 -97
  89. package/src/features/shared/layouts/ContractTabsLayout/ContractTabsLayout.tsx +42 -5
  90. package/src/features/shared/layouts/LoadingTabsLayout/LoadingTabsLayout.tsx +1 -1
  91. package/src/features/shared/layouts/SumaPageLayout/FullSidebarNav.tsx +124 -0
  92. package/src/features/shared/layouts/SumaPageLayout/SumaPageLayout.tsx +56 -0
  93. package/src/features/shared/layouts/SumaPageLayout/SumaSidebar.tsx +80 -0
  94. package/src/features/shared/layouts/SumaPageLayout/SumaSidebarDrawer.tsx +48 -0
  95. package/src/features/shared/layouts/SumaPageLayout/index.ts +6 -0
  96. package/src/features/shared/layouts/SumaPageLayout/suma-sidebar-drawer.scss +37 -0
  97. package/src/features/shared/layouts/index.ts +6 -0
  98. package/src/features/shared/services/feature-flags/__tests__/get-feature-flags.service.test.ts +71 -0
  99. package/src/features/shared/services/feature-flags/get-feature-flags.service.ts +1 -0
  100. package/src/features/shared/utils/buyerPortalRoutes.ts +9 -0
  101. package/src/features/shared/utils/constants.ts +1 -1
  102. package/src/features/shared/utils/getSumaListingSidebarLinks.ts +18 -0
  103. package/src/features/shared/utils/index.ts +1 -0
  104. package/src/features/users/layouts/UsersLayout/UsersLayout.tsx +97 -103
  105. package/src/pages/contract-information.tsx +128 -0
  106. package/src/pages/contracts.tsx +104 -0
  107. package/src/pages/home.tsx +10 -6
  108. package/src/pages/org-unit-details.tsx +17 -13
  109. package/src/themes/layouts.scss +2 -0
  110. package/.agents/skills/create-page/SKILL.md +0 -154
  111. package/.agents/skills/create-page/metadata.json +0 -14
  112. package/.specify/memory/constitution.md +0 -83
@@ -0,0 +1,233 @@
1
+ # SUMA: Remove Contracts from Organizational Unit
2
+
3
+ > **Status**: Done
4
+ > **Created**: 2026-06-23
5
+
6
+ ## 1. Business Context
7
+
8
+ ### Problem Statement
9
+
10
+ Admins managing a Buyer Organization need to remove contracts that are no longer relevant to an Organizational Unit (OU). Without a remove flow, contracts accumulate on OUs indefinitely — buyers continue to see stale options during store navigation and checkout, and admins have no self-service way to correct the OU's contract pool.
11
+
12
+ ### Goals
13
+
14
+ - Admins can remove one or more contracts from an OU in a single action.
15
+ - The system prevents removing all contracts from an OU (at least one must always remain).
16
+ - The OU's contract listing reflects removals immediately after confirmation.
17
+
18
+ ### User Stories
19
+
20
+ #### US-1: Remove Selected Contracts
21
+
22
+ - **Story**: As an admin, I want to select one or more contracts and remove them from my OU, so that buyers in that unit can no longer access those contracts.
23
+ - **Acceptance Criteria**:
24
+ - **Given** an admin is on the Contract Listing page with ≥2 contracts, **when** they select one or more contracts and click "Remove" in the selection toolbar, **then** a confirmation drawer opens showing the count and OU name.
25
+ - **Given** the confirmation drawer is open, **when** the admin clicks "Remove", **then** the selected contracts are removed via parallel BFF calls and disappear from the listing.
26
+ - **Given** the removal is in progress, **when** the BFF calls are pending, **then** the confirm button shows a loading state and cannot be clicked again.
27
+ - **Given** the BFF calls succeed, **when** the drawer closes, **then** a success toast appears and the selection is cleared.
28
+ - **Given** the confirmation drawer is open, **when** the admin clicks "Cancel" or the backdrop, **then** the drawer closes and no BFF call is made.
29
+
30
+ #### US-2: Guard — Cannot Remove All Contracts
31
+
32
+ - **Story**: As a system, I must ensure at least one contract always remains on an OU, so that buyers always have a contract available during checkout.
33
+ - **Acceptance Criteria**:
34
+ - **Given** an OU has exactly N contracts, **when** an admin selects all N contracts and clicks "Remove", **then** instead of the confirmation drawer, the "Unable to remove" drawer opens explaining the constraint.
35
+ - **Given** the "Unable to remove" drawer is open, **when** the admin clicks "Got it", **then** the drawer closes and no BFF call is made.
36
+
37
+ #### US-3: BFF Error Handling
38
+
39
+ - **Story**: As an admin, I want to be informed when a removal fails, so that I can retry or investigate.
40
+ - **Acceptance Criteria**:
41
+ - **Given** one or more BFF DELETE calls fail, **when** the hook reports an error, **then** the confirmation drawer closes, an error toast appears on the listing page, and the listing is refreshed to reflect any partial changes.
42
+
43
+ ### Key Scenarios
44
+
45
+ | Scenario | Pre-conditions | Steps | Expected Result |
46
+ |---|---|---|---|
47
+ | Happy path — remove single contract | OU has ≥2 contracts; admin selects 1 | Select 1 → click Remove in toolbar → click Remove in drawer | Contract removed; listing refreshes; success toast shown; selection cleared |
48
+ | Happy path — remove multiple contracts | OU has ≥3 contracts; admin selects 2 | Select 2 → click Remove → confirm | 2 contracts removed; listing refreshes; toast: "2 contracts removed successfully" |
49
+ | Guard — attempt to remove all | OU has exactly 2 contracts; admin selects both | Select all → click Remove | "Unable to remove" drawer opens; no BFF call; listing unchanged |
50
+ | BFF error (one fails) | BFF returns 5xx on one DELETE | Select 2 → confirm | Drawer closes; error toast; listing refreshes (partial state reflected) |
51
+ | Cancel before confirm | Admin opens confirm drawer | Click Cancel or backdrop | Drawer closes; no BFF call; listing unchanged; selection preserved |
52
+ | Cmd+A then remove | OU has 3 contracts | Cmd+A → click Remove in toolbar | Guard triggers: "Unable to remove" drawer opens (all selected = all contracts) |
53
+
54
+ ### Functional Requirements
55
+
56
+ - **FR-1**: The selection toolbar "Remove" button MUST be visible only when `selectedIds.size > 0` and `isSingleContract === false`.
57
+ - **FR-2**: Before opening the confirmation drawer, the layout MUST check if `selectedIds.size === contracts.length`. If true, open the "Unable to remove" drawer instead.
58
+ - **FR-3**: The `ConfirmRemoveDrawer` MUST display the count of contracts being removed and the OU name.
59
+ - **FR-4**: On confirmation, each selected contract ID MUST be removed via a separate `DELETE units/{orgUnitId}/contracts/attached/{contractId}` call, executed in parallel via `Promise.allSettled`.
60
+ - **FR-5**: While removal is in progress, the confirm button MUST show a loading state and be disabled.
61
+ - **FR-6**: On full or partial BFF failure, an error toast MUST appear and the listing MUST refresh.
62
+ - **FR-7**: On success, the selection MUST be cleared and the listing MUST refresh via `router.replace(router.asPath)`.
63
+ - **FR-8**: A success toast MUST appear after successful removal.
64
+ - **FR-9**: The `ErrorRemoveDrawer` ("Unable to remove") MUST explain the ≥1 contract constraint and show only a "Got it" dismiss button.
65
+
66
+ ### Non-Functional Requirements
67
+
68
+ - Each DELETE call MUST complete within 5 seconds; parallel calls run concurrently.
69
+ - Loading indicator MUST appear within 500ms of clicking "Remove" in the confirmation drawer.
70
+ - TypeScript strict mode: zero `any` usage, zero `tsc --noEmit` errors.
71
+ - The feature MUST be gated behind the `suma` feature flag.
72
+
73
+ ### Out of Scope
74
+
75
+ - Propagating removal to child OUs (API does not expose a batch/cascade param).
76
+ - Undo / restore after removal.
77
+ - Removing the default contract (the listing already prevents navigating away from the default; removal is allowed — the next contract becomes default after reload).
78
+
79
+ ---
80
+
81
+ ## 2. Arch Decisions
82
+
83
+ ### Proposed Solution
84
+
85
+ A new `useRemoveContracts` hook wraps parallel `removeContractService` calls via `Promise.allSettled`. The hook is wired into `ContractListingLayout`, which already owns the `ContractSelectionList` and will now also render `ConfirmRemoveDrawer` and `ErrorRemoveDrawer`. The guard logic (can't remove all) lives in the layout's `handleRequestRemove` handler, mirroring the prototype.
86
+
87
+ ### Architecture Overview
88
+
89
+ ```mermaid
90
+ sequenceDiagram
91
+ participant User
92
+ participant ContractSelectionList
93
+ participant ContractListingLayout
94
+ participant ConfirmRemoveDrawer
95
+ participant ErrorRemoveDrawer
96
+ participant useRemoveContracts
97
+ participant BFF
98
+
99
+ User->>ContractSelectionList: selects contracts, clicks Remove
100
+ ContractSelectionList->>ContractListingLayout: onRemove(selectedIds)
101
+ alt selectedIds.size === contracts.length
102
+ ContractListingLayout->>ErrorRemoveDrawer: isOpen=true (validation guard)
103
+ User->>ErrorRemoveDrawer: clicks Got it
104
+ ErrorRemoveDrawer->>ContractListingLayout: onDismiss
105
+ else
106
+ ContractListingLayout->>ConfirmRemoveDrawer: isOpen=true, count, orgUnitName
107
+ User->>ConfirmRemoveDrawer: clicks Remove
108
+ ConfirmRemoveDrawer->>useRemoveContracts: removeContracts(orgUnitId, ids)
109
+ loop for each id (parallel)
110
+ useRemoveContracts->>BFF: DELETE /units/{orgUnitId}/contracts/attached/{id}
111
+ end
112
+ BFF-->>useRemoveContracts: results (allSettled)
113
+ alt all succeeded
114
+ useRemoveContracts-->>ContractListingLayout: onSuccess()
115
+ ContractListingLayout->>ContractListingLayout: clearSelection + router.replace
116
+ ContractListingLayout->>User: success toast
117
+ else any failed
118
+ useRemoveContracts-->>ContractListingLayout: onError()
119
+ ContractListingLayout->>User: error toast + router.replace
120
+ end
121
+ end
122
+ ```
123
+
124
+ ### Alternatives Considered
125
+
126
+ | Alternative | Pros | Cons | Verdict |
127
+ |---|---|---|---|
128
+ | Sequential DELETE calls (one at a time) | Simple error attribution | Slow for multi-select | Rejected — parallel is faster and matches prototype behavior |
129
+ | Single batch DELETE endpoint | One round-trip | Does not exist in the current API spec | Rejected — not available |
130
+ | Guard via disabled button (prevent selecting all) | No secondary drawer needed | Poor UX — prevents Cmd+A; not prototype-validated | Rejected — secondary "Unable to remove" drawer is the prototype pattern |
131
+ | Toast instead of ConfirmRemoveDrawer | Fewer interactions | No confirmation for destructive action; does not match prototype | Rejected — confirmation is required for destructive actions |
132
+
133
+ ### Risks & Mitigations
134
+
135
+ | Risk | Impact | Likelihood | Mitigation |
136
+ |---|---|---|---|
137
+ | Partial failure (some DELETEs succeed, some fail) | Med — inconsistent state | Low | `Promise.allSettled` captures all results; show error toast + refresh listing to reflect actual state |
138
+ | Default contract removed | Med — OU has no default | Low | Listing refresh lets the SSR pick a new default; no special client-side handling needed |
139
+
140
+ ### Key Decisions
141
+
142
+ #### Decision 1: Parallel Removes via `Promise.allSettled`
143
+
144
+ - **Status**: Accepted
145
+ - **Context**: The API has no batch DELETE endpoint. Multi-select removal would be sequential otherwise.
146
+ - **Decision**: Call `removeContractService` for each selected ID concurrently using `Promise.allSettled`. Report success only if all resolved; otherwise report error and refresh.
147
+ - **Consequences**: Fast for multi-select. Partial failures are surfaced via error toast; listing reflects actual server state after refresh.
148
+
149
+ #### Decision 2: Guard Logic in Layout, Not in `useRemoveContracts`
150
+
151
+ - **Status**: Accepted
152
+ - **Context**: The "can't remove all" constraint is a UI-level rule, not a BFF enforcement (BFF would allow it).
153
+ - **Decision**: `ContractListingLayout.handleRequestRemove` checks `selectedIds.size === contracts.length` before opening the confirmation drawer. If guard triggers, open `ErrorRemoveDrawer` instead.
154
+ - **Consequences**: Guard is co-located with the data it checks (`contracts`). Hook stays pure — only concerns itself with calling the service.
155
+
156
+ #### Decision 3: Reuse Existing Drawer Components
157
+
158
+ - **Status**: Accepted
159
+ - **Context**: `ConfirmRemoveDrawer` and `ErrorRemoveDrawer` components already exist as stubs. `ErrorRemoveDrawer` currently has "BFF error" messaging; the prototype uses it for the validation case.
160
+ - **Decision**: Update `ConfirmRemoveDrawer` to accept `count` and render dynamic text. Update `ErrorRemoveDrawer` to match the prototype's "must keep ≥1" messaging (remove retry; add "Got it"). BFF errors surface as a toast (consistent with `AddContractsDrawer`).
161
+ - **Consequences**: `ErrorRemoveDrawer` becomes the validation guard drawer only. BFF error is handled by toast, not a second drawer.
162
+
163
+ ### Implementation Plan
164
+
165
+ 1. Create `useRemoveContracts` hook — parallel `removeContractService` calls via `Promise.allSettled`; expose `{ removeContracts, isLoading }`.
166
+ 2. Update `ConfirmRemoveDrawer` — add `count` prop; render dynamic title and body copy.
167
+ 3. Update `ErrorRemoveDrawer` — swap messaging to "Unable to remove contracts" / must keep ≥1 constraint / "Got it" button only.
168
+ 4. Update `ContractSelectionList` — wire `onRemove` prop to the toolbar "Remove" button.
169
+ 5. Wire `ContractListingLayout` — `handleRequestRemove` guard; open `ConfirmRemoveDrawer` / `ErrorRemoveDrawer`; call `useRemoveContracts`; `onSuccess` refreshes listing and clears selection; `onError` shows error toast and refreshes.
170
+ 6. Unit test `useRemoveContracts` — full success, partial failure, all-fail cases.
171
+
172
+ ---
173
+
174
+ ## 3. Technical Contract
175
+
176
+ ### Data Models
177
+
178
+ ```ts
179
+ // No new data models — operates on existing ContractData.id (string)
180
+ ```
181
+
182
+ ### Interfaces
183
+
184
+ ```ts
185
+ // useRemoveContracts hook
186
+ interface UseRemoveContractsOptions {
187
+ onSuccess: () => void
188
+ onError: () => void
189
+ }
190
+
191
+ interface UseRemoveContractsReturn {
192
+ removeContracts: (params: { orgUnitId: string; ids: string[] }) => Promise<void>
193
+ isLoading: boolean
194
+ }
195
+
196
+ // Updated ConfirmRemoveDrawer props
197
+ interface ConfirmRemoveDrawerProps extends Omit<BasicDrawerProps, 'children'> {
198
+ orgUnitName: string
199
+ count: number // number of contracts being removed
200
+ onConfirm: () => void
201
+ isLoading?: boolean
202
+ }
203
+
204
+ // Updated ErrorRemoveDrawer props (validation guard only — no retry)
205
+ interface ErrorRemoveDrawerProps extends Omit<BasicDrawerProps, 'children'> {
206
+ orgUnitName: string
207
+ }
208
+
209
+ // Updated ContractSelectionList props
210
+ interface ContractSelectionListProps {
211
+ // ... existing props
212
+ onRemove: (ids: Set<string>) => void // called when toolbar Remove is clicked
213
+ }
214
+ ```
215
+
216
+ ### Integration Points
217
+
218
+ | Point | Direction | Details |
219
+ |---|---|---|
220
+ | `removeContractService` | Write | Called once per selected ID; `DELETE units/{orgUnitId}/contracts/attached/{contractId}` |
221
+ | `ContractListingLayout` | Parent | Owns guard logic, drawer states, `onSuccess`/`onError` callbacks |
222
+ | `ContractSelectionList` | Child | Receives `onRemove` prop; fires it on toolbar "Remove" click |
223
+ | `useContractSelection.clearSelection` | Write | Called on success to reset toolbar state |
224
+ | `router.replace(router.asPath)` | Navigation | Refreshes listing SSR data after removal (same as add-contracts pattern) |
225
+ | Feature flag `suma` | Gate | Remove entry point only rendered when `featureFlags.suma === true` |
226
+
227
+ ### Invariants & Constraints
228
+
229
+ - `contracts.length - selectedIds.size` MUST be ≥ 1 before opening `ConfirmRemoveDrawer`; otherwise open `ErrorRemoveDrawer`.
230
+ - The "Remove" toolbar button MUST only appear when `selectedIds.size > 0` AND `isSingleContract === false`.
231
+ - While `isLoading === true`, the confirm button MUST be disabled and the drawer MUST NOT be dismissible via backdrop click.
232
+ - On `ConfirmRemoveDrawer` cancel (backdrop or Cancel button), no BFF call is made and `selectedIds` is unchanged.
233
+ - `useRemoveContracts` MUST use `Promise.allSettled` — a single failed DELETE MUST NOT abort remaining calls.
@@ -0,0 +1,208 @@
1
+ # SUMA: Set Default Contract for Organizational Unit
2
+
3
+ > **Status**: Done
4
+ > **Created**: 2026-06-23
5
+
6
+ ## 1. Business Context
7
+
8
+ ### Problem Statement
9
+
10
+ Admins managing a Buyer Organization need to designate one contract as the default for an Organizational Unit (OU). The default contract determines which contract is pre-selected during store navigation and checkout. Without a way to change it, the default is fixed at the time contracts are attached and cannot be updated as the OU's commercial priorities evolve.
11
+
12
+ ### Goals
13
+
14
+ - Admins can mark any non-default attached contract as the default for their OU in a single action.
15
+ - The listing immediately reflects the new default after the action succeeds.
16
+ - The feature is available from both the Contract Listing page and the Profile page (Contract Information).
17
+
18
+ ### User Stories
19
+
20
+ #### US-1: Set Default from Contract Listing
21
+
22
+ - **Story**: As an admin, I want to mark a contract as default from the listing page, so that I can change the OU's default contract without navigating away.
23
+ - **Acceptance Criteria**:
24
+ - **Given** an OU has ≥2 attached contracts and the admin is on the Contract Listing page, **when** they open the dots menu for a non-default contract and click "Set as default", **then** a BFF PUT call is made and the listing refreshes to show the new default badge.
25
+ - **Given** the BFF call succeeds, **then** a success toast appears.
26
+ - **Given** the BFF call fails, **then** an error toast appears and the listing is refreshed to reflect actual server state.
27
+ - **Given** a contract is already the default (`isDefault === true`), **when** the admin opens its dots menu, **then** the "Set as default" item is disabled (greyed out, not clickable).
28
+
29
+ #### US-2: Set Default from Profile Page (Contract Information)
30
+
31
+ - **Story**: As an admin, I want to mark a contract as default from its profile page, so that I can update the default while reviewing the contract's settings.
32
+ - **Acceptance Criteria**:
33
+ - **Given** the admin is on the Profile page (`/profile/[orgUnitId]/[contractId]`) for a non-default contract, **when** they open the three-dot menu in the sidebar and click "Set as default", **then** a BFF PUT call is made and the page header updates to reflect the new default status.
34
+ - **Given** the BFF call succeeds, **then** a success toast appears.
35
+ - **Given** the BFF call fails, **then** an error toast appears.
36
+ - **Given** the contract is already the default, **when** the admin opens the sidebar menu, **then** "Set as default" is disabled.
37
+
38
+ ### Key Scenarios
39
+
40
+ | Scenario | Pre-conditions | Steps | Expected Result |
41
+ |---|---|---|---|
42
+ | Happy path — set default from listing | OU has ≥2 contracts; target contract is not default | Open dots menu → click "Set as default" | PUT call succeeds; listing refreshes; target contract shows default badge; previous default loses badge; success toast |
43
+ | Happy path — set default from profile | Admin is on Profile page for non-default contract | Open sidebar menu → click "Set as default" | PUT call succeeds; page refreshes; success toast |
44
+ | BFF error | BFF returns 5xx | Open dots menu → click "Set as default" | Error toast; listing refreshed to reflect actual state |
45
+ | Already default — listing | Contract has `isDefault === true` | Open dots menu | "Set as default" item is disabled; no BFF call possible |
46
+ | Already default — profile | Admin is on Profile page for default contract | Open sidebar menu | "Set as default" item is disabled |
47
+ | Single contract | OU has exactly 1 contract | — | `ContractSelectionList` not rendered (single-contract path); feature not exposed |
48
+
49
+ ### Functional Requirements
50
+
51
+ - **FR-1**: "Set as default" MUST be disabled (visually and functionally) when `contract.isDefault === true`.
52
+ - **FR-2**: On click, a `PUT units/{orgUnitId}/contracts/attached/{contractId}/default` call MUST be made with no request body, only the session cookie in the header.
53
+ - **FR-3**: On success, the listing/page MUST refresh via `router.replace(router.asPath)` to reflect updated `isDefault` values from the BFF.
54
+ - **FR-4**: On success, a success toast MUST appear.
55
+ - **FR-5**: On error, an error toast MUST appear; if on the listing page, the listing MUST refresh to reflect actual server state.
56
+ - **FR-6**: While the mutation is in progress, the dots menu trigger MUST be disabled to prevent double-submit.
57
+ - **FR-7**: The "Set as default" dots menu item MUST use `StarOutline` when the contract is not the default, and `Star` (filled) when it already is the default (disabled state). The default badge in `ContractListItem` MUST use `Star` (filled).
58
+
59
+ ### Non-Functional Requirements
60
+
61
+ - BFF call MUST complete within 5 seconds.
62
+ - Success/error feedback MUST be visible within 500ms of the response.
63
+ - TypeScript strict mode: zero `any` usage, zero `tsc --noEmit` errors.
64
+ - The feature MUST be accessible only when the `suma` feature flag is active (implicit — the entire contracts feature is gated at route level).
65
+
66
+ ### Out of Scope
67
+
68
+ - Confirmation drawer before setting default (action is non-destructive; direct action is the chosen UX).
69
+ - Propagating the default change to child OUs.
70
+ - Unsetting the default without replacing it (there must always be a default contract).
71
+
72
+ ---
73
+
74
+ ## 2. Arch Decisions
75
+
76
+ ### Proposed Solution
77
+
78
+ A new `useSetDefaultContract` hook wraps a `setDefaultContractService` (following the same `withClientErrorBoundary` pattern as `useAddContracts` and `useRemoveContracts`). The hook is wired into both `ContractListingLayout` (via `ContractListItem`) and `ContractInformationLayout`. The "Set as default" dots menu item is enabled only when `!contract.isDefault`, and the action is fire-and-refresh (no drawer).
79
+
80
+ ### Architecture Overview
81
+
82
+ ```mermaid
83
+ sequenceDiagram
84
+ participant Admin
85
+ participant ContractListItem / ContractInformationLayout
86
+ participant useSetDefaultContract
87
+ participant BFF
88
+
89
+ Admin->>ContractListItem / ContractInformationLayout: clicks "Set as default"
90
+ ContractListItem / ContractInformationLayout->>useSetDefaultContract: setDefaultContract({ orgUnitId, contractId })
91
+ useSetDefaultContract->>BFF: PUT /units/{orgUnitId}/contracts/attached/{contractId}/default
92
+ alt success
93
+ BFF-->>useSetDefaultContract: 200 OK
94
+ useSetDefaultContract-->>ContractListItem / ContractInformationLayout: onSuccess()
95
+ ContractListItem / ContractInformationLayout->>ContractListItem / ContractInformationLayout: router.replace(router.asPath)
96
+ ContractListItem / ContractInformationLayout->>Admin: success toast
97
+ else error
98
+ BFF-->>useSetDefaultContract: 4xx / 5xx
99
+ useSetDefaultContract-->>ContractListItem / ContractInformationLayout: onError(error)
100
+ ContractListItem / ContractInformationLayout->>Admin: error toast + router.replace
101
+ end
102
+ ```
103
+
104
+ ### Alternatives Considered
105
+
106
+ | Alternative | Pros | Cons | Verdict |
107
+ |---|---|---|---|
108
+ | Optimistic update (update `isDefault` client-side before BFF responds) | Instant visual feedback | Risk of stale state if BFF fails; `isDefault` is authoritative on server | Rejected — server refresh is simpler and correct |
109
+ | Confirmation drawer before action | Prevents accidental clicks | Extra interaction for a non-destructive action; prototype calls for direct action | Rejected — direct action is the specified UX |
110
+
111
+ ### Risks & Mitigations
112
+
113
+ | Risk | Impact | Likelihood | Mitigation |
114
+ |---|---|---|---|
115
+ | Race condition — user sets default while previous request is in-flight | Low — duplicate PUT on same contract | Very Low | Disable dots trigger while `isLoading === true` |
116
+ | BFF returns success but listing refresh shows old data (SSR cache) | Med — visual inconsistency | Low | `router.replace(router.asPath)` forces SSR re-fetch; no client-side cache to bust |
117
+
118
+ ### Key Decisions
119
+
120
+ #### Decision 1: Direct Action — No Confirmation Drawer
121
+
122
+ - **Status**: Accepted
123
+ - **Context**: "Set as default" is non-destructive (the previous default is not removed, only demoted). The prototype specifies direct action.
124
+ - **Decision**: Click → BFF call → toast. No intermediate confirmation step.
125
+ - **Consequences**: Simpler UX, fewer components needed. Accidental clicks can be corrected by setting another contract as default.
126
+
127
+ #### Decision 2: Server Refresh Over Optimistic Update
128
+
129
+ - **Status**: Accepted
130
+ - **Context**: `isDefault` is authoritative on the BFF. Two contracts in the listing have `isDefault` values that must be swapped atomically from the client's perspective.
131
+ - **Decision**: After success, call `router.replace(router.asPath)` to re-run SSR and get the correct `isDefault` values for all contracts.
132
+ - **Consequences**: Slight latency on visual update (~SSR round-trip). Eliminates risk of client-side state diverging from server.
133
+
134
+ #### Decision 3: Disable "Set as default" When Already Default
135
+
136
+ - **Status**: Accepted
137
+ - **Context**: Setting the same contract as default twice is a no-op but could confuse users or generate unnecessary BFF calls.
138
+ - **Decision**: When `contract.isDefault === true`, render the `DropdownItem` with the `disabled` prop. Apply the same logic in `ContractInformationLayout`'s sidebar menu.
139
+ - **Consequences**: Clear visual affordance; no guard needed inside the hook/service.
140
+
141
+ ### Implementation Plan
142
+
143
+ 1. Add `setDefaultContract(orgUnitId, contractId, cookie)` method to `ContractsClient`.
144
+ 2. Create `set-default-contract.service.ts` wrapped by `withClientErrorBoundary`.
145
+ 3. Create `useSetDefaultContract` hook — same pattern as `useRemoveContracts`.
146
+ 4. Enable "Set as default" in `ContractListItem` — pass `onSetDefault` prop; enable/disable based on `isDefault`.
147
+ 5. Wire `ContractListingLayout` — pass `onSetDefault` handler to `ContractSelectionList` → `ContractListItem`.
148
+ 6. Enable "Set as default" in `ContractInformationLayout` — use `useSetDefaultContract` directly in the layout.
149
+ 7. Unit test `useSetDefaultContract` and `setDefaultContractService`.
150
+
151
+ ---
152
+
153
+ ## 3. Technical Contract
154
+
155
+ ### Data Models
156
+
157
+ ```ts
158
+ // No new data models — operates on existing ContractData.isDefault (boolean)
159
+ // ContractData.isDefault is already optional; after this feature it is always present for attached contracts
160
+ ```
161
+
162
+ ### Interfaces
163
+
164
+ ```ts
165
+ // ContractsClient — new method
166
+ setDefaultContract(orgUnitId: string, contractId: string, cookie: string): Promise<unknown>
167
+
168
+ // setDefaultContractService
169
+ interface SetDefaultContractServiceProps {
170
+ orgUnitId: string
171
+ contractId: string
172
+ cookie: string
173
+ }
174
+
175
+ // useSetDefaultContract hook
176
+ interface UseSetDefaultContractReturn {
177
+ setDefaultContract: (params: Omit<SetDefaultContractServiceProps, 'cookie'>) => Promise<void> | undefined
178
+ isSetDefaultContractLoading: boolean
179
+ }
180
+
181
+ // ContractListItem — updated props
182
+ interface ContractListItemProps {
183
+ // ... existing props
184
+ onSetDefault: () => void // called when "Set as default" is clicked; disabled when contract.isDefault === true
185
+ }
186
+
187
+ // ContractSelectionList — updated props
188
+ interface ContractSelectionListProps {
189
+ // ... existing props
190
+ onSetDefault: (contractId: string) => void
191
+ }
192
+ ```
193
+
194
+ ### Integration Points
195
+
196
+ | Point | Direction | Details |
197
+ |---|---|---|
198
+ | `ContractsClient.setDefaultContract` | Write | `PUT units/{orgUnitId}/contracts/attached/{contractId}/default` — no body, cookie in header |
199
+ | `ContractListingLayout` | Parent (listing) | Owns `useSetDefaultContract`; passes `onSetDefault` down to `ContractSelectionList` → `ContractListItem` |
200
+ | `ContractInformationLayout` | Parent (info page) | Owns its own `useSetDefaultContract` instance; wires the sidebar dropdown item |
201
+ | `router.replace(router.asPath)` | Navigation | Refreshes SSR data after success or error to sync `isDefault` values |
202
+
203
+ ### Invariants & Constraints
204
+
205
+ - Exactly one contract per OU MUST be `isDefault === true` at any time (enforced by BFF; client does not validate).
206
+ - "Set as default" MUST be disabled (`disabled` prop on `DropdownItem`) when `contract.isDefault === true`.
207
+ - While `isSetDefaultContractLoading === true`, the dots menu trigger MUST be non-interactive.
208
+ - No BFF call is made when the item is disabled — guard is purely visual via `disabled` prop.