diff --git a/apps/customer-portal/webapp/src/features/project-hub/components/PartnerGlobalSearch.tsx b/apps/customer-portal/webapp/src/features/project-hub/components/PartnerGlobalSearch.tsx index 17d1a11b26..6722017e7e 100644 --- a/apps/customer-portal/webapp/src/features/project-hub/components/PartnerGlobalSearch.tsx +++ b/apps/customer-portal/webapp/src/features/project-hub/components/PartnerGlobalSearch.tsx @@ -49,6 +49,11 @@ import { 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"; @@ -134,7 +139,7 @@ export default function PartnerGlobalSearch(): JSX.Element { isExportingRef.current = true; setExportingFormat(format); try { - const allProjects = await fetchAllProjectsForExport(authFetch); + const allProjects = await fetchAllProjectsForExport(authFetch, debouncedSearchQuery); if (allProjects.length === 0) { showError("No projects to export."); return; @@ -151,11 +156,49 @@ export default function PartnerGlobalSearch(): JSX.Element { setExportingFormat(null); } }, - [authFetch, showError], + [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; + // Table query — filters by the debounced search query when one is active const { data: tableData, @@ -438,16 +481,29 @@ export default function PartnerGlobalSearch(): JSX.Element { - {highlightMatch( - c.number - ? `${c.number} · ${c.title ?? ""}` - : (c.title ?? ""), - debouncedSearchQuery, - )} - - - {caseTypeLabel} + {highlightMatch(c.title ?? "--", debouncedSearchQuery)} + + + {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, + flexShrink: 0, + fontSize: "0.65rem", + fontWeight: 500, + height: 16, + px: 0, + "& .MuiChip-label": { pl: "5px", pr: "5px" }, + })} + /> + ); @@ -638,21 +694,52 @@ export default function PartnerGlobalSearch(): JSX.Element { {/* Cases section */} - - - - Cases - {!isLoadingCases && ( - - {" "} - ({casesTotal}) - - )} - + + + + + Cases + {!isLoadingCases && ( + + {" "} + ({casesTotal}) + + )} + + + + + void handleCasesExport("csv")}>Export to CSV + void handleCasesExport("pdf")}>Export to PDF + diff --git a/apps/customer-portal/webapp/src/features/project-hub/pages/PartnerCasesPage.tsx b/apps/customer-portal/webapp/src/features/project-hub/pages/PartnerCasesPage.tsx index 915734539a..f2f959e461 100644 --- a/apps/customer-portal/webapp/src/features/project-hub/pages/PartnerCasesPage.tsx +++ b/apps/customer-portal/webapp/src/features/project-hub/pages/PartnerCasesPage.tsx @@ -17,10 +17,14 @@ import { alpha, Box, + Button, Chip, + CircularProgress, IconButton, InputAdornment, LinearProgress, + Menu, + MenuItem, Paper, Skeleton, Table, @@ -33,8 +37,8 @@ import { TextField, Typography, } from "@wso2/oxygen-ui"; -import { ArrowLeft, FileText, Search, X } from "@wso2/oxygen-ui-icons-react"; -import { type ChangeEvent, type JSX, useEffect, useState } from "react"; +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"; @@ -43,6 +47,13 @@ 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; @@ -55,8 +66,12 @@ const COL_SPAN = 6; * Navigated to via "View More" on the partner global search page. * Pre-fills the search bar from the `?q=` URL parameter. */ +type ExportFormat = "csv" | "pdf"; + export default function PartnerCasesPage(): JSX.Element { const navigate = useNavigate(); + const authFetch = useAuthApiClient(); + const { showError } = useErrorBanner(); const [searchParams, setSearchParams] = useSearchParams(); const urlQuery = searchParams.get("q") ?? ""; @@ -94,6 +109,44 @@ export default function PartnerCasesPage(): JSX.Element { 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)); @@ -146,6 +199,35 @@ export default function PartnerCasesPage(): JSX.Element { /> )} + + + void handleExport("csv")}>Export to CSV + void handleExport("pdf")}>Export to PDF + {/* Filtered-results hint */} diff --git a/apps/customer-portal/webapp/src/features/project-hub/utils/casesExport.ts b/apps/customer-portal/webapp/src/features/project-hub/utils/casesExport.ts new file mode 100644 index 0000000000..d3bdd36bad --- /dev/null +++ b/apps/customer-portal/webapp/src/features/project-hub/utils/casesExport.ts @@ -0,0 +1,160 @@ +// 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 { buildCsvContent, downloadCsvFile } from "@utils/csv"; +import { downloadPdfFile, type PdfColumnStyle } from "@utils/pdf"; +import type { GlobalSearchCase, GlobalSearchResponse } from "@features/project-hub/types/globalSearch"; +import type { AuthFetchFn } from "@features/project-hub/utils/projectsExport"; +import { getCaseTypeChipProps } from "@features/project-hub/utils/globalSearchNavigation"; + +const EXPORT_ALL_PAGE_SIZE = 50; + +const EXPORT_HEADERS = [ + "Title", + "Case ID", + "Case Type", + "Severity", + "Status", + "Created by", + "Created on", + "Project", +]; + +// A4 landscape usable width ~269mm, 8 columns. +const PDF_COLUMN_STYLES: Record = { + 0: { cellWidth: 70 }, // Title + 1: { cellWidth: 35 }, // Case ID + 2: { cellWidth: 30 }, // Case Type + 3: { cellWidth: 20, halign: "center" }, // Severity + 4: { cellWidth: 24, halign: "center" }, // Status + 5: { cellWidth: 30 }, // Created by + 6: { cellWidth: 24, halign: "center" }, // Created on + 7: { cellWidth: 36 }, // Project +}; + +const DATE_LOCALE = "en-US"; +const DATE_FORMAT: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "short", + year: "numeric", +}; + +/** Formats an ISO date string for display in exported files. */ +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); +} + +/** Combines the portal case number and WSO2 internal ID into a single display string. */ +function formatCaseId(number?: string | null, internalId?: string | null): string { + const n = number?.trim() ?? ""; + const id = internalId?.trim() ?? ""; + if (n && id) return `${n} | ${id}`; + return n || id || "--"; +} + +/** Builds a timestamped filename for the exported file. */ +function buildFilename(ext: "csv" | "pdf"): string { + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + return `cases-${yyyy}-${mm}-${dd}.${ext}`; +} + +/** Maps a list of GlobalSearchCase objects to string rows for CSV/PDF output. */ +function mapCasesToRows(cases: GlobalSearchCase[]): string[][] { + return cases.map((c) => { + const { displayLabel: caseTypeLabel } = getCaseTypeChipProps(c.caseType?.label); + return [ + c.title?.trim() ?? "--", + formatCaseId(c.number, c.internalId), + caseTypeLabel, + c.severity?.label ?? "--", + c.state?.label ?? "--", + c.createdBy?.trim() ?? "--", + formatDate(c.createdOn), + c.project?.label ?? "--", + ]; + }); +} + +/** + * Fetches every page of cases matching an optional search query via POST /search. + * Use this to collect the full dataset before exporting. + */ +export async function fetchAllCasesForExport( + authFetch: AuthFetchFn, + searchQuery?: string, +): Promise { + const baseUrl = window.config?.CUSTOMER_PORTAL_BACKEND_BASE_URL; + if (!baseUrl) throw new Error("CUSTOMER_PORTAL_BACKEND_BASE_URL is not configured"); + + const allCases: GlobalSearchCase[] = []; + let offset = 0; + + for (;;) { + const body = { + filters: { + types: ["cases"], + ...(searchQuery?.trim() ? { searchQuery: searchQuery.trim() } : {}), + }, + casesPagination: { offset, limit: EXPORT_ALL_PAGE_SIZE }, + }; + + const response = await authFetch(`${baseUrl}/search`, { + method: "POST", + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Error fetching cases for export: ${response.statusText}`); + } + + const data: GlobalSearchResponse = await response.json(); + const page = data.cases ?? []; + allCases.push(...page); + + const total = data.casesTotal ?? 0; + const nextOffset = offset + EXPORT_ALL_PAGE_SIZE; + if (page.length === 0 || allCases.length >= total || nextOffset <= offset) { + break; + } + offset = nextOffset; + } + + return allCases; +} + +/** Triggers a CSV file download containing the provided cases. */ +export function downloadCaseListCsv(cases: GlobalSearchCase[]): void { + const rows = mapCasesToRows(cases); + const content = buildCsvContent(EXPORT_HEADERS, rows); + downloadCsvFile(buildFilename("csv"), content); +} + +/** Triggers a PDF file download containing the provided cases. */ +export function downloadCaseListPdf(cases: GlobalSearchCase[]): void { + const rows = mapCasesToRows(cases); + downloadPdfFile( + buildFilename("pdf"), + "Cases", + EXPORT_HEADERS, + rows, + PDF_COLUMN_STYLES, + ); +}