diff --git a/frontends/api/src/mitxonline/hooks/organizations/index.ts b/frontends/api/src/mitxonline/hooks/organizations/index.ts index e042776f5e..4fd740d9d3 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/index.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/index.ts @@ -1,3 +1,20 @@ -import { organizationQueries, useB2BAttachMutation } from "./queries" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { b2bApi } from "../../clients" +import { B2bApiB2bAttachCreateRequest } from "@mitodl/mitxonline-api-axios/v2" +import { organizationQueries, managerOrganizationQueries } from "./queries" -export { organizationQueries, useB2BAttachMutation } +const useB2BAttachMutation = (opts: B2bApiB2bAttachCreateRequest) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async () => { + const response = await b2bApi.b2bAttachCreate(opts) + return response + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["mitxonline"] }) + }, + }) +} + +export { organizationQueries, managerOrganizationQueries, useB2BAttachMutation } +export type { ContractCode } from "./queries" diff --git a/frontends/api/src/mitxonline/hooks/organizations/queries.ts b/frontends/api/src/mitxonline/hooks/organizations/queries.ts index 01862ed6c2..055aa011f4 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/queries.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/queries.ts @@ -1,15 +1,21 @@ -import { - queryOptions, - useMutation, - useQueryClient, -} from "@tanstack/react-query" +import { queryOptions } from "@tanstack/react-query" import { b2bApi } from "../../clients" import { OrganizationPage, - B2bApiB2bAttachCreateRequest, + ManagerContractDetail, B2bApiB2bOrganizationsRetrieveRequest, + B2bApiB2bManagerOrganizationsContractsRetrieveRequest, + B2bApiB2bManagerOrganizationsContractsCodesListRequest, } from "@mitodl/mitxonline-api-axios/v2" +type ContractCode = { + id: number + code: string + is_redeemed: boolean + redeemed_by: string | null + redeemed_on: string | null +} + const organizationKeys = { root: ["mitxonline", "organizations"], organizationsRetrieve: (opts?: B2bApiB2bOrganizationsRetrieveRequest) => [ @@ -29,20 +35,66 @@ const organizationQueries = { }), } -const useB2BAttachMutation = (opts: B2bApiB2bAttachCreateRequest) => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async () => { - const response = await b2bApi.b2bAttachCreate(opts) - // 200 (already attached) indicates user already attached to all contracts - // 201 (successfully attached) is success - // 404 (invalid or expired code) will be thrown as error by axios - return response - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["mitxonline"] }) - }, - }) +const managerOrganizationKeys = { + list: () => ["mitxonline", "manager", "organizations", "list"] as const, + contractDetail: ( + opts: B2bApiB2bManagerOrganizationsContractsRetrieveRequest, + ) => + [ + "mitxonline", + "manager", + "organizations", + "contracts", + "detail", + opts, + ] as const, + contractCodes: ( + opts: B2bApiB2bManagerOrganizationsContractsCodesListRequest, + ) => + [ + "mitxonline", + "manager", + "organizations", + "contracts", + "codes", + opts, + ] as const, +} + +const managerOrganizationQueries = { + managerOrganizationsList: () => + queryOptions({ + queryKey: managerOrganizationKeys.list(), + queryFn: async (): Promise => + b2bApi.b2bManagerOrganizationsList().then((res) => res.data), + }), + managerContractDetail: ( + opts: B2bApiB2bManagerOrganizationsContractsRetrieveRequest, + ) => + queryOptions({ + queryKey: managerOrganizationKeys.contractDetail(opts), + queryFn: async (): Promise => + b2bApi + .b2bManagerOrganizationsContractsRetrieve(opts) + .then((res) => res.data), + }), + managerContractCodes: ( + opts: B2bApiB2bManagerOrganizationsContractsCodesListRequest, + ) => + queryOptions({ + queryKey: managerOrganizationKeys.contractCodes(opts), + queryFn: async (): Promise => + b2bApi + .b2bManagerOrganizationsContractsCodesList(opts) + .then((res) => res.data), + }), +} + +export { + organizationQueries, + organizationKeys, + managerOrganizationQueries, + managerOrganizationKeys, } -export { organizationQueries, organizationKeys, useB2BAttachMutation } +export type { ContractCode } diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx new file mode 100644 index 0000000000..225a316878 --- /dev/null +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx @@ -0,0 +1,167 @@ +import React from "react" +import { renderWithProviders, screen } from "@/test-utils" +import { setMockResponse } from "api/test-utils" +import { factories } from "api/mitxonline-test-utils" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { allowConsoleErrors } from "ol-test-utilities" +import { ForbiddenError } from "@/common/errors" +import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" +import ContractAdminPage from "./ContractAdminPage" + +jest.mock("posthog-js/react", () => ({ + ...jest.requireActual("posthog-js/react"), + useFeatureFlagEnabled: jest.fn(), +})) +jest.mock("@/common/useFeatureFlagsLoaded") +const mockedUseFeatureFlagsLoaded = jest.mocked(useFeatureFlagsLoaded) +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) + +const API_BASE_URL = process.env.NEXT_PUBLIC_MITX_ONLINE_BASE_URL +const managerOrgsUrl = `${API_BASE_URL}/api/v0/b2b/manager/organizations/` +const managerContractDetailUrl = (orgId: number, contractId: number) => + `${API_BASE_URL}/api/v0/b2b/manager/organizations/${orgId}/contracts/${contractId}/` + +const makeOrgWithContract = () => { + const contract = factories.contracts.contract() + const org = factories.organizations.organization({ contracts: [contract] }) + return { org, contract } +} + +describe("ContractAdminPage", () => { + beforeEach(() => { + mockedUseFeatureFlagsLoaded.mockReturnValue(false) + mockedUseFeatureFlagEnabled.mockReturnValue(undefined) + }) + + test("throws ForbiddenError when feature flag is disabled", () => { + mockedUseFeatureFlagsLoaded.mockReturnValue(true) + mockedUseFeatureFlagEnabled.mockReturnValue(false) + allowConsoleErrors() + + expect(() => + renderWithProviders( + , + ), + ).toThrow(ForbiddenError) + }) + + test("throws ForbiddenError when flags are loaded but flag is absent", () => { + mockedUseFeatureFlagsLoaded.mockReturnValue(true) + mockedUseFeatureFlagEnabled.mockReturnValue(undefined) + allowConsoleErrors() + + expect(() => + renderWithProviders( + , + ), + ).toThrow(ForbiddenError) + }) + + test("renders a skeleton while PostHog flags are still loading", () => { + mockedUseFeatureFlagsLoaded.mockReturnValue(false) + mockedUseFeatureFlagEnabled.mockReturnValue(undefined) + + renderWithProviders( + , + ) + + // Skeleton renders as a non-null child; not a 403 error page + expect( + screen.queryByRole("heading", { name: /forbidden/i }), + ).not.toBeInTheDocument() + }) + + test("shows 'Something went wrong' when the manager orgs API call fails", async () => { + mockedUseFeatureFlagsLoaded.mockReturnValue(true) + mockedUseFeatureFlagEnabled.mockReturnValue(true) + allowConsoleErrors() + + setMockResponse.get(managerOrgsUrl, "Internal Server Error", { code: 500 }) + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: "Something went wrong" }) + }) + + test("shows 'Organization not found' when user is not a manager for the requested org", async () => { + mockedUseFeatureFlagsLoaded.mockReturnValue(true) + mockedUseFeatureFlagEnabled.mockReturnValue(true) + + const otherOrg = factories.organizations.organization({}) + setMockResponse.get(managerOrgsUrl, [otherOrg]) + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: "Organization not found" }) + }) + + test("shows 'Contract not found' when org is found but contract slug does not match", async () => { + mockedUseFeatureFlagsLoaded.mockReturnValue(true) + mockedUseFeatureFlagEnabled.mockReturnValue(true) + + const { org } = makeOrgWithContract() + setMockResponse.get(managerOrgsUrl, [org]) + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: "Contract not found" }) + }) + + test("renders org name and contract name when flag is on and user is a manager", async () => { + mockedUseFeatureFlagsLoaded.mockReturnValue(true) + mockedUseFeatureFlagEnabled.mockReturnValue(true) + + const { org, contract } = makeOrgWithContract() + setMockResponse.get(managerOrgsUrl, [org]) + setMockResponse.get(managerContractDetailUrl(org.id, contract.id), { + ...contract, + attachment_percentage: null, + total_enrollments: 0, + total_codes: 50, + }) + setMockResponse.get( + `${API_BASE_URL}/api/v0/b2b/manager/organizations/${org.id}/contracts/${contract.id}/codes/`, + [], + ) + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: org.name }) + expect(screen.getByText(contract.name)).toBeInTheDocument() + }) + + test("renders seat count from contract detail", async () => { + mockedUseFeatureFlagsLoaded.mockReturnValue(true) + mockedUseFeatureFlagEnabled.mockReturnValue(true) + + const { org, contract } = makeOrgWithContract() + setMockResponse.get(managerOrgsUrl, [org]) + setMockResponse.get(managerContractDetailUrl(org.id, contract.id), { + ...contract, + attachment_percentage: null, + total_enrollments: 12, + total_codes: 75, + }) + setMockResponse.get( + `${API_BASE_URL}/api/v0/b2b/manager/organizations/${org.id}/contracts/${contract.id}/codes/`, + [], + ) + + renderWithProviders( + , + ) + + await screen.findByText("75 seats") + }) +}) diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx new file mode 100644 index 0000000000..0ebca3e254 --- /dev/null +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx @@ -0,0 +1,722 @@ +"use client" + +import React, { useState } from "react" +import Image from "next/image" +import { useQuery } from "@tanstack/react-query" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { RiMoreLine } from "@remixicon/react" +import { + Chip, + Container, + Pagination, + SearchInput, + Skeleton, + Stack, + TabContext, + Typography, + styled, +} from "ol-components" +import { + Button, + TabButton, + TabButtonList, + VisuallyHidden, +} from "@mitodl/smoot-design" +import { managerOrganizationQueries } from "api/mitxonline-hooks/organizations" +import type { ContractCode } from "api/mitxonline-hooks/organizations" +import { matchOrganizationBySlug } from "@/common/utils" +import { ForbiddenError } from "@/common/errors" +import { FeatureFlags } from "@/common/feature_flags" +import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" +import { ErrorContent } from "../ErrorPage/ErrorPageTemplate" +import graduateLogo from "@/public/images/dashboard/graduate.png" + +const PAGE_SIZE = 20 + +const Page = styled(Container)(({ theme }) => ({ + maxWidth: "1400px", + padding: "40px 24px", + [theme.breakpoints.down("md")]: { + padding: "24px 16px", + }, +})) + +const HeaderSection = styled.div(({ theme }) => ({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "24px", + [theme.breakpoints.down("md")]: { + flexDirection: "column", + alignItems: "flex-start", + }, +})) + +const OrgDetailsContainer = styled.div({ + display: "flex", + alignItems: "center", + gap: "24px", +}) + +const ImageContainer = styled.div(({ theme }) => ({ + display: "flex", + width: "60px", + height: "60px", + padding: "8px", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + borderRadius: "8px", + backgroundColor: theme.custom.colors.white, + border: `1px solid ${theme.custom.colors.lightGray2}`, + overflow: "hidden", + "> img": { + width: "100%", + height: "auto", + }, +})) + +const OrgName = styled(Typography)(({ theme }) => ({ + ...theme.typography.h3, + color: theme.custom.colors.darkGray2, +})) as typeof Typography + +const ContractSubtitle = styled(Typography)(({ theme }) => ({ + ...theme.typography.subtitle1, + color: theme.custom.colors.silverGrayDark, +})) as typeof Typography + +const StatsSide = styled.div(({ theme }) => ({ + display: "flex", + gap: "64px", + alignItems: "center", + [theme.breakpoints.down("md")]: { + gap: "32px", + flexWrap: "wrap", + }, +})) + +const StatBlock = styled.div({ + display: "flex", + flexDirection: "column", + gap: "4px", +}) + +const StatValue = styled(Typography)(({ theme }) => ({ + ...theme.typography.h3, + color: theme.custom.colors.darkGray2, +})) as typeof Typography + +const StatLabel = styled(Typography)(({ theme }) => ({ + ...theme.typography.subtitle1, + color: theme.custom.colors.silverGrayDark, +})) as typeof Typography + +const SeatAssignmentsSection = styled.div({ + display: "flex", + flexDirection: "column", + gap: "16px", + paddingTop: "8px", +}) + +const SectionTitle = styled(Typography)(({ theme }) => ({ + ...theme.typography.h5, + color: theme.custom.colors.black, +})) as typeof Typography + +const StyledSearchInput = styled(SearchInput)(({ theme }) => ({ + minWidth: "356px", + [theme.breakpoints.down("md")]: { + minWidth: "auto", + width: "100%", + order: -1, + }, +})) + +const SeatAssignmentsControls = styled.div(({ theme }) => ({ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: "16px", + [theme.breakpoints.down("md")]: { + flexDirection: "column", + alignItems: "stretch", + }, +})) + +const ExportButtonWrapper = styled.div(({ theme }) => ({ + [theme.breakpoints.down("md")]: { + width: "100%", + "> button": { + width: "100%", + }, + }, +})) + +const ControlsLeft = styled.div(({ theme }) => ({ + display: "flex", + gap: "16px", + alignItems: "center", + flex: 1, + [theme.breakpoints.down("md")]: { + flexWrap: "wrap", + gap: "12px", + }, +})) + +const TableCard = styled.div(({ theme }) => ({ + backgroundColor: theme.custom.colors.white, + border: `1px solid ${theme.custom.colors.lightGray2}`, + borderRadius: "4px", + padding: "32px", + [theme.breakpoints.down("md")]: { + padding: "16px", + }, +})) + +const TableHeaderRow = styled.div(({ theme }) => ({ + display: "flex", + gap: "16px", + alignItems: "center", + paddingBottom: "16px", + borderBottom: `2px solid ${theme.custom.colors.silverGrayDark}`, + [theme.breakpoints.down("md")]: { + display: "none", + }, +})) + +const TableHeaderCell = styled("div", { + shouldForwardProp: (prop) => prop !== "$flex", +})<{ $flex: number }>(({ $flex, theme }) => ({ + flex: $flex, + minWidth: 0, + ...theme.typography.subtitle2, + color: theme.custom.colors.black, +})) + +const TableRow = styled.div(({ theme }) => ({ + display: "flex", + gap: "16px", + alignItems: "center", + padding: "14px 0", + borderBottom: `1px solid ${theme.custom.colors.silverGrayLight}`, + "&:last-child": { + borderBottom: "none", + }, + [theme.breakpoints.down("md")]: { + position: "relative", + flexWrap: "wrap", + gap: "6px 0", + padding: "16px 40px 16px 0", + }, +})) + +const MobileLabel = styled.span(({ theme }) => ({ + display: "none", + [theme.breakpoints.down("md")]: { + display: "inline", + ...theme.typography.subtitle2, + color: theme.custom.colors.darkGray2, + minWidth: "120px", + flexShrink: 0, + }, +})) + +const TableCell = styled("div", { + shouldForwardProp: (prop) => prop !== "$flex" && prop !== "$primary", +})<{ $flex: number; $primary?: boolean }>(({ $flex, $primary, theme }) => ({ + flex: $flex, + minWidth: 0, + ...theme.typography.body2, + color: theme.custom.colors.black, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + [theme.breakpoints.down("md")]: { + flex: "none", + width: "100%", + display: "flex", + alignItems: "center", + gap: "8px", + overflow: "visible", + whiteSpace: "normal", + ...($primary && { + ...theme.typography.subtitle2, + marginBottom: "4px", + }), + }, +})) + +const StatusBadge = styled(Chip, { + shouldForwardProp: (prop) => prop !== "$status", +})<{ $status: "redeemed" | "pending" }>(({ $status, theme }) => ({ + height: "20px", + borderRadius: "4px", + fontSize: "12px", + fontWeight: theme.typography.fontWeightBold as number, + lineHeight: "16px", + "& .MuiChip-label": { + padding: "0 8px", + }, + ...($status === "redeemed" && { + backgroundColor: `${theme.custom.colors.green}33`, + color: theme.custom.colors.darkGreen, + }), + ...($status === "pending" && { + backgroundColor: `${theme.custom.colors.blue}33`, + color: theme.custom.colors.darkBlue, + }), +})) + +const ActionCell = styled.div(({ theme }) => ({ + width: "40px", + flexShrink: 0, + display: "flex", + justifyContent: "center", + [theme.breakpoints.down("md")]: { + position: "absolute", + top: "16px", + right: 0, + }, +})) + +const IconButton = styled.button(({ theme }) => ({ + background: "none", + border: "none", + padding: "4px", + cursor: "pointer", + borderRadius: "4px", + color: theme.custom.colors.silverGrayDark, + display: "flex", + alignItems: "center", + "&:hover": { + backgroundColor: theme.custom.colors.lightGray2, + color: theme.custom.colors.darkGray2, + }, + "&:focus-visible": { + outline: `2px solid ${theme.custom.colors.darkGray2}`, + outlineOffset: "2px", + }, +})) + +const TableFooter = styled.div({ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + paddingTop: "16px", +}) + +const TableFootnote = styled(Typography)(({ theme }) => ({ + ...theme.typography.body2, + color: theme.custom.colors.silverGrayDark, +})) as typeof Typography + +const EmptyTableMessage = styled(Typography)(({ theme }) => ({ + ...theme.typography.body2, + color: theme.custom.colors.silverGrayDark, + padding: "32px 0", + textAlign: "center", +})) as typeof Typography + +const STUB = "—" + +type StatusFilter = "all" | "pending" | "redeemed" + +const COLUMN_FLEX = { + assignedTo: 2, + redeemedBy: 2, + status: 1.5, + assignedOn: 1.2, + redeemedOn: 1.2, + lastSent: 1, +} + +function formatDate(iso: string | null | undefined): string { + if (!iso) return STUB + return new Date(iso).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) +} + +function isRedeemed(code: ContractCode): boolean { + return code.is_redeemed +} + +type ContractAdminPageInternalProps = { + orgSlug: string + contractSlug: string +} + +const ContractAdminPageInternal: React.FC = ({ + orgSlug, + contractSlug, +}) => { + const [statusFilter, setStatusFilter] = useState("all") + const [searchQuery, setSearchQuery] = useState("") + const [page, setPage] = useState(1) + const [searchAnnouncement, setSearchAnnouncement] = useState("") + + const { + data: managerOrgs, + isLoading: isLoadingOrgs, + isError: isOrgsError, + } = useQuery(managerOrganizationQueries.managerOrganizationsList()) + + const org = managerOrgs?.find(matchOrganizationBySlug(orgSlug)) + const contract = org?.contracts.find((c) => c.slug === contractSlug) + + const { data: contractDetail, isLoading: isLoadingDetail } = useQuery({ + ...managerOrganizationQueries.managerContractDetail({ + id: contract?.id ?? 0, + parent_lookup_organization: org?.id ?? 0, + }), + enabled: !!org && !!contract, + }) + + const { + data: codes, + isLoading: isLoadingCodes, + isError: isCodesError, + } = useQuery({ + ...managerOrganizationQueries.managerContractCodes({ + id: contract?.id ?? 0, + parent_lookup_organization: org?.id ?? 0, + }), + enabled: !!org && !!contract, + }) + + if (isLoadingOrgs) { + return ( + + + + ) + } + + if (isOrgsError || isCodesError) { + return + } + + if (!org) { + return + } + + if (!contract) { + return + } + + const totalPurchased = contractDetail?.total_codes + const totalRedeemed = contractDetail?.total_enrollments + + const filteredCodes = (codes ?? []).filter((code) => { + const redeemed = isRedeemed(code) + const matchesFilter = + statusFilter === "all" || + (statusFilter === "redeemed" && redeemed) || + (statusFilter === "pending" && !redeemed) + const email = code.redeemed_by ?? "" + const matchesSearch = + !searchQuery || email.toLowerCase().includes(searchQuery.toLowerCase()) + return matchesFilter && matchesSearch + }) + + const totalPages = Math.ceil(filteredCodes.length / PAGE_SIZE) + const pagedCodes = filteredCodes.slice( + (page - 1) * PAGE_SIZE, + page * PAGE_SIZE, + ) + + const handleTabChange = (_: React.SyntheticEvent, val: StatusFilter) => { + setStatusFilter(val) + setPage(1) + } + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value) + setPage(1) + } + + const announceSearchCount = (count: number) => { + setSearchAnnouncement("") + setTimeout(() => { + setSearchAnnouncement(`${count} result${count !== 1 ? "s" : ""}`) + }, 0) + } + + return ( + + + {/* Header */} + + + + + +
+ {org.name} + + {contract.name} + {isLoadingDetail ? null : totalPurchased !== undefined ? ( + <> + {" "} + · {totalPurchased} seats + + ) : null} + +
+
+ + + {isLoadingDetail ? ( + + ) : ( + {totalPurchased ?? STUB} + )} + Total purchased + + + {STUB} + Unassigned + + + {STUB} + Pending claim + + + {isLoadingDetail ? ( + + ) : ( + {totalRedeemed ?? STUB} + )} + Redeemed + + +
+ + {/* Seat Assignments */} + + Seat Assignments + + + + + + + + + + { + const count = (codes ?? []).filter((code) => { + const redeemed = isRedeemed(code) + return ( + statusFilter === "all" || + (statusFilter === "redeemed" && redeemed) || + (statusFilter === "pending" && !redeemed) + ) + }).length + setSearchQuery("") + setPage(1) + announceSearchCount(count) + }} + onSubmit={() => announceSearchCount(filteredCodes.length)} + /> + + + + + + + {searchAnnouncement} + + +
+
+ + + Assigned to + + + Redeemed by + + + Status + + + Assigned on + + + Redeemed on + + + Last sent + + + +
+
+ + {isLoadingCodes + ? "Loading seat assignments" + : filteredCodes.length === 0 + ? "No seat assignments found" + : `Showing ${filteredCodes.length === 0 ? 0 : (page - 1) * PAGE_SIZE + 1}–${Math.min(page * PAGE_SIZE, filteredCodes.length)} of ${filteredCodes.length} assignment${filteredCodes.length !== 1 ? "s" : ""}`} + + {isLoadingCodes ? ( + <> + {[1, 2, 3].map((i) => ( + +
+ +
+
+ ))} + + ) : pagedCodes.length === 0 ? ( + + + No seat assignments found. + + + ) : ( + pagedCodes.map((code) => { + const redeemed = isRedeemed(code) + return ( + + + {STUB} + + + Redeemed by + {code.redeemed_by ?? STUB} + + + Status + + + + Assigned on + {STUB} + + + Redeemed on + {formatDate(code.redeemed_on)} + + + Last sent + {STUB} + + + + + + + + ) + }) + )} +
+
+ + + {totalPages > 1 && ( + setPage(p)} + shape="rounded" + size="small" + aria-label="Seat assignments pagination" + /> + )} + +
+
+
+
+ ) +} + +type ContractAdminPageProps = { + orgSlug: string + contractSlug: string +} + +const ContractAdminPage: React.FC = ({ + orgSlug, + contractSlug, +}) => { + const flagEnabled = useFeatureFlagEnabled( + FeatureFlags.B2BContractManagerDashboard, + ) + const flagsLoaded = useFeatureFlagsLoaded() + + if (!flagsLoaded) { + return ( + + + + ) + } + + if (!flagEnabled) { + throw new ForbiddenError("Not enabled.") + } + + return ( + + ) +} + +export default ContractAdminPage + +export type { ContractAdminPageProps } diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index 8b4c685815..fd91220e98 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -22,6 +22,18 @@ import { } from "@mitodl/mitxonline-api-axios/v2" import { faker } from "@faker-js/faker/locale/en" import invariant from "tiny-invariant" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { FeatureFlags } from "@/common/feature_flags" +import { contractAdminView } from "@/common/urls" + +jest.mock("posthog-js/react", () => ({ + ...jest.requireActual("posthog-js/react"), + useFeatureFlagEnabled: jest.fn(), +})) +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) + +const API_BASE_URL = process.env.NEXT_PUBLIC_MITX_ONLINE_BASE_URL +const managerOrganizationsUrl = `${API_BASE_URL}/api/v0/b2b/manager/organizations/` const makeCourseEnrollment = factories.enrollment.courseEnrollment const makeGrade = factories.enrollment.grade @@ -59,6 +71,7 @@ describe("ContractContent", () => { setMockResponse.get(urls.enrollment.enrollmentsListV3(), []) setMockResponse.get(urls.programEnrollments.enrollmentsListV3(), []) setMockResponse.get(urls.contracts.contractsList(), []) + mockedUseFeatureFlagEnabled.mockReturnValue(undefined) }) it("displays a header for each program returned and cards for courses in program", async () => { @@ -1397,6 +1410,70 @@ describe("ContractContent", () => { await screen.findByRole("heading", { name: "Contract not found" }) }) + test("does not render the Manage button when the feature flag is off", async () => { + mockedUseFeatureFlagEnabled.mockImplementation(() => false) + const { orgX } = setupProgramsAndCourses() + + setMockResponse.get(managerOrganizationsUrl, [orgX]) + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: orgX.name }) + expect( + screen.queryByRole("link", { name: "Manage" }), + ).not.toBeInTheDocument() + }) + + test("does not render the Manage button when flag is on but user is not a manager for this org", async () => { + mockedUseFeatureFlagEnabled.mockImplementation( + (flag) => flag === FeatureFlags.B2BContractManagerDashboard, + ) + const { orgX } = setupProgramsAndCourses() + + // Return a different org — user is a manager elsewhere, not for orgX + const otherOrg = factories.organizations.organization({}) + setMockResponse.get(managerOrganizationsUrl, [otherOrg]) + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: orgX.name }) + expect( + screen.queryByRole("link", { name: "Manage" }), + ).not.toBeInTheDocument() + }) + + test("renders the Manage button with correct href when flag is on and user is a manager", async () => { + mockedUseFeatureFlagEnabled.mockImplementation( + (flag) => flag === FeatureFlags.B2BContractManagerDashboard, + ) + const { orgX } = setupProgramsAndCourses() + + setMockResponse.get(managerOrganizationsUrl, [orgX]) + + renderWithProviders( + , + ) + + const manageButton = await screen.findByRole("link", { name: "Manage" }) + expect(manageButton).toHaveAttribute( + "href", + contractAdminView(orgX.slug, orgX.contracts[0].slug), + ) + }) + test("sanitizes HTML content in welcome_message_extra", async () => { const { orgX } = setupProgramsAndCourses() diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx index 59e246a908..bfe141159f 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx @@ -22,10 +22,14 @@ import type { V3UserProgramEnrollment, } from "@mitodl/mitxonline-api-axios/v2" import { mitxUserQueries } from "api/mitxonline-hooks/user" +import { managerOrganizationQueries } from "api/mitxonline-hooks/organizations" import { ButtonLink } from "@mitodl/smoot-design" import { RiAwardFill } from "@remixicon/react" +import { useFeatureFlagEnabled } from "posthog-js/react" import { ErrorContent } from "../ErrorPage/ErrorPageTemplate" import { matchOrganizationBySlug } from "@/common/utils" +import { FeatureFlags } from "@/common/feature_flags" +import { contractAdminView } from "@/common/urls" import { ResourceType, getKey } from "./CoursewareDisplay/helpers" import type { DashboardCourseEntry } from "./CoursewareDisplay/model/dashboardViewModel" import { useContractDashboardData } from "./CoursewareDisplay/hooks/useContractDashboardData" @@ -346,11 +350,22 @@ const ContractHeaderSection = styled.div(({ theme }) => ({ boxShadow: "0 1px 6px 0 rgba(3, 21, 45, 0.05)", [theme.breakpoints.down("sm")]: { flexDirection: "column", + alignItems: "flex-start", gap: "16px", padding: "16px 0 0 0", }, })) +const ManageButtonWrapper = styled.div(({ theme }) => ({ + [theme.breakpoints.down("sm")]: { + width: "100%", + padding: "0 16px 16px", + "> a": { + width: "100%", + }, + }, +})) + type ContractContentInternalProps = { org: OrganizationPage contract: ContractPage @@ -369,6 +384,17 @@ const ContractContentInternal: React.FC = ({ collections, } = useContractDashboardData(org, contract) + const managerDashboardFlag = useFeatureFlagEnabled( + FeatureFlags.B2BContractManagerDashboard, + ) + const { data: managerOrgs } = useQuery({ + ...managerOrganizationQueries.managerOrganizationsList(), + enabled: managerDashboardFlag === true, + }) + const isManager = + managerOrgs?.some(matchOrganizationBySlug(org.slug.replace(/^org-/, ""))) ?? + false + const skeleton = ( {Array.from({ length: 2 }).map((_, index) => ( @@ -408,6 +434,19 @@ const ContractContentInternal: React.FC = ({ + {managerDashboardFlag && isManager && ( + + + Manage + + + )} {variantOptions.length > 1 && ( coursesQuery.data?.results ?? [], [coursesQuery.data?.results], ) - const courseRunEnrollments = courseRunEnrollmentsQuery.data ?? [] + const courseRunEnrollments = React.useMemo( + () => courseRunEnrollmentsQuery.data ?? [], + [courseRunEnrollmentsQuery.data], + ) const variantOptions = React.useMemo( () => contract.variant_options ?? [], diff --git a/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/admin/layout.tsx b/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/admin/layout.tsx new file mode 100644 index 0000000000..34762eb825 --- /dev/null +++ b/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/admin/layout.tsx @@ -0,0 +1,20 @@ +import React from "react" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { Permission } from "api/hooks/user" +import { standardizeMetadata } from "@/common/metadata" +import type { Metadata } from "next" + +export const metadata: Metadata = standardizeMetadata({ + title: "Contract Management", + social: false, +}) + +const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return ( + + {children} + + ) +} + +export default Layout diff --git a/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/admin/page.tsx b/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/admin/page.tsx new file mode 100644 index 0000000000..77aa16042b --- /dev/null +++ b/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/admin/page.tsx @@ -0,0 +1,16 @@ +import React from "react" +import ContractAdminPage from "@/app-pages/ContractAdminPage/ContractAdminPage" + +const Page: React.FC< + PageProps<"/organization/[orgSlug]/contract/[contractSlug]/admin"> +> = async ({ params }) => { + const resolved = await params + return ( + + ) +} + +export default Page diff --git a/frontends/main/src/common/feature_flags.ts b/frontends/main/src/common/feature_flags.ts index 2fddd7acc3..2ff30e705f 100644 --- a/frontends/main/src/common/feature_flags.ts +++ b/frontends/main/src/common/feature_flags.ts @@ -15,6 +15,7 @@ export enum FeatureFlags { OcwProductPages = "ocw-product-pages", VideoPlaylistPage = "video-playlist-page", PodcastDetailPage = "podcast-detail-page", + B2BContractManagerDashboard = "b2b-contract-manager-dashboard", } /** diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index 7bc48486a2..2796fea4eb 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -113,6 +113,10 @@ export const CONTRACT_VIEW = "/dashboard/organization/[orgSlug]/contract/[contractSlug]" export const contractView = (orgSlug: string, contractSlug: string) => generatePath(CONTRACT_VIEW, { orgSlug: orgSlug, contractSlug: contractSlug }) +export const CONTRACT_ADMIN_VIEW = + "/organization/[orgSlug]/contract/[contractSlug]/admin" +export const contractAdminView = (orgSlug: string, contractSlug: string) => + generatePath(CONTRACT_ADMIN_VIEW, { orgSlug, contractSlug }) export const PROGRAM_VIEW = "/dashboard/program/[id]" export const programView = (id: number) => generatePath(PROGRAM_VIEW, { id: String(id) }) diff --git a/frontends/main/src/common/utils.ts b/frontends/main/src/common/utils.ts index 2652f5946c..a949f11090 100644 --- a/frontends/main/src/common/utils.ts +++ b/frontends/main/src/common/utils.ts @@ -10,7 +10,7 @@ const isInEnum = ( const matchOrganizationBySlug = (orgSlug: string) => (organization: OrganizationPage) => { - return organization.slug.replace("org-", "") === orgSlug + return organization.slug.replace(/^org-/, "") === orgSlug } // Utility function to collapse whitespace