diff --git a/apps/customer-portal/webapp/src/App.tsx b/apps/customer-portal/webapp/src/App.tsx index ca2a592962..9a8c16498e 100644 --- a/apps/customer-portal/webapp/src/App.tsx +++ b/apps/customer-portal/webapp/src/App.tsx @@ -49,7 +49,10 @@ import SettingsPage from "@features/settings/pages/SettingsPage"; import ServiceNowCaseRedirectPage from "@features/project-hub/pages/ServiceNowCaseRedirectPage"; import PartnerProjectsPage from "@features/project-hub/pages/PartnerProjectsPage"; import PartnerCasesPage from "@features/project-hub/pages/PartnerCasesPage"; +import UserProjectsPage from "@features/project-hub/pages/UserProjectsPage"; +import UserCasesPage from "@features/project-hub/pages/UserCasesPage"; import PartnerGuard from "@layouts/PartnerGuard"; +import NonPartnerGuard from "@layouts/NonPartnerGuard"; import Error401Page from "@components/error/Error401Page"; import Error403Page from "@components/error/Error403Page"; import Error404Page from "@components/error/Error404Page"; @@ -108,6 +111,12 @@ export default function App(): JSX.Element { } /> + {/* Home overview drill-down pages for non-partner users with multiple projects */} + }> + } /> + } /> + + {/* Project Specific Routes */} }> {/* Dashboard */} diff --git a/apps/customer-portal/webapp/src/features/project-hub/components/UserGlobalSearch.tsx b/apps/customer-portal/webapp/src/features/project-hub/components/UserGlobalSearch.tsx new file mode 100644 index 0000000000..001b57c891 --- /dev/null +++ b/apps/customer-portal/webapp/src/features/project-hub/components/UserGlobalSearch.tsx @@ -0,0 +1,666 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { + alpha, + Box, + Button, + Chip, + CircularProgress, + IconButton, + InputAdornment, + Menu, + MenuItem, + Paper, + Skeleton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from "@wso2/oxygen-ui"; +import { ChevronDown, Download, FileText, FolderOpen, Search, X } from "@wso2/oxygen-ui-icons-react"; +import { type JSX, type MouseEvent, useCallback, useRef, useState } from "react"; +import { useNavigate } from "react-router"; +import { useGetGlobalSearch } from "@api/useGetGlobalSearch"; +import { useDebouncedValue } from "@hooks/useDebouncedValue"; +import { useAuthApiClient } from "@/hooks/useAuthApiClient"; +import { useErrorBanner } from "@context/error-banner/ErrorBannerContext"; +import { PROJECT_HUB_SEARCH_DEBOUNCE_MS } from "@features/project-hub/constants/projectHubConstants"; +import { + downloadProjectListCsv, + downloadProjectListPdf, + fetchAllProjectsForExport, +} from "@features/project-hub/utils/projectsExport"; +import { + downloadCaseListCsv, + downloadCaseListPdf, + fetchAllCasesForExport, +} from "@features/project-hub/utils/casesExport"; +import { getSeverityLegendColor } from "@features/dashboard/utils/dashboard"; +import { formatCasesTableCaseIdentifier, getStatusColor } from "@features/dashboard/utils/casesTable"; +import { mapSeverityToDisplay } from "@features/support/utils/support"; +import type { GlobalSearchCase, GlobalSearchProject } from "@features/project-hub/types/globalSearch"; +import { getCaseNavigationPath, getCaseTypeChipProps } from "@features/project-hub/utils/globalSearchNavigation"; + +type ExportFormat = "csv" | "pdf"; + +const SUMMARY_PAGE_SIZE = 5; +const SKELETON_ROW_COUNT = 5; + +function SkeletonRows({ cols }: { cols: number }): JSX.Element { + return ( + <> + {Array.from({ length: SKELETON_ROW_COUNT }).map((_, i) => ( + + {Array.from({ length: cols }).map((_, j) => ( + + + + ))} + + ))} + + ); +} + +/** + * Home overview for users with more than one project. + * Same layout as the partner global search page — Projects and Cases table + * sections with export — but the Projects table shows Action Required, + * Outstanding and Active Chats counts instead of Start/End Date. + */ +export default function UserGlobalSearch(): JSX.Element { + const navigate = useNavigate(); + const authFetch = useAuthApiClient(); + const { showError } = useErrorBanner(); + const [searchQuery, setSearchQuery] = useState(""); + const debouncedSearchQuery = useDebouncedValue(searchQuery, PROJECT_HUB_SEARCH_DEBOUNCE_MS); + + const [exportingFormat, setExportingFormat] = useState(null); + const isExportingRef = useRef(false); + const [exportAnchorEl, setExportAnchorEl] = useState(null); + + const handleExportOpen = (e: MouseEvent) => { + if (!isExportingRef.current) setExportAnchorEl(e.currentTarget); + }; + const handleExportClose = () => setExportAnchorEl(null); + + const handleExport = useCallback( + async (format: ExportFormat) => { + if (isExportingRef.current) return; + handleExportClose(); + isExportingRef.current = true; + setExportingFormat(format); + try { + const allProjects = await fetchAllProjectsForExport(authFetch, debouncedSearchQuery); + if (allProjects.length === 0) { + showError("No projects to export."); + return; + } + if (format === "csv") { + downloadProjectListCsv(allProjects); + } else { + downloadProjectListPdf(allProjects); + } + } catch { + showError("Failed to export projects."); + } finally { + isExportingRef.current = false; + setExportingFormat(null); + } + }, + [authFetch, debouncedSearchQuery, showError], + ); + + const isExporting = exportingFormat !== null; + + const [exportingCasesFormat, setExportingCasesFormat] = useState(null); + const isExportingCasesRef = useRef(false); + const [exportCasesAnchorEl, setExportCasesAnchorEl] = useState(null); + + const handleCasesExportOpen = (e: MouseEvent) => { + if (!isExportingCasesRef.current) setExportCasesAnchorEl(e.currentTarget); + }; + const handleCasesExportClose = () => setExportCasesAnchorEl(null); + + const handleCasesExport = useCallback( + async (format: ExportFormat) => { + if (isExportingCasesRef.current) return; + handleCasesExportClose(); + isExportingCasesRef.current = true; + setExportingCasesFormat(format); + try { + const allCases = await fetchAllCasesForExport(authFetch, debouncedSearchQuery); + if (allCases.length === 0) { + showError("No cases to export."); + return; + } + if (format === "csv") { + downloadCaseListCsv(allCases); + } else { + downloadCaseListPdf(allCases); + } + } catch { + showError("Failed to export cases."); + } finally { + isExportingCasesRef.current = false; + setExportingCasesFormat(null); + } + }, + [authFetch, debouncedSearchQuery, showError], + ); + + const isExportingCases = exportingCasesFormat !== null; + + // Both projects and cases come from the unified /search endpoint, same as + // the partner view — GlobalSearchProject includes the action required/ + // outstanding/active chats counts this table needs. + const { + data: searchData, + isLoading: isLoadingSearch, + isError: isErrorSearch, + } = useGetGlobalSearch({ + ...(debouncedSearchQuery ? { filters: { searchQuery: debouncedSearchQuery } } : {}), + projectsPagination: { offset: 0, limit: SUMMARY_PAGE_SIZE }, + casesPagination: { offset: 0, limit: SUMMARY_PAGE_SIZE }, + }); + + const isLoadingProjects = isLoadingSearch; + const isErrorProjects = isErrorSearch; + const projects: GlobalSearchProject[] = searchData?.projects ?? []; + const projectsTotal = searchData?.projectsTotal ?? 0; + + const isLoadingCases = isLoadingSearch; + const isErrorCases = isErrorSearch; + const cases: GlobalSearchCase[] = searchData?.cases ?? []; + const casesTotal = searchData?.casesTotal ?? 0; + + const projectsMoreCount = Math.max(0, projectsTotal - projects.length); + const casesMoreCount = Math.max(0, casesTotal - cases.length); + + return ( + + {/* Page header + search bar */} + + + + setSearchQuery("")} + size="small" + > + + + + ) : undefined, + startAdornment: , + }} + onChange={(e) => setSearchQuery(e.target.value)} + placeholder="Search projects and cases..." + size="small" + sx={{ + "& input:-webkit-autofill, & input:-webkit-autofill:focus, & input:-webkit-autofill:hover": + { + WebkitBoxShadow: "0 0 0 100px transparent inset", + WebkitTextFillColor: "inherit", + transition: "background-color 5000s ease-in-out 0s", + }, + }} + value={searchQuery} + /> + {debouncedSearchQuery.trim() && !isLoadingProjects && !isLoadingCases && ( + + Showing results for “{debouncedSearchQuery.trim()}” + + )} + + + + {/* Scrollable content: projects + cases sections */} + + {/* Projects section */} + + + + + + Projects + {!isLoadingProjects && ( + + {" "} + ({projectsTotal}) + + )} + + + + + void handleExport("csv")}>Export to CSV + void handleExport("pdf")}>Export to PDF + + + + + + + + Project Key + Name + Status + Action Required + Outstanding + Active Chats + + + + {isLoadingProjects ? ( + + ) : isErrorProjects ? ( + + + + Failed to load projects. + + + + ) : projects.length === 0 ? ( + + + + No projects found. + + + + ) : ( + projects.map((project) => { + const isSuspended = + project.closureState?.toLowerCase() === "suspended"; + return ( + navigate(`/projects/${project.id}/dashboard`)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + navigate(`/projects/${project.id}/dashboard`); + } + }} + sx={{ cursor: "pointer" }} + tabIndex={0} + > + + + {project.key} + + + + {project.name} + + + + + + + {project.actionRequiredCount ?? 0} + + + + + {project.outstandingCount ?? 0} + + + + + {project.activeChatsCount ?? 0} + + + + ); + }) + )} + +
+
+ + {projectsMoreCount > 0 && ( + + + + )} +
+ + {/* Cases section */} + + + + + + Cases + {!isLoadingCases && ( + + {" "} + ({casesTotal}) + + )} + + + + + void handleCasesExport("csv")}>Export to CSV + void handleCasesExport("pdf")}>Export to PDF + + + + + + + + Details + Case Type + Severity + Status + Created by + Created on + Project + + + + {isLoadingCases ? ( + + ) : isErrorCases ? ( + + + + Failed to load cases. + + + + ) : cases.length === 0 ? ( + + + + No cases found. + + + + ) : ( + cases.map((c) => { + const severityLabel = c.severity?.label; + const severityDisplay = mapSeverityToDisplay(severityLabel); + const severityColor = getSeverityLegendColor(severityLabel); + const { color: caseTypeColor, displayLabel: caseTypeLabel } = getCaseTypeChipProps(c.caseType?.label); + const casePath = getCaseNavigationPath(c); + const handleNavigate = casePath ? () => navigate(casePath, { state: { returnTo: "/" } }) : undefined; + return ( + { + if (handleNavigate && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + handleNavigate(); + } + }} + sx={{ cursor: casePath ? "pointer" : "default" }} + tabIndex={casePath ? 0 : undefined} + > + + + + {c.title ?? "--"} + + + {formatCasesTableCaseIdentifier(c.number, c.internalId)} + + + + + ({ + bgcolor: alpha(caseTypeColor, theme.palette.mode === "dark" ? 0.05 : 0.1), + borderColor: alpha(caseTypeColor, theme.palette.mode === "dark" ? 0.18 : 0.3), + color: caseTypeColor, + fontSize: "0.75rem", + fontWeight: 500, + height: 20, + px: 0, + "& .MuiChip-label": { pl: "6px", pr: "6px" }, + })} + /> + + + {severityLabel ? ( + + ) : ( + + -- + + )} + + + + + + {c.state?.label ?? "--"} + + + + + + {c.createdBy ?? "--"} + + + + + {formatDateShort(c.createdOn)} + + + + + {c.project?.label ?? "--"} + + + + ); + }) + )} + +
+
+ + {casesMoreCount > 0 && ( + + + + )} +
+
+
+ ); +} + +const DATE_LOCALE = "en-US"; +const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "short", + year: "numeric", +}; + +function formatDateShort(value: string | null | undefined): string { + if (!value) return "--"; + const d = new Date(value); + return isNaN(d.getTime()) ? "--" : d.toLocaleDateString(DATE_LOCALE, DATE_FORMAT_OPTIONS); +} diff --git a/apps/customer-portal/webapp/src/features/project-hub/pages/ProjectHub.tsx b/apps/customer-portal/webapp/src/features/project-hub/pages/ProjectHub.tsx index 6f1a456cb7..e2da7440bd 100644 --- a/apps/customer-portal/webapp/src/features/project-hub/pages/ProjectHub.tsx +++ b/apps/customer-portal/webapp/src/features/project-hub/pages/ProjectHub.tsx @@ -39,6 +39,7 @@ import { useDebouncedValue } from "@hooks/useDebouncedValue"; import ProjectCard from "@features/project-hub/components/ProjectCard"; import ProjectCardSkeleton from "@features/project-hub/components/project-card/ProjectCardSkeleton"; import PartnerGlobalSearch from "@features/project-hub/components/PartnerGlobalSearch"; +import UserGlobalSearch from "@features/project-hub/components/UserGlobalSearch"; import useGetUserDetails from "@features/settings/api/useGetUserDetails"; import { hasPartnerAccess } from "@features/settings/constants/settingsConstants"; import { clearLastSelectedProject } from "@features/settings/utils/settingsStorage"; @@ -265,6 +266,22 @@ export default function ProjectHub(): JSX.Element { return ; } + // Non-partner users with more than one project see the same table-based + // overview as partners, with per-project stat counts instead of dates. + const showUserGlobalSearch = + !isPartner && + !isLoading && + !isAuthLoading && + !isLoadingUserDetails && + !isError && + !allProjectsSuspended && + !isCheckingAllSuspended && + projects.length > 1; + + if (showUserGlobalSearch) { + return ; + } + const showSearchBar = shouldShowProjectHubSearchBar( titleTotalRecords, searchQuery, diff --git a/apps/customer-portal/webapp/src/features/project-hub/pages/UserCasesPage.tsx b/apps/customer-portal/webapp/src/features/project-hub/pages/UserCasesPage.tsx new file mode 100644 index 0000000000..ad52db5419 --- /dev/null +++ b/apps/customer-portal/webapp/src/features/project-hub/pages/UserCasesPage.tsx @@ -0,0 +1,475 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { + alpha, + Box, + Button, + Chip, + CircularProgress, + IconButton, + InputAdornment, + LinearProgress, + Menu, + MenuItem, + Paper, + Skeleton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Typography, +} from "@wso2/oxygen-ui"; +import { ArrowLeft, ChevronDown, Download, FileText, Search, X } from "@wso2/oxygen-ui-icons-react"; +import { type ChangeEvent, type JSX, type MouseEvent, useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router"; +import { useGetGlobalSearch } from "@api/useGetGlobalSearch"; +import { useDebouncedValue } from "@hooks/useDebouncedValue"; +import { PROJECT_HUB_SEARCH_DEBOUNCE_MS } from "@features/project-hub/constants/projectHubConstants"; +import { getSeverityLegendColor } from "@features/dashboard/utils/dashboard"; +import { formatCasesTableCaseIdentifier, getStatusColor } from "@features/dashboard/utils/casesTable"; +import { mapSeverityToDisplay } from "@features/support/utils/support"; +import { getCaseNavigationPath, getCaseTypeChipProps } from "@features/project-hub/utils/globalSearchNavigation"; +import { + downloadCaseListCsv, + downloadCaseListPdf, + fetchAllCasesForExport, +} from "@features/project-hub/utils/casesExport"; +import { useAuthApiClient } from "@/hooks/useAuthApiClient"; +import { useErrorBanner } from "@context/error-banner/ErrorBannerContext"; + +const ROWS_PER_PAGE_OPTIONS = [10, 25, 50]; +const DEFAULT_ROWS_PER_PAGE = 10; +const SKELETON_ROW_COUNT = 5; +const COL_SPAN = 7; + +const DATE_LOCALE = "en-US"; +const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "short", + year: "numeric", +}; + +function formatDate(value: string | null | undefined): string { + if (!value) return "--"; + const d = new Date(value); + return isNaN(d.getTime()) ? "--" : d.toLocaleDateString(DATE_LOCALE, DATE_FORMAT_OPTIONS); +} + +type ExportFormat = "csv" | "pdf"; + +/** + * Full-page cases search for users with more than one project. + * Navigated to via "View More" on the home overview page. + * Pre-fills the search bar from the `?q=` URL parameter. + */ +export default function UserCasesPage(): JSX.Element { + const navigate = useNavigate(); + const authFetch = useAuthApiClient(); + const { showError } = useErrorBanner(); + const [searchParams, setSearchParams] = useSearchParams(); + const [searchQuery, setSearchQuery] = useState(() => searchParams.get("q") ?? ""); + const debouncedSearchQuery = useDebouncedValue(searchQuery, PROJECT_HUB_SEARCH_DEBOUNCE_MS); + + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(DEFAULT_ROWS_PER_PAGE); + + useEffect(() => { + setPage(0); + }, [debouncedSearchQuery]); + + useEffect(() => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (debouncedSearchQuery.trim()) { + next.set("q", debouncedSearchQuery.trim()); + } else { + next.delete("q"); + } + return next; + }, + { replace: true }, + ); + }, [debouncedSearchQuery, setSearchParams]); + + const { data, isLoading, isError } = useGetGlobalSearch({ + filters: { + types: ["cases"], + ...(debouncedSearchQuery ? { searchQuery: debouncedSearchQuery } : {}), + }, + casesPagination: { offset: page * rowsPerPage, limit: rowsPerPage }, + }); + + const cases = data?.cases ?? []; + const totalRecords = data?.casesTotal ?? 0; + + const [exportingFormat, setExportingFormat] = useState(null); + const isExportingRef = useRef(false); + const [exportAnchorEl, setExportAnchorEl] = useState(null); + + const handleExportOpen = (e: MouseEvent) => { + if (!isExportingRef.current) setExportAnchorEl(e.currentTarget); + }; + const handleExportClose = () => setExportAnchorEl(null); + + const handleExport = useCallback( + async (format: ExportFormat) => { + if (isExportingRef.current) return; + handleExportClose(); + isExportingRef.current = true; + setExportingFormat(format); + try { + const allCases = await fetchAllCasesForExport(authFetch, debouncedSearchQuery); + if (allCases.length === 0) { + showError("No cases to export."); + return; + } + if (format === "csv") { + downloadCaseListCsv(allCases); + } else { + downloadCaseListPdf(allCases); + } + } catch { + showError("Failed to export cases."); + } finally { + isExportingRef.current = false; + setExportingFormat(null); + } + }, + [authFetch, debouncedSearchQuery, showError], + ); + + const isExporting = exportingFormat !== null; + + const handleChangePage = (_: unknown, newPage: number) => setPage(newPage); + const handleChangeRowsPerPage = (e: ChangeEvent) => { + setRowsPerPage(parseInt(e.target.value, 10)); + setPage(0); + }; + + return ( + + {/* Header */} + + navigate("/")} + size="small" + > + + + + + Cases + {!isLoading && ( + + {" "}({totalRecords}) + + )} + {isLoading && ( + + )} + + + + void handleExport("csv")}>Export to CSV + void handleExport("pdf")}>Export to PDF + + + + {/* Filtered-results hint */} + {debouncedSearchQuery.trim() && !isLoading && ( + + + Showing results for “{debouncedSearchQuery.trim()}” + + + )} + + {/* Search bar */} + + + setSearchQuery("")} + size="small" + > + + + + ) : undefined, + startAdornment: , + }} + onChange={(e) => setSearchQuery(e.target.value)} + placeholder="Search cases..." + size="small" + sx={{ + maxWidth: 560, + "& input:-webkit-autofill, & input:-webkit-autofill:focus, & input:-webkit-autofill:hover": + { + WebkitBoxShadow: "0 0 0 100px transparent inset", + WebkitTextFillColor: "inherit", + transition: "background-color 5000s ease-in-out 0s", + }, + }} + value={searchQuery} + /> + + + {/* Loading indicator */} + {isLoading && ( + + )} + + {/* Scrollable table */} + + + + + + Details + Case Type + Severity + Status + Created by + Created on + Project + + + + {isLoading ? ( + Array.from({ length: SKELETON_ROW_COUNT }).map((_, i) => ( + + {Array.from({ length: COL_SPAN }).map((_, j) => ( + + + + ))} + + )) + ) : isError ? ( + + + + Failed to load cases. Please try again. + + + + ) : cases.length === 0 ? ( + + + + No cases found. + + + + ) : ( + cases.map((c) => { + const severityLabel = c.severity?.label; + const severityDisplay = mapSeverityToDisplay(severityLabel); + const severityColor = getSeverityLegendColor(severityLabel); + const { color: caseTypeColor, displayLabel: caseTypeLabel } = getCaseTypeChipProps(c.caseType?.label); + const casePath = getCaseNavigationPath(c); + const returnTo = `/cases${searchParams.toString() ? `?${searchParams.toString()}` : ""}`; + const handleNavigate = casePath ? () => navigate(casePath, { state: { returnTo } }) : undefined; + return ( + { + if (handleNavigate && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + handleNavigate(); + } + }} + sx={{ cursor: casePath ? "pointer" : "default" }} + tabIndex={casePath ? 0 : undefined} + > + + + + {c.title ?? "--"} + + + {formatCasesTableCaseIdentifier(c.number, c.internalId)} + + + + + ({ + bgcolor: alpha(caseTypeColor, theme.palette.mode === "dark" ? 0.05 : 0.1), + borderColor: alpha(caseTypeColor, theme.palette.mode === "dark" ? 0.18 : 0.3), + color: caseTypeColor, + fontSize: "0.75rem", + fontWeight: 500, + height: 20, + px: 0, + "& .MuiChip-label": { pl: "6px", pr: "6px" }, + })} + /> + + + {severityLabel ? ( + + ) : ( + + -- + + )} + + + + + + {c.state?.label ?? "--"} + + + + + + {c.createdBy ?? "--"} + + + + + {formatDate(c.createdOn)} + + + + + {c.project?.label ?? "--"} + + + + ); + }) + )} + +
+
+ +
+
+ ); +} diff --git a/apps/customer-portal/webapp/src/features/project-hub/pages/UserProjectsPage.tsx b/apps/customer-portal/webapp/src/features/project-hub/pages/UserProjectsPage.tsx new file mode 100644 index 0000000000..4ae9fe74ff --- /dev/null +++ b/apps/customer-portal/webapp/src/features/project-hub/pages/UserProjectsPage.tsx @@ -0,0 +1,402 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { + Box, + Button, + Chip, + CircularProgress, + IconButton, + InputAdornment, + LinearProgress, + Menu, + MenuItem, + Paper, + Skeleton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Typography, +} from "@wso2/oxygen-ui"; +import { ArrowLeft, ChevronDown, Download, FolderOpen, Search, X } from "@wso2/oxygen-ui-icons-react"; +import { type ChangeEvent, type JSX, type MouseEvent, useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router"; +import { useGetGlobalSearch } from "@api/useGetGlobalSearch"; +import { useAuthApiClient } from "@/hooks/useAuthApiClient"; +import { useErrorBanner } from "@context/error-banner/ErrorBannerContext"; +import { useDebouncedValue } from "@hooks/useDebouncedValue"; +import { PROJECT_HUB_SEARCH_DEBOUNCE_MS } from "@features/project-hub/constants/projectHubConstants"; +import { + downloadProjectListCsv, + downloadProjectListPdf, + fetchAllProjectsForExport, +} from "@features/project-hub/utils/projectsExport"; + +const ROWS_PER_PAGE_OPTIONS = [10, 25, 50]; +const DEFAULT_ROWS_PER_PAGE = 10; +const SKELETON_ROW_COUNT = 5; +const COL_SPAN = 6; + +type ExportFormat = "csv" | "pdf"; + +/** + * Full-page projects search for users with more than one project. + * Navigated to via "View More" on the home overview page. + * Pre-fills the search bar from the `?q=` URL parameter. + */ +export default function UserProjectsPage(): JSX.Element { + const navigate = useNavigate(); + const authFetch = useAuthApiClient(); + const { showError } = useErrorBanner(); + const [searchParams, setSearchParams] = useSearchParams(); + const [searchQuery, setSearchQuery] = useState(() => searchParams.get("q") ?? ""); + const debouncedSearchQuery = useDebouncedValue(searchQuery, PROJECT_HUB_SEARCH_DEBOUNCE_MS); + + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(DEFAULT_ROWS_PER_PAGE); + + const normalizedQuery = debouncedSearchQuery.trim(); + + useEffect(() => { + setPage(0); + }, [normalizedQuery]); + + useEffect(() => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (normalizedQuery) { + next.set("q", normalizedQuery); + } else { + next.delete("q"); + } + return next; + }, + { replace: true }, + ); + }, [normalizedQuery, setSearchParams]); + + const { data, isLoading, isError } = useGetGlobalSearch({ + filters: { + types: ["projects"], + ...(normalizedQuery ? { searchQuery: normalizedQuery } : {}), + }, + projectsPagination: { offset: page * rowsPerPage, limit: rowsPerPage }, + }); + + const projects = data?.projects ?? []; + const totalRecords = data?.projectsTotal ?? 0; + + const [exportingFormat, setExportingFormat] = useState(null); + const isExportingRef = useRef(false); + const [exportAnchorEl, setExportAnchorEl] = useState(null); + + const handleExportOpen = (e: MouseEvent) => { + if (!isExportingRef.current) setExportAnchorEl(e.currentTarget); + }; + const handleExportClose = () => setExportAnchorEl(null); + + const handleExport = useCallback( + async (format: ExportFormat) => { + if (isExportingRef.current) return; + handleExportClose(); + isExportingRef.current = true; + setExportingFormat(format); + try { + const allProjects = await fetchAllProjectsForExport(authFetch, normalizedQuery); + if (allProjects.length === 0) { + showError("No projects to export."); + return; + } + if (format === "csv") { + downloadProjectListCsv(allProjects); + } else { + downloadProjectListPdf(allProjects); + } + } catch { + showError("Failed to export projects."); + } finally { + isExportingRef.current = false; + setExportingFormat(null); + } + }, + [authFetch, normalizedQuery, showError], + ); + + const isExporting = exportingFormat !== null; + + const handleChangePage = (_: unknown, newPage: number) => setPage(newPage); + const handleChangeRowsPerPage = (e: ChangeEvent) => { + setRowsPerPage(parseInt(e.target.value, 10)); + setPage(0); + }; + + return ( + + {/* Header */} + + navigate("/")} + size="small" + > + + + + + Projects + {!isLoading && ( + + {" "}({totalRecords}) + + )} + {isLoading && ( + + )} + + + + void handleExport("csv")}>Export to CSV + void handleExport("pdf")}>Export to PDF + + + + {/* Filtered-results hint */} + {normalizedQuery && !isLoading && ( + + + Showing results for “{normalizedQuery}” + + + )} + + {/* Search bar */} + + + setSearchQuery("")} + size="small" + > + + + + ) : undefined, + startAdornment: , + }} + onChange={(e) => setSearchQuery(e.target.value)} + placeholder="Search projects..." + size="small" + sx={{ + maxWidth: 560, + "& input:-webkit-autofill, & input:-webkit-autofill:focus, & input:-webkit-autofill:hover": + { + WebkitBoxShadow: "0 0 0 100px transparent inset", + WebkitTextFillColor: "inherit", + transition: "background-color 5000s ease-in-out 0s", + }, + }} + value={searchQuery} + /> + + + {/* Loading indicator */} + {isLoading && ( + + )} + + {/* Scrollable table */} + + + + + + Project Key + Name + Status + Action Required + Outstanding + Active Chats + + + + {isLoading ? ( + Array.from({ length: SKELETON_ROW_COUNT }).map((_, i) => ( + + {Array.from({ length: COL_SPAN }).map((_, j) => ( + + + + ))} + + )) + ) : isError ? ( + + + + Failed to load projects. Please try again. + + + + ) : projects.length === 0 ? ( + + + + No projects found. + + + + ) : ( + projects.map((project) => { + const isSuspended = + project.closureState?.toLowerCase() === "suspended"; + return ( + + navigate(`/projects/${project.id}/dashboard`) + } + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + navigate(`/projects/${project.id}/dashboard`); + } + }} + sx={{ cursor: "pointer" }} + tabIndex={0} + > + + + {project.key} + + + + {project.name} + + + + + + + {project.actionRequiredCount ?? 0} + + + + + {project.outstandingCount ?? 0} + + + + + {project.activeChatsCount ?? 0} + + + + ); + }) + )} + +
+
+ +
+
+ ); +} diff --git a/apps/customer-portal/webapp/src/features/project-hub/types/globalSearch.ts b/apps/customer-portal/webapp/src/features/project-hub/types/globalSearch.ts index bc64f661a6..3dd4460663 100644 --- a/apps/customer-portal/webapp/src/features/project-hub/types/globalSearch.ts +++ b/apps/customer-portal/webapp/src/features/project-hub/types/globalSearch.ts @@ -49,6 +49,9 @@ export interface GlobalSearchProject { hasPdpSubscription: boolean; closureState?: string | null; account: ReferenceItem; + activeChatsCount: number; + actionRequiredCount: number; + outstandingCount: number; } export interface GlobalSearchCase { diff --git a/apps/customer-portal/webapp/src/layouts/NonPartnerGuard.tsx b/apps/customer-portal/webapp/src/layouts/NonPartnerGuard.tsx new file mode 100644 index 0000000000..4a272d3a97 --- /dev/null +++ b/apps/customer-portal/webapp/src/layouts/NonPartnerGuard.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { type JSX } from "react"; +import { Navigate, Outlet } from "react-router"; +import { Box, LinearProgress, Typography } from "@wso2/oxygen-ui"; +import useGetUserDetails from "@features/settings/api/useGetUserDetails"; +import { hasPartnerAccess } from "@features/settings/constants/settingsConstants"; + +/** + * Guards routes meant only for non-partner users (e.g. the user overview + * drill-down pages). Partner users are redirected to the home page, which + * renders their own global search overview. + * On fetch error the guard shows an error message rather than falsely redirecting. + */ +export default function NonPartnerGuard(): JSX.Element { + const { data: userDetails, isLoading, isError } = useGetUserDetails(); + + if (isLoading) { + return ( + + + + ); + } + + if (isError || !userDetails) { + return ( + + + Unable to verify access. Please refresh and try again. + + + ); + } + + const isPartner = hasPartnerAccess(userDetails.roles ?? []); + + if (isPartner) { + return ; + } + + return ; +}