Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions apps/customer-portal/webapp/src/api/useGetProjectChangeRequestStats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// 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 { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { useAsgardeo } from "@asgardeo/react";
import { useLogger } from "@hooks/useLogger";
import { ApiQueryKeys } from "@constants/apiConstants";
import { useAuthApiClient } from "@context/AuthApiContext";
import type {
ChangeRequestStatsResponse,
ChangeRequestStats,
} from "@models/responses";

/**
* Maps the API response to ChangeRequestStats.
*
* @param {ChangeRequestStatsResponse} response - The API response.
* @returns {ChangeRequestStats} The mapped stats object.
*/
function mapChangeRequestStats(
response: ChangeRequestStatsResponse,
): ChangeRequestStats {
const { totalCount, stateCount } = response;

// Find scheduled count (label: "Scheduled")
const scheduled =
stateCount.find((state) => state.label === "Scheduled")?.count ?? 0;

// Calculate in-progress count (sum of: Implement, Review, Customer Approval, Customer Review)
const inProgressLabels = [
"Implement",
"Review",
"Customer Approval",
"Customer Review",
];
const inProgress = stateCount
.filter((state) => inProgressLabels.includes(state.label))
.reduce((sum, state) => sum + state.count, 0);

// Calculate completed count (sum of: Closed, Canceled, Rollback)
const completedLabels = ["Closed", "Canceled", "Rollback"];
const completed = stateCount
.filter((state) => completedLabels.includes(state.label))
.reduce((sum, state) => sum + state.count, 0);

return {
totalRequests: totalCount,
scheduled,
inProgress,
completed,
};
}

