@vtex/faststore-plugin-buyer-portal 2.0.7 → 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 +30 -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/components/BasicBuyingPolicyDrawer/BasicBuyingPolicyDrawer.tsx +2 -2
- 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,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.
|