@vtex/faststore-plugin-buyer-portal 2.0.25 → 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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/features/contracts/components/ContractListItem/ContractListItem.tsx +10 -3
  3. package/src/features/contracts/layouts/ContractListingLayout/ContractListingLayout.tsx +18 -5
  4. package/src/features/contracts/layouts/ContractListingLayout/ContractSettingsNav.tsx +14 -9
  5. package/src/features/contracts/layouts/ContractListingLayout/contract-listing-layout.scss +13 -0
  6. package/src/features/contracts/services/__tests__/list-unit-contracts.service.test.ts +117 -0
  7. package/src/features/contracts/services/__tests__/resolve-attached-contract.service.test.ts +118 -0
  8. package/src/features/contracts/services/index.ts +10 -0
  9. package/src/features/contracts/services/list-unit-contracts.service.ts +44 -0
  10. package/src/features/contracts/services/resolve-attached-contract.service.ts +53 -0
  11. package/src/features/contracts/types/ContractData.ts +2 -0
  12. package/src/features/org-units/components/CreateOrgUnitDrawer/CreateOrgUnitDrawer.tsx +2 -1
  13. package/src/features/org-units/layouts/OrgUnitDetailsLayout/OrgUnitDetailsLayout.tsx +12 -7
  14. package/src/features/profile/layouts/ProfileLayout/ProfileLayout.tsx +30 -1
  15. package/src/features/shared/hooks/analytics/__tests__/buildErrorEventMetadata.test.ts +109 -0
  16. package/src/features/shared/hooks/analytics/__tests__/classifyError.test.ts +148 -0
  17. package/src/features/shared/hooks/analytics/buildErrorEventMetadata.ts +60 -0
  18. package/src/features/shared/hooks/analytics/classifyError.ts +120 -0
  19. package/src/features/shared/hooks/analytics/types.ts +32 -0
  20. package/src/features/shared/hooks/analytics/useAnalytics.ts +12 -14
  21. package/src/features/shared/layouts/SumaPageLayout/FullSidebarNav.tsx +9 -4
  22. package/src/features/shared/layouts/SumaPageLayout/SumaSidebar.tsx +27 -5
  23. package/src/features/shared/services/logger/analytics/types.ts +11 -4
  24. package/src/features/shared/utils/__tests__/contractScopedLink.test.ts +39 -0
  25. package/src/features/shared/utils/__tests__/resolveContractId.test.ts +10 -2
  26. package/src/features/shared/utils/constants.ts +1 -1
  27. package/src/features/shared/utils/contractScopedLink.ts +16 -0
  28. package/src/features/shared/utils/getContractSettingsLinks.ts +7 -9
  29. package/src/features/shared/utils/getFinanceSettingsLinks.ts +4 -6
  30. package/src/features/shared/utils/index.ts +1 -0
  31. package/src/features/shared/utils/resolveContractId.ts +6 -3
  32. package/src/features/users/components/CreateUserDrawer/CreateUserDrawer.tsx +2 -2
  33. package/src/features/users/components/CreateUserDrawerWithUsername/CreateUserDrawerWithUsername.tsx +4 -1
  34. package/src/pages/contracts.tsx +2 -2
  35. package/src/pages/org-unit-details.tsx +22 -2
  36. package/src/pages/org-units.tsx +36 -23
  37. package/src/pages/roles.tsx +59 -42
  38. package/src/pages/users.tsx +25 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vtex/faststore-plugin-buyer-portal",
3
- "version": "2.0.25",
3
+ "version": "2.0.27",
4
4
  "description": "A plugin for faststore with buyer portal",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -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
- onClick={onRemove}
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 === contracts.length) {
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, filteredIds);
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(filteredIds);
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(filteredIds);
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(linkParams),
39
+ link: contractScopedLink(buyerPortalRoutes.profileDetails, linkParams),
39
40
  },
40
41
  {
41
42
  name: t("layouts.navigation.addresses"),
42
- link: buyerPortalRoutes.addresses(linkParams),
43
+ link: contractScopedLink(buyerPortalRoutes.addresses, linkParams),
43
44
  },
44
45
  {
45
46
  name: t("layouts.navigation.paymentMethods"),
46
- link: buyerPortalRoutes.paymentMethods(linkParams),
47
+ link: contractScopedLink(buyerPortalRoutes.paymentMethods, linkParams),
47
48
  },
48
49
  {
49
50
  name: t("layouts.navigation.creditCards"),
50
- link: buyerPortalRoutes.creditCards(linkParams),
51
+ link: contractScopedLink(buyerPortalRoutes.creditCards, linkParams),
51
52
  },
52
53
  {
53
54
  name: t("contracts.nav.assortment"),
54
- link: buyerPortalRoutes.productAssortment(linkParams),
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: buyerPortalRoutes.accountingFields({
63
- ...linkParams,
64
- accountingFieldId: field.id,
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;
@@ -104,7 +104,8 @@ export const CreateOrgUnitDrawer = ({
104
104
 
105
105
  trackEntityCreateError(ANALYTICS_EVENTS.ORG_UNIT_CREATE_ERROR, err, {
106
106
  parent_org_unit_id: parentOrgUnit?.id,
107
- error_type: error.code || "unknown",
107
+ errorType:
108
+ error.code === "InvalidOrganizationUnitName" ? "conflict" : undefined,
108
109
  });
109
110
 
110
111
  if (error.code === "InvalidOrganizationUnitName") {
@@ -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: buyerPortalRoutes.accountingFields({
131
- ...linkParams,
132
- accountingFieldId: item.id,
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
  >