/**
* Custom hook to fetch project change request statistics.
*
* @param {string} projectId - The ID of the project.
* @returns {UseQueryResult<ChangeRequestStats, Error>} The query result object.
*/
export function useGetProjectChangeRequestStats(
projectId: string,
): UseQueryResult<ChangeRequestStats, Error> {
const logger = useLogger();
const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo();
const fetchFn = useAuthApiClient();

return useQuery<ChangeRequestStats, Error>({
queryKey: [ApiQueryKeys.CHANGE_REQUEST_STATS, projectId],
queryFn: async (): Promise<ChangeRequestStats> => {
logger.debug(
`[useGetProjectChangeRequestStats] Fetching change request stats for project ID: ${projectId}`,
);

try {
const baseUrl = window.config?.CUSTOMER_PORTAL_BACKEND_BASE_URL;

if (!baseUrl) {
throw new Error("CUSTOMER_PORTAL_BACKEND_BASE_URL is not configured");
}

const requestUrl = `${baseUrl}/projects/${projectId}/stats/change-requests`;
const response = await fetchFn(requestUrl, { method: "GET" });

logger.debug(
`[useGetProjectChangeRequestStats] Response status for ${projectId}: ${response.status}`,
);

if (!response.ok) {
throw new Error(
`Error fetching change request stats: ${response.statusText}`,
);
}

const data: ChangeRequestStatsResponse = await response.json();
logger.debug("[useGetProjectChangeRequestStats] Data received:", data);

// Map the API response to the expected stats format
const mappedStats = mapChangeRequestStats(data);
logger.debug(
"[useGetProjectChangeRequestStats] Mapped stats:",
mappedStats,
);

return mappedStats;
} catch (error) {
logger.error("[useGetProjectChangeRequestStats] Error:", error);
throw error;
}
},
enabled: !!projectId && isSignedIn && !isAuthLoading,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import {
TablePagination,
alpha,
} from "@wso2/oxygen-ui";
import { ExternalLink } from "@wso2/oxygen-ui-icons-react";
import { type JSX, type ChangeEvent } from "react";
import type { CaseSearchResponse, CaseListItem } from "@models/responses";
import {
Expand Down Expand Up @@ -160,7 +159,6 @@ const CasesList = ({
}}
>
{row.title || "--"}
<ExternalLink size={12} style={{ opacity: 0.5 }} />
</Typography>
<Typography variant="caption" color="text.secondary">
ID: {row.number}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ import { useAsgardeo } from "@asgardeo/react";
import { getNoveraChatEnabled } from "@utils/settingsStorage";
import { useGetProjectCasesPage } from "@api/useGetProjectCasesPage";
import useGetProjectFilters from "@api/useGetProjectFilters";
import { useGetDeployments } from "@api/useGetDeployments";
import FilterPopover, {
type FilterField,
} from "@components/common/filter-panel/FilterPopover";
import CasesTableHeader from "@components/dashboard/cases-table/CasesTableHeader";
import CasesList from "@components/dashboard/cases-table/CasesList";
import { normalizeCaseTypeOptions } from "@utils/support";
import { normalizeCaseTypeOptions, mapSeverityToDisplay } from "@utils/support";

const OUTSTANDING_STATUS_IDS = [1, 10, 18, 1003, 1006] as const;

Expand All @@ -48,6 +49,9 @@ const CasesTable = ({ projectId }: CasesTableProps): JSX.Element => {
isError: isErrorFilters,
} = useGetProjectFilters(projectId);

// Fetch deployments for the deployment filter
const { data: deploymentsData } = useGetDeployments(projectId);

const dynamicFilterFields: FilterField[] = [
{
id: "statusId",
Expand All @@ -65,7 +69,7 @@ const CasesTable = ({ projectId }: CasesTableProps): JSX.Element => {
type: "select",
options:
filtersMetadata?.severities?.map((s) => ({
label: s.label,
label: mapSeverityToDisplay(s.label),
value: s.id,
})) || [],
},
Expand All @@ -84,8 +88,8 @@ const CasesTable = ({ projectId }: CasesTableProps): JSX.Element => {
label: "Deployment",
type: "select",
options:
filtersMetadata?.deploymentTypes?.map((d) => ({
label: d.label,
deploymentsData?.deployments?.map((d) => ({
label: d.type?.label || d.name,
value: d.id,
})) || [],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ import type { SelectChangeEvent } from "@wso2/oxygen-ui";
import type {
CaseMetadataResponse,
AllCasesFilterValues,
ProjectDeploymentItem,
} from "@models/responses";
import { ALL_CASES_FILTER_DEFINITIONS } from "@constants/supportConstants";
import { deriveFilterLabels, mapSeverityToDisplay } from "@utils/support";

export interface AllCasesFiltersProps {
filters: AllCasesFilterValues;
filterMetadata: CaseMetadataResponse | undefined;
deployments?: ProjectDeploymentItem[];
onFilterChange: (field: string, value: string) => void;
}

Expand All @@ -46,6 +48,7 @@ export interface AllCasesFiltersProps {
export default function AllCasesFilters({
filters,
filterMetadata,
deployments,
onFilterChange,
}: AllCasesFiltersProps): JSX.Element {
const handleSelectChange =
Expand All @@ -58,16 +61,25 @@ export default function AllCasesFilters({
<Grid container spacing={2} sx={{ mt: 1 }}>
{ALL_CASES_FILTER_DEFINITIONS.map((def) => {
const { label, allLabel } = deriveFilterLabels(def.id);
const metadataOptions = filterMetadata?.[def.metadataKey];
const options = Array.isArray(metadataOptions)
? metadataOptions.map((item: { label: string; id: string }) => ({
label:
def.metadataKey === "severities"
? mapSeverityToDisplay(item.label)
: item.label,
value: def.useLabelAsValue ? item.label : item.id,

const isDeploymentFilter = def.id === "deployment";
const options = isDeploymentFilter && deployments
? deployments.map((deployment) => ({
label: deployment.type?.label || deployment.name,
value: deployment.id,
}))
: [];
: (() => {
const metadataOptions = filterMetadata?.[def.metadataKey];
return Array.isArray(metadataOptions)
? metadataOptions.map((item: { label: string; id: string }) => ({
label:
def.metadataKey === "severities"
? mapSeverityToDisplay(item.label)
: item.label,
value: def.useLabelAsValue ? item.label : item.id,
}))
: [];
})();

return (
<Grid key={def.id} size={{ xs: 12, sm: 6, md: 3 }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ import {
X,
} from "@wso2/oxygen-ui-icons-react";
import type { JSX, ChangeEvent } from "react";
import type { CaseMetadataResponse } from "@models/responses";
import type {
CaseMetadataResponse,
ProjectDeploymentItem,
} from "@models/responses";
import AllCasesFilters from "@components/support/all-cases/AllCasesFilters";

export interface AllCasesSearchBarProps {
Expand All @@ -45,6 +48,7 @@ export interface AllCasesSearchBarProps {
deploymentId?: string;
};
filterMetadata: CaseMetadataResponse | undefined;
deployments?: ProjectDeploymentItem[];
onFilterChange: (field: string, value: string) => void;
onClearFilters: () => void;
}
Expand All @@ -62,6 +66,7 @@ export default function AllCasesSearchBar({
onFiltersToggle,
filters,
filterMetadata,
deployments,
onFilterChange,
onClearFilters,
}: AllCasesSearchBarProps): JSX.Element {
Expand Down Expand Up @@ -118,6 +123,7 @@ export default function AllCasesSearchBar({
<AllCasesFilters
filters={filters}
filterMetadata={filterMetadata}
deployments={deployments}
onFilterChange={onFilterChange}
/>
</>
Expand Down
1 change: 1 addition & 0 deletions apps/customer-portal/webapp/src/constants/apiConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const ApiQueryKeys = {
CHANGE_REQUESTS: "change-requests",
CHANGE_REQUEST_DETAILS: "change-request-details",
CHANGE_REQUEST_COMMENTS: "change-request-comments",
CHANGE_REQUEST_STATS: "change-request-stats",
CATALOGS_SEARCH: "catalogs-search",
CATALOG_ITEM_VARIABLES: "catalog-item-variables",
} as const;
Expand Down
2 changes: 1 addition & 1 deletion apps/customer-portal/webapp/src/models/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ export interface CreateCaseRequest {
projectId: string;
severityKey?: number;
title: string;
/** Parent case ID when creating a related case from closed state. */
parentCaseId?: string;
conversationId?: string;
}

// Request body for creating a service request (POST /cases with type: "service_request").
Expand Down
10 changes: 10 additions & 0 deletions apps/customer-portal/webapp/src/models/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,16 @@ export interface ChangeRequestStats {
completed: number;
}

// Change Request Stats API Response
export interface ChangeRequestStatsResponse {
totalCount: number;
stateCount: Array<{
id: string;
label: string;
count: number;
}>;
}

// Case Search Response
export interface CaseSearchResponse {
cases: CaseListItem[];
Expand Down
5 changes: 5 additions & 0 deletions apps/customer-portal/webapp/src/pages/AllCasesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { ArrowLeft } from "@wso2/oxygen-ui-icons-react";
import { useGetProjectCasesStats } from "@api/useGetProjectCasesStats";
import useGetProjectFilters from "@api/useGetProjectFilters";
import useGetProjectCases from "@api/useGetProjectCases";
import { useGetDeployments } from "@api/useGetDeployments";
import { getIncidentAndQueryIds } from "@utils/support";
import { CaseType } from "@constants/supportConstants";
import type { AllCasesFilterValues } from "@models/responses";
Expand Down Expand Up @@ -66,6 +67,9 @@ export default function AllCasesPage(): JSX.Element {
// Fetch filter metadata first to get Incident and Query IDs for stats API
const { data: filterMetadata } = useGetProjectFilters(projectId || "");

// Fetch deployments for the deployment filter
const { data: deploymentsData } = useGetDeployments(projectId || "");

const { incidentId, queryId } = useMemo(
() => getIncidentAndQueryIds(filterMetadata?.caseTypes),
[filterMetadata?.caseTypes],
Expand Down Expand Up @@ -228,6 +232,7 @@ export default function AllCasesPage(): JSX.Element {
onFiltersToggle={() => setIsFiltersOpen(!isFiltersOpen)}
filters={filters}
filterMetadata={filterMetadata}
deployments={deploymentsData?.deployments}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
/>
Expand Down
Loading