@vtex/faststore-plugin-buyer-portal 2.0.26 → 2.0.27
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/package.json +1 -1
- package/src/features/contracts/components/ContractListItem/ContractListItem.tsx +10 -3
- package/src/features/contracts/layouts/ContractListingLayout/ContractListingLayout.tsx +18 -5
- package/src/features/contracts/layouts/ContractListingLayout/ContractSettingsNav.tsx +14 -9
- package/src/features/contracts/layouts/ContractListingLayout/contract-listing-layout.scss +13 -0
- package/src/features/contracts/services/__tests__/list-unit-contracts.service.test.ts +117 -0
- package/src/features/contracts/services/__tests__/resolve-attached-contract.service.test.ts +118 -0
- package/src/features/contracts/services/index.ts +10 -0
- package/src/features/contracts/services/list-unit-contracts.service.ts +44 -0
- package/src/features/contracts/services/resolve-attached-contract.service.ts +53 -0
- package/src/features/contracts/types/ContractData.ts +2 -0
- package/src/features/org-units/layouts/OrgUnitDetailsLayout/OrgUnitDetailsLayout.tsx +12 -7
- package/src/features/profile/layouts/ProfileLayout/ProfileLayout.tsx +30 -1
- package/src/features/shared/layouts/SumaPageLayout/FullSidebarNav.tsx +9 -4
- package/src/features/shared/layouts/SumaPageLayout/SumaSidebar.tsx +27 -5
- package/src/features/shared/utils/__tests__/contractScopedLink.test.ts +39 -0
- package/src/features/shared/utils/__tests__/resolveContractId.test.ts +10 -2
- package/src/features/shared/utils/constants.ts +1 -1
- package/src/features/shared/utils/contractScopedLink.ts +16 -0
- package/src/features/shared/utils/getContractSettingsLinks.ts +7 -9
- package/src/features/shared/utils/getFinanceSettingsLinks.ts +4 -6
- package/src/features/shared/utils/index.ts +1 -0
- package/src/features/shared/utils/resolveContractId.ts +6 -3
- package/src/pages/contracts.tsx +2 -2
- package/src/pages/org-unit-details.tsx +22 -2
- package/src/pages/org-units.tsx +36 -23
- package/src/pages/roles.tsx +59 -42
- package/src/pages/users.tsx +25 -5
package/package.json
CHANGED
|
@@ -29,10 +29,12 @@ export const ContractListItem = ({
|
|
|
29
29
|
const displayName = contract.name ?? contract.email;
|
|
30
30
|
const { pushToast } = useUI();
|
|
31
31
|
const { switchContract, loading } = useSwitchContract();
|
|
32
|
+
const isRemovable = contract.isAttached !== false;
|
|
32
33
|
|
|
33
34
|
const handleCheckboxClick = (event: React.MouseEvent) => {
|
|
34
35
|
event.stopPropagation();
|
|
35
36
|
event.preventDefault();
|
|
37
|
+
if (!isRemovable) return;
|
|
36
38
|
onSelect(contract.id, event);
|
|
37
39
|
};
|
|
38
40
|
|
|
@@ -59,10 +61,14 @@ export const ContractListItem = ({
|
|
|
59
61
|
>
|
|
60
62
|
<div
|
|
61
63
|
data-fs-bp-contract-list-item-checkbox
|
|
64
|
+
data-fs-bp-contract-list-item-checkbox-disabled={
|
|
65
|
+
!isRemovable || undefined
|
|
66
|
+
}
|
|
62
67
|
role="checkbox"
|
|
63
68
|
aria-checked={isSelected}
|
|
69
|
+
aria-disabled={!isRemovable}
|
|
64
70
|
aria-label={`Select ${displayName ?? "contract"}`}
|
|
65
|
-
tabIndex={0}
|
|
71
|
+
tabIndex={isRemovable ? 0 : -1}
|
|
66
72
|
onClick={handleCheckboxClick}
|
|
67
73
|
onKeyDown={(e) =>
|
|
68
74
|
e.key === " " && handleCheckboxClick(e as unknown as React.MouseEvent)
|
|
@@ -91,7 +97,7 @@ export const ContractListItem = ({
|
|
|
91
97
|
<Dropdown>
|
|
92
98
|
<BasicDropdownMenu.Trigger />
|
|
93
99
|
<BasicDropdownMenu>
|
|
94
|
-
{contract.isDefault ? (
|
|
100
|
+
{contract.isDefault || !isRemovable ? (
|
|
95
101
|
<DropdownItem disabled>
|
|
96
102
|
<Icon name="Star" width={16} height={16} />
|
|
97
103
|
{t("contracts.actions.setAsDefault")}
|
|
@@ -105,7 +111,8 @@ export const ContractListItem = ({
|
|
|
105
111
|
<BasicDropdownMenu.Separator />
|
|
106
112
|
<DropdownItem
|
|
107
113
|
data-fs-bp-dropdown-menu-item-mode="danger"
|
|
108
|
-
|
|
114
|
+
disabled={!isRemovable}
|
|
115
|
+
onClick={isRemovable ? onRemove : undefined}
|
|
109
116
|
>
|
|
110
117
|
<Icon name="CircleRemove" width={16} height={16} />
|
|
111
118
|
{t("shared.buttons.remove")}
|
|
@@ -23,6 +23,7 @@ import { GlobalLayout } from "../../../shared/layouts";
|
|
|
23
23
|
import { SumaSidebar } from "../../../shared/layouts/SumaPageLayout/SumaSidebar";
|
|
24
24
|
import { useLocalization } from "../../../shared/localization/LocalizationContext";
|
|
25
25
|
import { buyerPortalRoutes } from "../../../shared/utils/buyerPortalRoutes";
|
|
26
|
+
import { contractScopedLink } from "../../../shared/utils/contractScopedLink";
|
|
26
27
|
import { AddContractsDrawer } from "../../components/AddContractsDrawer/AddContractsDrawer";
|
|
27
28
|
import { ConfirmRemoveDrawer } from "../../components/ConfirmRemoveDrawer/ConfirmRemoveDrawer";
|
|
28
29
|
import { ContractSelectAllRow } from "../../components/ContractSelectAllRow";
|
|
@@ -169,9 +170,14 @@ export const ContractListingLayout = ({
|
|
|
169
170
|
},
|
|
170
171
|
});
|
|
171
172
|
|
|
173
|
+
const removableIds = new Set(
|
|
174
|
+
contracts.filter((c) => c.isAttached !== false).map((c) => c.id)
|
|
175
|
+
);
|
|
176
|
+
|
|
172
177
|
const handleRequestRemove = (overrideIds?: Set<string>) => {
|
|
173
178
|
const targetIds = overrideIds ?? selectedIds;
|
|
174
|
-
if (targetIds.size ===
|
|
179
|
+
if (targetIds.size === 0) return;
|
|
180
|
+
if (targetIds.size === removableIds.size) {
|
|
175
181
|
openUnableToRemoveDrawer();
|
|
176
182
|
return;
|
|
177
183
|
}
|
|
@@ -180,6 +186,7 @@ export const ContractListingLayout = ({
|
|
|
180
186
|
};
|
|
181
187
|
|
|
182
188
|
const handleItemRemove = (id: string) => {
|
|
189
|
+
if (!removableIds.has(id)) return;
|
|
183
190
|
handleRequestRemove(new Set([id]));
|
|
184
191
|
};
|
|
185
192
|
|
|
@@ -215,6 +222,7 @@ export const ContractListingLayout = ({
|
|
|
215
222
|
});
|
|
216
223
|
|
|
217
224
|
const handleSetDefault = (contractId: string) => {
|
|
225
|
+
if (!removableIds.has(contractId)) return;
|
|
218
226
|
setDefaultContract({ orgUnitId: orgUnit.id, contractId })?.catch(() => {});
|
|
219
227
|
};
|
|
220
228
|
|
|
@@ -267,9 +275,14 @@ export const ContractListingLayout = ({
|
|
|
267
275
|
selectedIds.has(id)
|
|
268
276
|
).length;
|
|
269
277
|
|
|
278
|
+
// Contracts sourced from the legacy fallback (never attached) can't be
|
|
279
|
+
// selected for bulk removal — the BFF has no attach relation to detach.
|
|
280
|
+
const removableFilteredIds = filteredIds.filter((id) => removableIds.has(id));
|
|
281
|
+
|
|
270
282
|
const handleSelect = (id: string, event: React.MouseEvent) => {
|
|
283
|
+
if (!removableIds.has(id)) return;
|
|
271
284
|
if (event.shiftKey) {
|
|
272
|
-
handleShiftClick(id,
|
|
285
|
+
handleShiftClick(id, removableFilteredIds);
|
|
273
286
|
} else {
|
|
274
287
|
handleModifierClick(id);
|
|
275
288
|
}
|
|
@@ -279,14 +292,14 @@ export const ContractListingLayout = ({
|
|
|
279
292
|
if (selectedInFiltered > 0) {
|
|
280
293
|
clearSelection();
|
|
281
294
|
} else {
|
|
282
|
-
handleSelectAll(
|
|
295
|
+
handleSelectAll(removableFilteredIds);
|
|
283
296
|
}
|
|
284
297
|
};
|
|
285
298
|
|
|
286
299
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
287
300
|
if ((event.metaKey || event.ctrlKey) && event.key === "a") {
|
|
288
301
|
event.preventDefault();
|
|
289
|
-
handleSelectAll(
|
|
302
|
+
handleSelectAll(removableFilteredIds);
|
|
290
303
|
}
|
|
291
304
|
};
|
|
292
305
|
|
|
@@ -342,7 +355,7 @@ export const ContractListingLayout = ({
|
|
|
342
355
|
<BasicCard
|
|
343
356
|
data-fs-bp-contracts-settings-card
|
|
344
357
|
footerMessage={t("orgUnitDetails.links.manageContractSettings")}
|
|
345
|
-
footerLink={buyerPortalRoutes.profileDetails
|
|
358
|
+
footerLink={contractScopedLink(buyerPortalRoutes.profileDetails, {
|
|
346
359
|
orgUnitId: orgUnit.id,
|
|
347
360
|
contractId: defaultContractId,
|
|
348
361
|
})}
|
|
@@ -4,6 +4,7 @@ import { AccountingFieldDropdown } from "../../../accounting-fields/components";
|
|
|
4
4
|
import { checkAccountingFieldIsEmpty } from "../../../accounting-fields/utils";
|
|
5
5
|
import { Icon } from "../../../shared/components";
|
|
6
6
|
import { buyerPortalRoutes } from "../../../shared/utils/buyerPortalRoutes";
|
|
7
|
+
import { contractScopedLink } from "../../../shared/utils/contractScopedLink";
|
|
7
8
|
|
|
8
9
|
import type { AccountingField } from "../../../accounting-fields/types";
|
|
9
10
|
import type { LocalizeFn } from "../../../shared/localization/types";
|
|
@@ -35,23 +36,23 @@ export const buildContractSettingsNavItems = ({
|
|
|
35
36
|
return [
|
|
36
37
|
{
|
|
37
38
|
name: t("contracts.nav.basicInformation"),
|
|
38
|
-
link: buyerPortalRoutes.profileDetails
|
|
39
|
+
link: contractScopedLink(buyerPortalRoutes.profileDetails, linkParams),
|
|
39
40
|
},
|
|
40
41
|
{
|
|
41
42
|
name: t("layouts.navigation.addresses"),
|
|
42
|
-
link: buyerPortalRoutes.addresses
|
|
43
|
+
link: contractScopedLink(buyerPortalRoutes.addresses, linkParams),
|
|
43
44
|
},
|
|
44
45
|
{
|
|
45
46
|
name: t("layouts.navigation.paymentMethods"),
|
|
46
|
-
link: buyerPortalRoutes.paymentMethods
|
|
47
|
+
link: contractScopedLink(buyerPortalRoutes.paymentMethods, linkParams),
|
|
47
48
|
},
|
|
48
49
|
{
|
|
49
50
|
name: t("layouts.navigation.creditCards"),
|
|
50
|
-
link: buyerPortalRoutes.creditCards
|
|
51
|
+
link: contractScopedLink(buyerPortalRoutes.creditCards, linkParams),
|
|
51
52
|
},
|
|
52
53
|
{
|
|
53
54
|
name: t("contracts.nav.assortment"),
|
|
54
|
-
link: buyerPortalRoutes.productAssortment
|
|
55
|
+
link: contractScopedLink(buyerPortalRoutes.productAssortment, linkParams),
|
|
55
56
|
},
|
|
56
57
|
{
|
|
57
58
|
name: t("layouts.navigation.accountingFields"),
|
|
@@ -59,10 +60,14 @@ export const buildContractSettingsNavItems = ({
|
|
|
59
60
|
submenu: {
|
|
60
61
|
items: accountingFields.map((field) => ({
|
|
61
62
|
name: field.name,
|
|
62
|
-
link:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
link: contractScopedLink(
|
|
64
|
+
(params) =>
|
|
65
|
+
buyerPortalRoutes.accountingFields({
|
|
66
|
+
...params,
|
|
67
|
+
accountingFieldId: field.id,
|
|
68
|
+
}),
|
|
69
|
+
linkParams
|
|
70
|
+
),
|
|
66
71
|
hasNotification: checkAccountingFieldIsEmpty(field),
|
|
67
72
|
actions: (
|
|
68
73
|
<AccountingFieldDropdown
|
|
@@ -89,6 +89,14 @@
|
|
|
89
89
|
flex-shrink: 0;
|
|
90
90
|
color: var(--fs-bp-color-brand);
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
[data-fs-bp-suma-sidebar-identity-contract] {
|
|
94
|
+
@include text-style("body");
|
|
95
|
+
color: var(--fs-bp-color-neutral-8);
|
|
96
|
+
white-space: nowrap;
|
|
97
|
+
overflow: hidden;
|
|
98
|
+
text-overflow: ellipsis;
|
|
99
|
+
}
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
102
|
|
|
@@ -437,6 +445,11 @@
|
|
|
437
445
|
outline: 2px solid var(--fs-bp-color-brand);
|
|
438
446
|
outline-offset: 2px;
|
|
439
447
|
}
|
|
448
|
+
|
|
449
|
+
&[data-fs-bp-contract-list-item-checkbox-disabled] {
|
|
450
|
+
cursor: not-allowed;
|
|
451
|
+
opacity: 0.4;
|
|
452
|
+
}
|
|
440
453
|
}
|
|
441
454
|
|
|
442
455
|
[data-fs-bp-letter-highlight] {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("../list-attached-contracts.service", () => ({
|
|
4
|
+
listAttachedContractsService: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock("../get-contracts-org-by-unit-id.service", () => ({
|
|
8
|
+
getContractsByOrgUnitIdService: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { getContractsByOrgUnitIdService } from "../get-contracts-org-by-unit-id.service";
|
|
12
|
+
import { listAttachedContractsService } from "../list-attached-contracts.service";
|
|
13
|
+
import { listUnitContractsService } from "../list-unit-contracts.service";
|
|
14
|
+
|
|
15
|
+
import type { ContractData } from "../../types";
|
|
16
|
+
|
|
17
|
+
const listAttachedContractsMock = vi.mocked(listAttachedContractsService);
|
|
18
|
+
const getContractsByOrgUnitIdMock = vi.mocked(getContractsByOrgUnitIdService);
|
|
19
|
+
|
|
20
|
+
const cookie = "VtexIdclientAutCookie=test-token";
|
|
21
|
+
const orgUnitId = "unit-123";
|
|
22
|
+
|
|
23
|
+
function contract(id: string, isDefault = false): ContractData {
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
name: id,
|
|
27
|
+
email: `${id}@b.com`,
|
|
28
|
+
isActive: true,
|
|
29
|
+
creationDate: "2026-01-01",
|
|
30
|
+
isDefault,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("listUnitContractsService", () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
listAttachedContractsMock.mockReset();
|
|
37
|
+
getContractsByOrgUnitIdMock.mockReset();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns the attached listing when the unit has attached contracts", async () => {
|
|
41
|
+
listAttachedContractsMock.mockResolvedValue([
|
|
42
|
+
contract("a", true),
|
|
43
|
+
contract("b"),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const result = await listUnitContractsService({ orgUnitId, cookie });
|
|
47
|
+
|
|
48
|
+
expect(result.map((c) => c.id)).toEqual(["a", "b"]);
|
|
49
|
+
expect(getContractsByOrgUnitIdMock).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("flags attached contracts as isAttached: true", async () => {
|
|
53
|
+
listAttachedContractsMock.mockResolvedValue([contract("a", true)]);
|
|
54
|
+
|
|
55
|
+
const result = await listUnitContractsService({ orgUnitId, cookie });
|
|
56
|
+
|
|
57
|
+
expect(result.map((c) => c.isAttached)).toEqual([true]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to the legacy unit-contracts listing when nothing is attached", async () => {
|
|
61
|
+
listAttachedContractsMock.mockResolvedValue([]);
|
|
62
|
+
getContractsByOrgUnitIdMock.mockResolvedValue([
|
|
63
|
+
contract("legacy-1"),
|
|
64
|
+
contract("legacy-2"),
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const result = await listUnitContractsService({ orgUnitId, cookie });
|
|
68
|
+
|
|
69
|
+
expect(result.map((c) => c.id)).toEqual(["legacy-1", "legacy-2"]);
|
|
70
|
+
expect(getContractsByOrgUnitIdMock).toHaveBeenCalledWith({
|
|
71
|
+
orgUnitId,
|
|
72
|
+
cookie,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("flags legacy fallback contracts as isAttached: false", async () => {
|
|
77
|
+
listAttachedContractsMock.mockResolvedValue([]);
|
|
78
|
+
getContractsByOrgUnitIdMock.mockResolvedValue([contract("legacy-1")]);
|
|
79
|
+
|
|
80
|
+
const result = await listUnitContractsService({ orgUnitId, cookie });
|
|
81
|
+
|
|
82
|
+
expect(result.map((c) => c.isAttached)).toEqual([false]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("flags the first fallback contract as default when none is flagged", async () => {
|
|
86
|
+
listAttachedContractsMock.mockResolvedValue([]);
|
|
87
|
+
getContractsByOrgUnitIdMock.mockResolvedValue([
|
|
88
|
+
contract("legacy-1"),
|
|
89
|
+
contract("legacy-2"),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const result = await listUnitContractsService({ orgUnitId, cookie });
|
|
93
|
+
|
|
94
|
+
expect(result.map((c) => c.isDefault)).toEqual([true, false]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("honors an explicit default in the fallback listing", async () => {
|
|
98
|
+
listAttachedContractsMock.mockResolvedValue([]);
|
|
99
|
+
getContractsByOrgUnitIdMock.mockResolvedValue([
|
|
100
|
+
contract("legacy-1"),
|
|
101
|
+
contract("legacy-2", true),
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const result = await listUnitContractsService({ orgUnitId, cookie });
|
|
105
|
+
|
|
106
|
+
expect(result.map((c) => c.isDefault)).toEqual([false, true]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns an empty list when both listings are empty", async () => {
|
|
110
|
+
listAttachedContractsMock.mockResolvedValue([]);
|
|
111
|
+
getContractsByOrgUnitIdMock.mockResolvedValue([]);
|
|
112
|
+
|
|
113
|
+
const result = await listUnitContractsService({ orgUnitId, cookie });
|
|
114
|
+
|
|
115
|
+
expect(result).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("../list-unit-contracts.service", () => ({
|
|
4
|
+
listUnitContractsService: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock("../../../shared/services/logger", () => ({
|
|
8
|
+
logError: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { logError } from "../../../shared/services/logger";
|
|
12
|
+
import { listUnitContractsService } from "../list-unit-contracts.service";
|
|
13
|
+
import { resolveAttachedContractService } from "../resolve-attached-contract.service";
|
|
14
|
+
|
|
15
|
+
import type { ContractData } from "../../types";
|
|
16
|
+
|
|
17
|
+
const listUnitContractsMock = vi.mocked(listUnitContractsService);
|
|
18
|
+
const logErrorMock = vi.mocked(logError);
|
|
19
|
+
|
|
20
|
+
const cookie = "VtexIdclientAutCookie=test-token";
|
|
21
|
+
const orgUnitId = "unit-123";
|
|
22
|
+
|
|
23
|
+
function contract(id: string, isDefault = false): ContractData {
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
name: id,
|
|
27
|
+
email: `${id}@b.com`,
|
|
28
|
+
isActive: true,
|
|
29
|
+
creationDate: "2026-01-01",
|
|
30
|
+
isDefault,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("resolveAttachedContractService", () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
listUnitContractsMock.mockReset();
|
|
37
|
+
logErrorMock.mockReset();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns the URL's contract when it belongs to the unit", async () => {
|
|
41
|
+
listUnitContractsMock.mockResolvedValue([
|
|
42
|
+
contract("a", true),
|
|
43
|
+
contract("b"),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const result = await resolveAttachedContractService({
|
|
47
|
+
orgUnitId,
|
|
48
|
+
contractId: "b",
|
|
49
|
+
cookie,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(result?.id).toBe("b");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns the unit's default contract when no contractId is given", async () => {
|
|
56
|
+
listUnitContractsMock.mockResolvedValue([
|
|
57
|
+
contract("a"),
|
|
58
|
+
contract("b", true),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const result = await resolveAttachedContractService({ orgUnitId, cookie });
|
|
62
|
+
|
|
63
|
+
expect(result?.id).toBe("b");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("falls back to the unit's default when the URL's contract belongs to another unit", async () => {
|
|
67
|
+
listUnitContractsMock.mockResolvedValue([
|
|
68
|
+
contract("a", true),
|
|
69
|
+
contract("b"),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const result = await resolveAttachedContractService({
|
|
73
|
+
orgUnitId,
|
|
74
|
+
contractId: "foreign-contract",
|
|
75
|
+
cookie,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(result?.id).toBe("a");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("falls back to the first contract when none is flagged as default", async () => {
|
|
82
|
+
listUnitContractsMock.mockResolvedValue([contract("a"), contract("b")]);
|
|
83
|
+
|
|
84
|
+
const result = await resolveAttachedContractService({ orgUnitId, cookie });
|
|
85
|
+
|
|
86
|
+
expect(result?.id).toBe("a");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns null when the unit has no contracts", async () => {
|
|
90
|
+
listUnitContractsMock.mockResolvedValue([]);
|
|
91
|
+
|
|
92
|
+
const result = await resolveAttachedContractService({ orgUnitId, cookie });
|
|
93
|
+
|
|
94
|
+
expect(result).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns null when the lookup fails instead of breaking the page", async () => {
|
|
98
|
+
listUnitContractsMock.mockRejectedValue(new Error("BFF unavailable"));
|
|
99
|
+
|
|
100
|
+
const result = await resolveAttachedContractService({ orgUnitId, cookie });
|
|
101
|
+
|
|
102
|
+
expect(result).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("logs the error when the lookup fails", async () => {
|
|
106
|
+
listUnitContractsMock.mockRejectedValue(new Error("BFF unavailable"));
|
|
107
|
+
|
|
108
|
+
await resolveAttachedContractService({ orgUnitId, cookie });
|
|
109
|
+
|
|
110
|
+
expect(logErrorMock).toHaveBeenCalledWith(
|
|
111
|
+
"Failed to resolve attached contract",
|
|
112
|
+
expect.objectContaining({
|
|
113
|
+
orgUnitId,
|
|
114
|
+
error_message: "BFF unavailable",
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -12,6 +12,16 @@ export { getContractDetailsService } from "./get-contract-details.service";
|
|
|
12
12
|
|
|
13
13
|
export { listAttachedContractsService } from "./list-attached-contracts.service";
|
|
14
14
|
|
|
15
|
+
export {
|
|
16
|
+
listUnitContractsService,
|
|
17
|
+
type ListUnitContractsServiceProps,
|
|
18
|
+
} from "./list-unit-contracts.service";
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
resolveAttachedContractService,
|
|
22
|
+
type ResolveAttachedContractServiceProps,
|
|
23
|
+
} from "./resolve-attached-contract.service";
|
|
24
|
+
|
|
15
25
|
export { listAvailableContractsService } from "./list-available-contracts.service";
|
|
16
26
|
|
|
17
27
|
export {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { getContractsByOrgUnitIdService } from "./get-contracts-org-by-unit-id.service";
|
|
2
|
+
import { listAttachedContractsService } from "./list-attached-contracts.service";
|
|
3
|
+
|
|
4
|
+
import type { ContractData } from "../types";
|
|
5
|
+
|
|
6
|
+
export type ListUnitContractsServiceProps = {
|
|
7
|
+
orgUnitId: string;
|
|
8
|
+
cookie: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Lists the contracts a unit operates under. The SUMA attach listing is the
|
|
13
|
+
* source of truth, but units that predate the attach flow have nothing
|
|
14
|
+
* explicitly attached — for those, fall back to the legacy unit-contracts
|
|
15
|
+
* endpoint (`units/<id>/contracts`), which is what the pre-SUMA portal
|
|
16
|
+
* displayed. Never fall back to the unit's `customerGroup.customerIds`:
|
|
17
|
+
* those mirror the session's customer group, not the unit's own contracts.
|
|
18
|
+
* The first fallback contract is flagged as default when none is, mirroring
|
|
19
|
+
* what listAttachedContractsService does for an unflagged BFF response.
|
|
20
|
+
* Fallback contracts are flagged `isAttached: false` — callers must treat
|
|
21
|
+
* them as read-only (no attach/detach actions), since the BFF has no
|
|
22
|
+
* attach relation to operate on for them.
|
|
23
|
+
*/
|
|
24
|
+
export const listUnitContractsService = async ({
|
|
25
|
+
orgUnitId,
|
|
26
|
+
cookie,
|
|
27
|
+
}: ListUnitContractsServiceProps): Promise<ContractData[]> => {
|
|
28
|
+
const attached = await listAttachedContractsService({ orgUnitId, cookie });
|
|
29
|
+
|
|
30
|
+
if (attached?.length) {
|
|
31
|
+
return attached.map((contract) => ({ ...contract, isAttached: true }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const contracts =
|
|
35
|
+
(await getContractsByOrgUnitIdService({ orgUnitId, cookie })) ?? [];
|
|
36
|
+
|
|
37
|
+
const hasExplicitDefault = contracts.some((contract) => contract.isDefault);
|
|
38
|
+
|
|
39
|
+
return contracts.map((contract, index) => ({
|
|
40
|
+
...contract,
|
|
41
|
+
isDefault: hasExplicitDefault ? Boolean(contract.isDefault) : index === 0,
|
|
42
|
+
isAttached: false,
|
|
43
|
+
}));
|
|
44
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { logError } from "../../shared/services/logger";
|
|
2
|
+
|
|
3
|
+
import { listUnitContractsService } from "./list-unit-contracts.service";
|
|
4
|
+
|
|
5
|
+
import type { ContractData } from "../types";
|
|
6
|
+
|
|
7
|
+
export type ResolveAttachedContractServiceProps = {
|
|
8
|
+
orgUnitId: string;
|
|
9
|
+
/** contractId carried in the URL — may be absent or belong to another unit */
|
|
10
|
+
contractId?: string;
|
|
11
|
+
cookie: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolves which contract the sidebar of an org-unit-scoped page should link
|
|
16
|
+
* to: the URL's contractId when it belongs to the unit, otherwise the unit's
|
|
17
|
+
* default contract. Contracts come from listUnitContractsService (attached
|
|
18
|
+
* listing, legacy unit contracts as fallback). Returns null when the unit has
|
|
19
|
+
* no resolvable contract (or the lookup fails) — callers must then avoid
|
|
20
|
+
* building contract-scoped URLs, since an empty contractId segment makes the
|
|
21
|
+
* path fall into the home catch-all route and 302 the user to their root org
|
|
22
|
+
* unit.
|
|
23
|
+
*/
|
|
24
|
+
export const resolveAttachedContractService = async ({
|
|
25
|
+
orgUnitId,
|
|
26
|
+
contractId,
|
|
27
|
+
cookie,
|
|
28
|
+
}: ResolveAttachedContractServiceProps): Promise<ContractData | null> => {
|
|
29
|
+
let contracts: ContractData[];
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
contracts = await listUnitContractsService({ orgUnitId, cookie });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
// The contract only feeds sidebar links/identity; the page's own data
|
|
35
|
+
// must still render if this lookup fails. Log it though — otherwise a
|
|
36
|
+
// transient BFF 5xx is indistinguishable from "unit has no contract".
|
|
37
|
+
logError("Failed to resolve attached contract", {
|
|
38
|
+
orgUnitId,
|
|
39
|
+
error_message: error instanceof Error ? error.message : String(error),
|
|
40
|
+
});
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!contracts.length) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
contracts.find((contract) => contract.id === contractId) ??
|
|
50
|
+
contracts.find((contract) => contract.isDefault) ??
|
|
51
|
+
contracts[0]
|
|
52
|
+
);
|
|
53
|
+
};
|
|
@@ -4,6 +4,8 @@ export type ContractData = {
|
|
|
4
4
|
email: string;
|
|
5
5
|
isActive: boolean;
|
|
6
6
|
isDefault?: boolean;
|
|
7
|
+
/** False when sourced from the legacy unit-contracts fallback (never explicitly attached) — undefined/true means attached. */
|
|
8
|
+
isAttached?: boolean;
|
|
7
9
|
creationDate: string;
|
|
8
10
|
salesRepresentative?: string;
|
|
9
11
|
imageUrl?: string;
|
|
@@ -28,6 +28,7 @@ import { useMediaQuery } from "../../../shared/hooks/useMediaQuery";
|
|
|
28
28
|
import { GlobalLayout } from "../../../shared/layouts";
|
|
29
29
|
import { useLocalization } from "../../../shared/localization/LocalizationContext";
|
|
30
30
|
import {
|
|
31
|
+
contractScopedLink,
|
|
31
32
|
getContractSettingsLinks,
|
|
32
33
|
getFinanceSettingsLinks,
|
|
33
34
|
getOrganizationSettingsLinks,
|
|
@@ -127,10 +128,14 @@ export const OrgUnitsDetailsLayout = ({
|
|
|
127
128
|
submenu: {
|
|
128
129
|
items: (listAccountingFieldData ?? []).map((item) => ({
|
|
129
130
|
name: item.name,
|
|
130
|
-
link:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
link: contractScopedLink(
|
|
132
|
+
(params) =>
|
|
133
|
+
buyerPortalRoutes.accountingFields({
|
|
134
|
+
...params,
|
|
135
|
+
accountingFieldId: item.id,
|
|
136
|
+
}),
|
|
137
|
+
linkParams
|
|
138
|
+
),
|
|
134
139
|
hasNotification: checkAccountingFieldIsEmpty(item),
|
|
135
140
|
actions: (
|
|
136
141
|
<AccountingFieldDropdown
|
|
@@ -261,7 +266,7 @@ export const OrgUnitsDetailsLayout = ({
|
|
|
261
266
|
<BasicCard
|
|
262
267
|
data-fs-bp-contracts-settings-card
|
|
263
268
|
footerMessage={t("orgUnitDetails.links.manageContractSettings")}
|
|
264
|
-
footerLink={buyerPortalRoutes.profileDetails
|
|
269
|
+
footerLink={contractScopedLink(buyerPortalRoutes.profileDetails, {
|
|
265
270
|
orgUnitId: orgUnit.id,
|
|
266
271
|
contractId: singleContract?.id ?? "",
|
|
267
272
|
})}
|
|
@@ -345,9 +350,9 @@ export const OrgUnitsDetailsLayout = ({
|
|
|
345
350
|
footerMessage={t(
|
|
346
351
|
"orgUnitDetails.links.manageFinanceAndComplianceSettings"
|
|
347
352
|
)}
|
|
348
|
-
footerLink={buyerPortalRoutes.buyingPolicies
|
|
353
|
+
footerLink={contractScopedLink(buyerPortalRoutes.buyingPolicies, {
|
|
349
354
|
orgUnitId: orgUnit.id,
|
|
350
|
-
contractId: contracts[0]?.id,
|
|
355
|
+
contractId: contracts[0]?.id ?? "",
|
|
351
356
|
})}
|
|
352
357
|
enableFooter
|
|
353
358
|
>
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { Fragment } from "react";
|
|
2
2
|
|
|
3
|
+
import { OrgUnitBreadcrumb } from "../../../org-units/components";
|
|
4
|
+
import {
|
|
5
|
+
MAX_DESKTOP_BREADCRUMB_ITEMS,
|
|
6
|
+
MAX_MOBILE_BREADCRUMB_ITEMS,
|
|
7
|
+
QUERY_TABLET,
|
|
8
|
+
} from "../../../org-units/layouts/OrgUnitDetailsLayout/utils";
|
|
3
9
|
import { HeaderInside } from "../../../shared/components";
|
|
4
10
|
import { useBuyerPortal } from "../../../shared/hooks";
|
|
11
|
+
import { useMediaQuery } from "../../../shared/hooks/useMediaQuery";
|
|
5
12
|
import { GlobalLayout, ContractTabsLayout } from "../../../shared/layouts";
|
|
6
13
|
import { useLocalization } from "../../../shared/localization/LocalizationContext";
|
|
7
14
|
|
|
@@ -15,7 +22,12 @@ export type ProfileLayoutProps = {
|
|
|
15
22
|
|
|
16
23
|
export const ProfileLayout = ({ data }: ProfileLayoutProps) => {
|
|
17
24
|
const { t } = useLocalization();
|
|
18
|
-
const {
|
|
25
|
+
const {
|
|
26
|
+
currentOrgUnit: orgUnit,
|
|
27
|
+
currentUser,
|
|
28
|
+
featureFlags,
|
|
29
|
+
} = useBuyerPortal();
|
|
30
|
+
const isMobile = useMediaQuery(QUERY_TABLET);
|
|
19
31
|
|
|
20
32
|
const rows = getProfileDetailRows(data);
|
|
21
33
|
|
|
@@ -29,6 +41,23 @@ export const ProfileLayout = ({ data }: ProfileLayoutProps) => {
|
|
|
29
41
|
pageName={t("shared.labels.contract")}
|
|
30
42
|
>
|
|
31
43
|
<section data-fs-bp-profile>
|
|
44
|
+
{/* Under SUMA this page is the unit's landing page, so identify
|
|
45
|
+
which unit the user is in — a child's profile is otherwise
|
|
46
|
+
indistinguishable from the root's when they share contracts. */}
|
|
47
|
+
{featureFlags?.suma && orgUnit && (
|
|
48
|
+
<HeaderInside data-fs-bp-profile-breadcrumb>
|
|
49
|
+
<OrgUnitBreadcrumb
|
|
50
|
+
userId={currentUser?.id ?? ""}
|
|
51
|
+
userRoles={currentUser?.roles ?? []}
|
|
52
|
+
orgUnit={orgUnit}
|
|
53
|
+
maxItems={
|
|
54
|
+
isMobile
|
|
55
|
+
? MAX_MOBILE_BREADCRUMB_ITEMS
|
|
56
|
+
: MAX_DESKTOP_BREADCRUMB_ITEMS
|
|
57
|
+
}
|
|
58
|
+
/>
|
|
59
|
+
</HeaderInside>
|
|
60
|
+
)}
|
|
32
61
|
<HeaderInside title={t("profile.titles.profile")} />
|
|
33
62
|
|
|
34
63
|
{data && (
|
|
@@ -10,6 +10,7 @@ import { Icon, VerticalNav } from "../../components";
|
|
|
10
10
|
import { useBuyerPortal, useDrawerProps } from "../../hooks";
|
|
11
11
|
import { useLocalization } from "../../localization/LocalizationContext";
|
|
12
12
|
import {
|
|
13
|
+
contractScopedLink,
|
|
13
14
|
getContractSettingsLinks,
|
|
14
15
|
getFinanceSettingsLinks,
|
|
15
16
|
getOrganizationSettingsLinks,
|
|
@@ -82,10 +83,14 @@ export const FullSidebarNav = ({
|
|
|
82
83
|
submenu: {
|
|
83
84
|
items: (listAccountingFieldData ?? []).map((item) => ({
|
|
84
85
|
name: item.name,
|
|
85
|
-
link:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
link: contractScopedLink(
|
|
87
|
+
(params) =>
|
|
88
|
+
buyerPortalRoutes.accountingFields({
|
|
89
|
+
...params,
|
|
90
|
+
accountingFieldId: item.id,
|
|
91
|
+
}),
|
|
92
|
+
linkParams
|
|
93
|
+
),
|
|
89
94
|
hasNotification: checkAccountingFieldIsEmpty(item),
|
|
90
95
|
actions: (
|
|
91
96
|
<AccountingFieldDropdown
|
|
@@ -23,12 +23,18 @@ export const SumaSidebar = ({
|
|
|
23
23
|
showBackLink = false,
|
|
24
24
|
loading = false,
|
|
25
25
|
}: SumaSidebarProps) => {
|
|
26
|
-
const { featureFlags, currentContract } = useBuyerPortal();
|
|
26
|
+
const { featureFlags, currentContract, currentOrgUnit } = useBuyerPortal();
|
|
27
27
|
const { t } = useLocalization();
|
|
28
28
|
const contractsUrl = buyerPortalRoutes.contracts({ orgUnitId });
|
|
29
29
|
|
|
30
|
+
// Identify WHERE the user is (the org unit), not just which contract they
|
|
31
|
+
// operate under — a child unit sharing its contracts with the root org is
|
|
32
|
+
// otherwise indistinguishable from it. The contract becomes a secondary
|
|
33
|
+
// line; contract-only identity is kept for pages with no unit context.
|
|
30
34
|
const contractName = currentContract?.name ?? currentContract?.email ?? "";
|
|
31
|
-
const
|
|
35
|
+
const unitName = currentOrgUnit?.name ?? "";
|
|
36
|
+
const identityName = unitName || contractName;
|
|
37
|
+
const showIdentity = variant === "full" && !!identityName;
|
|
32
38
|
|
|
33
39
|
return (
|
|
34
40
|
<aside
|
|
@@ -48,10 +54,10 @@ export const SumaSidebar = ({
|
|
|
48
54
|
|
|
49
55
|
{showIdentity && (
|
|
50
56
|
<div data-fs-bp-suma-sidebar-identity>
|
|
51
|
-
<LetterHighlight size="large" letter={
|
|
57
|
+
<LetterHighlight size="large" letter={identityName} />
|
|
52
58
|
<div data-fs-bp-suma-sidebar-identity-row>
|
|
53
|
-
<span data-fs-bp-suma-sidebar-identity-name>{
|
|
54
|
-
{currentContract?.isDefault && (
|
|
59
|
+
<span data-fs-bp-suma-sidebar-identity-name>{identityName}</span>
|
|
60
|
+
{!unitName && currentContract?.isDefault && (
|
|
55
61
|
<Icon
|
|
56
62
|
name="Default"
|
|
57
63
|
width={20}
|
|
@@ -61,6 +67,22 @@ export const SumaSidebar = ({
|
|
|
61
67
|
/>
|
|
62
68
|
)}
|
|
63
69
|
</div>
|
|
70
|
+
{unitName && contractName && (
|
|
71
|
+
<div data-fs-bp-suma-sidebar-identity-row>
|
|
72
|
+
<span data-fs-bp-suma-sidebar-identity-contract>
|
|
73
|
+
{contractName}
|
|
74
|
+
</span>
|
|
75
|
+
{currentContract?.isDefault && (
|
|
76
|
+
<Icon
|
|
77
|
+
name="Default"
|
|
78
|
+
width={16}
|
|
79
|
+
height={16}
|
|
80
|
+
data-fs-bp-suma-sidebar-identity-default
|
|
81
|
+
aria-label="Default contract"
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
64
86
|
</div>
|
|
65
87
|
)}
|
|
66
88
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { buyerPortalRoutes } from "../buyerPortalRoutes";
|
|
4
|
+
import { contractScopedLink } from "../contractScopedLink";
|
|
5
|
+
|
|
6
|
+
describe("contractScopedLink", () => {
|
|
7
|
+
it("builds the contract-scoped URL when a contractId is known", () => {
|
|
8
|
+
expect(
|
|
9
|
+
contractScopedLink(buyerPortalRoutes.addresses, {
|
|
10
|
+
orgUnitId: "unit-1",
|
|
11
|
+
contractId: "contract-1",
|
|
12
|
+
})
|
|
13
|
+
).toBe("/pvt/organization-account/addresses/unit-1/contract-1");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("falls back to the unit's contracts listing when contractId is empty", () => {
|
|
17
|
+
// An empty contractId would render `/addresses/unit-1/`, which the home
|
|
18
|
+
// catch-all route captures and 302s to the user's root org unit.
|
|
19
|
+
expect(
|
|
20
|
+
contractScopedLink(buyerPortalRoutes.addresses, {
|
|
21
|
+
orgUnitId: "unit-1",
|
|
22
|
+
contractId: "",
|
|
23
|
+
})
|
|
24
|
+
).toBe("/pvt/organization-account/contracts/unit-1");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("supports builders with extra params via a wrapping arrow", () => {
|
|
28
|
+
expect(
|
|
29
|
+
contractScopedLink(
|
|
30
|
+
(params) =>
|
|
31
|
+
buyerPortalRoutes.accountingFields({
|
|
32
|
+
...params,
|
|
33
|
+
accountingFieldId: "field-1",
|
|
34
|
+
}),
|
|
35
|
+
{ orgUnitId: "unit-1", contractId: "" }
|
|
36
|
+
)
|
|
37
|
+
).toBe("/pvt/organization-account/contracts/unit-1");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -11,13 +11,22 @@ const orgUnit = {
|
|
|
11
11
|
} as OrgUnitBasicData;
|
|
12
12
|
|
|
13
13
|
describe("resolveContractId", () => {
|
|
14
|
-
it("prefers the URL's contractId
|
|
14
|
+
it("prefers the loader-validated currentContract over the URL's contractId", () => {
|
|
15
15
|
expect(
|
|
16
16
|
resolveContractId({
|
|
17
17
|
queryContractId: "contract-c",
|
|
18
18
|
currentContract: contract,
|
|
19
19
|
currentOrgUnit: orgUnit,
|
|
20
20
|
})
|
|
21
|
+
).toBe("contract-b");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("uses the URL's contractId when no currentContract is resolved", () => {
|
|
25
|
+
expect(
|
|
26
|
+
resolveContractId({
|
|
27
|
+
queryContractId: "contract-c",
|
|
28
|
+
currentOrgUnit: orgUnit,
|
|
29
|
+
})
|
|
21
30
|
).toBe("contract-c");
|
|
22
31
|
});
|
|
23
32
|
|
|
@@ -25,7 +34,6 @@ describe("resolveContractId", () => {
|
|
|
25
34
|
expect(
|
|
26
35
|
resolveContractId({
|
|
27
36
|
queryContractId: ["contract-c", "contract-d"],
|
|
28
|
-
currentContract: contract,
|
|
29
37
|
currentOrgUnit: orgUnit,
|
|
30
38
|
})
|
|
31
39
|
).toBe("contract-c");
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { buyerPortalRoutes } from "./buyerPortalRoutes";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds a contract-scoped URL, falling back to the unit's contracts listing
|
|
5
|
+
* when no contractId is known. An empty contractId would otherwise produce a
|
|
6
|
+
* URL with an empty segment, which the home catch-all route
|
|
7
|
+
* (`/pvt/organization-account/[[...contractMode]]`) captures and 302s to the
|
|
8
|
+
* user's root org unit — bouncing them out of the unit they were browsing.
|
|
9
|
+
*/
|
|
10
|
+
export const contractScopedLink = (
|
|
11
|
+
buildLink: (params: { orgUnitId: string; contractId: string }) => string,
|
|
12
|
+
{ orgUnitId, contractId }: { orgUnitId: string; contractId: string }
|
|
13
|
+
) =>
|
|
14
|
+
contractId
|
|
15
|
+
? buildLink({ orgUnitId, contractId })
|
|
16
|
+
: buyerPortalRoutes.contracts({ orgUnitId });
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { buyerPortalRoutes } from "./buyerPortalRoutes";
|
|
2
|
+
import { contractScopedLink } from "./contractScopedLink";
|
|
2
3
|
|
|
3
4
|
import type { LocalizeFn } from "../localization/types";
|
|
4
5
|
|
|
5
6
|
export const getContractSettingsLinks = (
|
|
6
|
-
{
|
|
7
|
-
orgUnitId,
|
|
8
|
-
contractId,
|
|
9
|
-
}: {
|
|
7
|
+
linkParams: {
|
|
10
8
|
orgUnitId: string;
|
|
11
9
|
contractId: string;
|
|
12
10
|
},
|
|
@@ -14,22 +12,22 @@ export const getContractSettingsLinks = (
|
|
|
14
12
|
) => [
|
|
15
13
|
{
|
|
16
14
|
name: t("layouts.navigation.profile"),
|
|
17
|
-
link: buyerPortalRoutes.profileDetails
|
|
15
|
+
link: contractScopedLink(buyerPortalRoutes.profileDetails, linkParams),
|
|
18
16
|
},
|
|
19
17
|
{
|
|
20
18
|
name: t("layouts.navigation.addresses"),
|
|
21
|
-
link: buyerPortalRoutes.addresses
|
|
19
|
+
link: contractScopedLink(buyerPortalRoutes.addresses, linkParams),
|
|
22
20
|
},
|
|
23
21
|
{
|
|
24
22
|
name: t("layouts.navigation.paymentMethods"),
|
|
25
|
-
link: buyerPortalRoutes.paymentMethods
|
|
23
|
+
link: contractScopedLink(buyerPortalRoutes.paymentMethods, linkParams),
|
|
26
24
|
},
|
|
27
25
|
{
|
|
28
26
|
name: t("layouts.navigation.creditCards"),
|
|
29
|
-
link: buyerPortalRoutes.creditCards
|
|
27
|
+
link: contractScopedLink(buyerPortalRoutes.creditCards, linkParams),
|
|
30
28
|
},
|
|
31
29
|
{
|
|
32
30
|
name: t("layouts.navigation.assortments"),
|
|
33
|
-
link: buyerPortalRoutes.productAssortment
|
|
31
|
+
link: contractScopedLink(buyerPortalRoutes.productAssortment, linkParams),
|
|
34
32
|
},
|
|
35
33
|
];
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { buyerPortalRoutes } from "./buyerPortalRoutes";
|
|
2
|
+
import { contractScopedLink } from "./contractScopedLink";
|
|
2
3
|
|
|
3
4
|
import type { LocalizeFn } from "../localization/types";
|
|
4
5
|
|
|
5
6
|
export const getFinanceSettingsLinks = (
|
|
6
|
-
{
|
|
7
|
-
orgUnitId,
|
|
8
|
-
contractId,
|
|
9
|
-
}: {
|
|
7
|
+
linkParams: {
|
|
10
8
|
orgUnitId: string;
|
|
11
9
|
contractId: string;
|
|
12
10
|
},
|
|
@@ -14,10 +12,10 @@ export const getFinanceSettingsLinks = (
|
|
|
14
12
|
) => [
|
|
15
13
|
{
|
|
16
14
|
name: t("layouts.navigation.budgets"),
|
|
17
|
-
link: buyerPortalRoutes.budgets
|
|
15
|
+
link: contractScopedLink(buyerPortalRoutes.budgets, linkParams),
|
|
18
16
|
},
|
|
19
17
|
{
|
|
20
18
|
name: t("layouts.navigation.buyingPolicies"),
|
|
21
|
-
link: buyerPortalRoutes.buyingPolicies
|
|
19
|
+
link: contractScopedLink(buyerPortalRoutes.buyingPolicies, linkParams),
|
|
22
20
|
},
|
|
23
21
|
];
|
|
@@ -21,6 +21,7 @@ export {
|
|
|
21
21
|
export { maskCardNumber, maskCVV, maskExpirationDate } from "./creditCard";
|
|
22
22
|
export { formatCurrency } from "./formatCurrency";
|
|
23
23
|
export { getClientContext, type ClientContext } from "./getClientContext";
|
|
24
|
+
export { contractScopedLink } from "./contractScopedLink";
|
|
24
25
|
export { getContractSettingsLinks } from "./getContractSettingsLinks";
|
|
25
26
|
export { getFinanceSettingsLinks } from "./getFinanceSettingsLinks";
|
|
26
27
|
export {
|
|
@@ -9,8 +9,11 @@ export type ResolveContractIdParams = {
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Single source of truth for which contract the sidebar should link to.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* `currentContract` wins when the loader resolved one — loaders validate it
|
|
13
|
+
* against the unit's attached contracts (honoring the URL's `contractId`
|
|
14
|
+
* when attached), so it is more trustworthy than the raw URL param, which
|
|
15
|
+
* may carry a contract from another org unit. `customerIds[0]` is a last
|
|
16
|
+
* resort for flows whose loaders don't resolve a contract.
|
|
14
17
|
*/
|
|
15
18
|
export const resolveContractId = ({
|
|
16
19
|
queryContractId,
|
|
@@ -22,8 +25,8 @@ export const resolveContractId = ({
|
|
|
22
25
|
: queryContractId;
|
|
23
26
|
|
|
24
27
|
return (
|
|
25
|
-
fromQuery ||
|
|
26
28
|
currentContract?.id ||
|
|
29
|
+
fromQuery ||
|
|
27
30
|
currentOrgUnit?.customerGroup.customerIds[0] ||
|
|
28
31
|
""
|
|
29
32
|
);
|
package/src/pages/contracts.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ContractListingLayout } from "../features/contracts/layouts/ContractListingLayout/ContractListingLayout";
|
|
2
|
-
import {
|
|
2
|
+
import { listUnitContractsService } from "../features/contracts/services";
|
|
3
3
|
import { getOrgUnitBasicDataService } from "../features/org-units/services";
|
|
4
4
|
import { withErrorBoundary } from "../features/shared/components";
|
|
5
5
|
import { ErrorTabsLayout } from "../features/shared/layouts/ErrorTabsLayout/ErrorTabsLayout";
|
|
@@ -65,7 +65,7 @@ const loaderFunction = async (
|
|
|
65
65
|
|
|
66
66
|
const [orgUnit, contracts, user, localizationLabels] = await Promise.all([
|
|
67
67
|
getOrgUnitBasicDataService({ id: orgUnitId, cookie }),
|
|
68
|
-
|
|
68
|
+
listUnitContractsService({ orgUnitId, cookie }),
|
|
69
69
|
getUserByIdService({ orgUnitId, userId, cookie }),
|
|
70
70
|
getLocalizationLabelsService({
|
|
71
71
|
entryIds: [
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getContractsByOrgUnitIdService,
|
|
3
|
+
resolveAttachedContractService,
|
|
4
|
+
} from "../features/contracts/services";
|
|
2
5
|
import { OrgUnitsDetailsLayout } from "../features/org-units/layouts";
|
|
3
6
|
import {
|
|
4
7
|
getOrgUnitBasicDataService,
|
|
@@ -71,7 +74,24 @@ const loaderFunction = async (
|
|
|
71
74
|
const featureFlags = await getBuyerPortalFeatureFlags(cookie);
|
|
72
75
|
|
|
73
76
|
if (featureFlags?.suma) {
|
|
74
|
-
|
|
77
|
+
// Under SUMA the unit's landing page is its Contract Information
|
|
78
|
+
// (profile) — the SUMA counterpart of this details page, with the full
|
|
79
|
+
// sidebar (Addresses, Payment methods, …). Resolve which contract that
|
|
80
|
+
// is for THIS unit instead of trusting the URL's contractId, which may
|
|
81
|
+
// carry the unit the user navigated from. Units with no resolvable
|
|
82
|
+
// contract land on their contracts listing, where admins can attach one.
|
|
83
|
+
const contract = await resolveAttachedContractService({
|
|
84
|
+
orgUnitId,
|
|
85
|
+
contractId,
|
|
86
|
+
cookie,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const location = contract
|
|
90
|
+
? buyerPortalRoutes.profileDetails({
|
|
91
|
+
orgUnitId,
|
|
92
|
+
contractId: contract.id,
|
|
93
|
+
})
|
|
94
|
+
: buyerPortalRoutes.contracts({ orgUnitId });
|
|
75
95
|
|
|
76
96
|
data.res?.writeHead(302, { Location: location });
|
|
77
97
|
data.res?.end();
|
package/src/pages/org-units.tsx
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getContractDetailsService,
|
|
3
|
+
resolveAttachedContractService,
|
|
4
|
+
} from "../features/contracts/services";
|
|
2
5
|
import { OrgUnitsLayout } from "../features/org-units/layouts";
|
|
3
6
|
import {
|
|
4
7
|
getChildrenOrgUnitsService,
|
|
@@ -55,30 +58,40 @@ const loaderFunction = async (
|
|
|
55
58
|
|
|
56
59
|
return withAuthLoader(
|
|
57
60
|
data,
|
|
58
|
-
async ({
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
getLocalizationLabelsService({
|
|
66
|
-
entryIds: LOCALIZATION_ENTRY_IDS.orgUnits,
|
|
67
|
-
locale: data.locale,
|
|
68
|
-
}),
|
|
69
|
-
]);
|
|
70
|
-
|
|
61
|
+
async ({
|
|
62
|
+
cookie,
|
|
63
|
+
customerId,
|
|
64
|
+
userId,
|
|
65
|
+
featureFlagsPromise,
|
|
66
|
+
...restClientContext
|
|
67
|
+
}) => {
|
|
71
68
|
// Organizational Units don't need contract data, but the sidebar
|
|
72
|
-
// (rendered by shared layouts)
|
|
73
|
-
// block
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
69
|
+
// (rendered by shared layouts) builds contract-scoped links and its
|
|
70
|
+
// identity block from `currentContract`. Under SUMA, resolve it
|
|
71
|
+
// against the unit's attached contracts (URL contractId when
|
|
72
|
+
// attached, else the unit's default) so those links never carry a
|
|
73
|
+
// foreign or empty id. With the flag off, keep the pre-SUMA behavior
|
|
74
|
+
// byte-for-byte: fetch only when the URL already carries a contractId.
|
|
75
|
+
const featureFlags = await featureFlagsPromise;
|
|
76
|
+
const contractDetailsPromise = featureFlags?.suma
|
|
77
|
+
? resolveAttachedContractService({ orgUnitId, contractId, cookie })
|
|
78
|
+
: contractId
|
|
79
|
+
? getContractDetailsService({ contractId, unitId: orgUnitId, cookie })
|
|
80
|
+
: Promise.resolve(null);
|
|
81
|
+
|
|
82
|
+
const [currentOrgUnit, user, localizationLabels, currentContract] =
|
|
83
|
+
await Promise.all([
|
|
84
|
+
getOrgUnitBasicDataService({
|
|
85
|
+
id: data.query.orgUnitId,
|
|
79
86
|
cookie,
|
|
80
|
-
})
|
|
81
|
-
|
|
87
|
+
}),
|
|
88
|
+
getUserByIdService({ orgUnitId, userId, cookie }),
|
|
89
|
+
getLocalizationLabelsService({
|
|
90
|
+
entryIds: LOCALIZATION_ENTRY_IDS.orgUnits,
|
|
91
|
+
locale: data.locale,
|
|
92
|
+
}),
|
|
93
|
+
contractDetailsPromise,
|
|
94
|
+
]);
|
|
82
95
|
|
|
83
96
|
const clientContext = {
|
|
84
97
|
cookie,
|
package/src/pages/roles.tsx
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getContractDetailsService,
|
|
3
|
+
resolveAttachedContractService,
|
|
4
|
+
} from "../features/contracts/services";
|
|
2
5
|
import { getOrgUnitBasicDataService } from "../features/org-units/services";
|
|
3
6
|
import { RolesLayout } from "../features/roles/layout";
|
|
4
7
|
import { getRolesAndPermissionsService } from "../features/roles/services";
|
|
@@ -46,51 +49,65 @@ const loaderFunction = async (
|
|
|
46
49
|
): Promise<AuthRouteProps<RolesPageData>> => {
|
|
47
50
|
const { orgUnitId, contractId } = data.query;
|
|
48
51
|
|
|
49
|
-
return withAuthLoader(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
cookie
|
|
68
|
-
|
|
69
|
-
getOrgUnitBasicDataService({
|
|
70
|
-
id: orgUnitId,
|
|
71
|
-
cookie,
|
|
72
|
-
}),
|
|
73
|
-
getUserByIdService({ orgUnitId, userId, cookie }),
|
|
74
|
-
contractDetailsPromise,
|
|
75
|
-
getLocalizationLabelsService({
|
|
76
|
-
entryIds: LOCALIZATION_ENTRY_IDS.roles,
|
|
77
|
-
locale: data.locale,
|
|
78
|
-
}),
|
|
79
|
-
]);
|
|
52
|
+
return withAuthLoader(
|
|
53
|
+
data,
|
|
54
|
+
async ({ cookie, userId, featureFlagsPromise, ...clientContext }) => {
|
|
55
|
+
// Roles don't need contract data, but the sidebar (rendered by shared
|
|
56
|
+
// layouts) builds contract-scoped links and its identity block from
|
|
57
|
+
// `currentContract`. Under SUMA, resolve it against the unit's
|
|
58
|
+
// attached contracts (URL contractId when attached, else the unit's
|
|
59
|
+
// default) so those links never carry a foreign or empty id. With the
|
|
60
|
+
// flag off, keep the pre-SUMA behavior byte-for-byte: fetch only when
|
|
61
|
+
// the URL already carries a contractId.
|
|
62
|
+
const featureFlags = await featureFlagsPromise;
|
|
63
|
+
const contractDetailsPromise = featureFlags?.suma
|
|
64
|
+
? resolveAttachedContractService({
|
|
65
|
+
orgUnitId,
|
|
66
|
+
contractId,
|
|
67
|
+
cookie,
|
|
68
|
+
})
|
|
69
|
+
: contractId
|
|
70
|
+
? getContractDetailsService({ contractId, unitId: orgUnitId, cookie })
|
|
71
|
+
: Promise.resolve(null);
|
|
80
72
|
|
|
81
|
-
|
|
82
|
-
data: {
|
|
73
|
+
const [
|
|
83
74
|
rolesAndPermissions,
|
|
84
|
-
},
|
|
85
|
-
context: {
|
|
86
|
-
clientContext: { cookie, userId, ...clientContext },
|
|
87
75
|
currentOrgUnit,
|
|
76
|
+
user,
|
|
88
77
|
currentContract,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
78
|
+
localizationLabels,
|
|
79
|
+
] = await Promise.all([
|
|
80
|
+
getRolesAndPermissionsService({
|
|
81
|
+
unitId: orgUnitId,
|
|
82
|
+
customerId: clientContext.customerId,
|
|
83
|
+
cookie,
|
|
84
|
+
}),
|
|
85
|
+
getOrgUnitBasicDataService({
|
|
86
|
+
id: orgUnitId,
|
|
87
|
+
cookie,
|
|
88
|
+
}),
|
|
89
|
+
getUserByIdService({ orgUnitId, userId, cookie }),
|
|
90
|
+
contractDetailsPromise,
|
|
91
|
+
getLocalizationLabelsService({
|
|
92
|
+
entryIds: LOCALIZATION_ENTRY_IDS.roles,
|
|
93
|
+
locale: data.locale,
|
|
94
|
+
}),
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
data: {
|
|
99
|
+
rolesAndPermissions,
|
|
100
|
+
},
|
|
101
|
+
context: {
|
|
102
|
+
clientContext: { cookie, userId, ...clientContext },
|
|
103
|
+
currentOrgUnit,
|
|
104
|
+
currentContract,
|
|
105
|
+
currentUser: user,
|
|
106
|
+
localization: { labels: localizationLabels },
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
);
|
|
94
111
|
};
|
|
95
112
|
|
|
96
113
|
export const loader = withLoaderErrorBoundary(loaderFunction, {
|
package/src/pages/users.tsx
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getContractDetailsService,
|
|
3
|
+
resolveAttachedContractService,
|
|
4
|
+
} from "../features/contracts/services";
|
|
2
5
|
import { getOrgUnitBasicDataService } from "../features/org-units/services";
|
|
3
6
|
import { getRolesIdsService } from "../features/roles/services";
|
|
4
7
|
import { RoleData } from "../features/roles/types";
|
|
@@ -63,7 +66,13 @@ const loaderFunction = async (
|
|
|
63
66
|
|
|
64
67
|
return withAuthLoader(
|
|
65
68
|
data,
|
|
66
|
-
async ({
|
|
69
|
+
async ({
|
|
70
|
+
cookie,
|
|
71
|
+
userId,
|
|
72
|
+
customerId,
|
|
73
|
+
featureFlagsPromise,
|
|
74
|
+
...clientContext
|
|
75
|
+
}) => {
|
|
67
76
|
const validPage = getValidPage(page);
|
|
68
77
|
|
|
69
78
|
const localizationLabels = await getLocalizationLabelsService({
|
|
@@ -92,9 +101,20 @@ const loaderFunction = async (
|
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
// Users don't need contract data, but the sidebar (rendered by shared
|
|
95
|
-
// layouts)
|
|
96
|
-
//
|
|
97
|
-
|
|
104
|
+
// layouts) builds contract-scoped links and its identity block from
|
|
105
|
+
// `currentContract`. Under SUMA, resolve it against the unit's
|
|
106
|
+
// attached contracts (URL contractId when attached, else the unit's
|
|
107
|
+
// default) so those links never carry a foreign or empty id. With the
|
|
108
|
+
// flag off, keep the pre-SUMA behavior byte-for-byte: fetch only when
|
|
109
|
+
// the URL already carries a contractId.
|
|
110
|
+
const featureFlags = await featureFlagsPromise;
|
|
111
|
+
const contractDetailsPromise = featureFlags?.suma
|
|
112
|
+
? resolveAttachedContractService({
|
|
113
|
+
orgUnitId,
|
|
114
|
+
contractId,
|
|
115
|
+
cookie,
|
|
116
|
+
})
|
|
117
|
+
: contractId
|
|
98
118
|
? getContractDetailsService({
|
|
99
119
|
contractId,
|
|
100
120
|
unitId: orgUnitId,
|