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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from "@wso2/oxygen-ui";
import type { SelectChangeEvent } from "@wso2/oxygen-ui";
import ListSearchBar from "@components/list-view/ListSearchBar";
import DateRangeFilter from "@components/list-view/DateRangeFilter";
import ListResultsBar from "@components/list-view/ListResultsBar";
import ListPagination from "@components/list-view/ListPagination";
import ListItems from "@components/list-view/ListItems";
Expand Down Expand Up @@ -79,6 +80,10 @@ export default function EngagementsListSection({
const activeFiltersCount = countListSearchAndFilters(searchTerm, {
statusIds: filters.statusIds,
engagementTypeKey: filters.engagementTypeKey,
startCreatedDate: filters.startCreatedDate,
endCreatedDate: filters.endCreatedDate,
startUpdatedDate: filters.startUpdatedDate,
endUpdatedDate: filters.endUpdatedDate,
});

return (
Expand All @@ -98,7 +103,7 @@ export default function EngagementsListSection({
isLoading={isProjectContextLoading}
actionsBeforeClearFilters={actionsBeforeClearFilters}
filtersContent={
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid container spacing={2} sx={{ mt: 1 }} alignItems="flex-end">
{filterMetadata?.caseStates && (
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl fullWidth size="small">
Expand Down Expand Up @@ -189,6 +194,24 @@ export default function EngagementsListSection({
</FormControl>
</Grid>
)}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<DateRangeFilter
label="Created Date"
startDate={filters.startCreatedDate}
endDate={filters.endCreatedDate}
onStartChange={(val) => onFilterChange("startCreatedDate", val ?? "")}
onEndChange={(val) => onFilterChange("endCreatedDate", val ?? "")}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<DateRangeFilter
label="Updated Date"
startDate={filters.startUpdatedDate}
endDate={filters.endUpdatedDate}
onStartChange={(val) => onFilterChange("startUpdatedDate", val ?? "")}
onEndChange={(val) => onFilterChange("endUpdatedDate", val ?? "")}
/>
</Grid>
</Grid>
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export function buildEngagementSearchRequest(
engagementTypeKeys: filters.engagementTypeKey?.length
? filters.engagementTypeKey.flatMap((k) => k.split(",").map(Number).filter(Boolean))
: undefined,
startCreatedDate: filters.startCreatedDate,
endCreatedDate: filters.endCreatedDate,
startUpdatedDate: filters.startUpdatedDate,
endUpdatedDate: filters.endUpdatedDate,
},
sortBy: {
field: normalizedSortField,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,10 @@ export function buildServiceRequestsPageCaseSearchRequest(
: undefined,
searchQuery: searchTerm.trim() || undefined,
createdByMe: createdByMe || undefined,
startCreatedDate: filters.startCreatedDate,
endCreatedDate: filters.endCreatedDate,
startUpdatedDate: filters.startUpdatedDate,
endUpdatedDate: filters.endUpdatedDate,
},
sortBy: {
field: normalizedSortField,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import TimeTrackingCard from "@time-tracking/TimeTrackingCard";
import TimeTrackingCardSkeleton from "@time-tracking/TimeTrackingCardSkeleton";
import TimeTrackingErrorState from "@time-tracking/TimeTrackingErrorState";
import EmptyState from "@components/empty-state/EmptyState";
import TimeCardsCsvExportButton from "@time-tracking/TimeCardsCsvExportButton";

import type { ProjectTimeTrackingProps } from "@features/project-details/types/projectDetailsComponents";
import { paginateList } from "@features/project-details/utils/timeTrackingPage";
Expand Down Expand Up @@ -119,10 +120,22 @@ export default function ProjectTimeTracking({
/>
</Box>

<Box sx={{ mb: 2 }}>
<Box sx={{ mb: 2, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="body2" color="text.secondary">
Showing {paginatedTimeCards.length} of {totalItems} time cards
</Typography>
<TimeCardsCsvExportButton
projectId={projectId}
projectName={project?.name}
filters={{
...(startDate && { startDate }),
...(endDate && { endDate }),
states: ["Approved"],
}}
prefetchedCards={allTimeCards}
totalRecords={totalItems}
disabled={isTimeCardsLoading || isTimeCardsError || totalItems === 0}
/>
</Box>

{isTimeCardsError ? (
Expand Down
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 { Button, CircularProgress, Menu, MenuItem } from "@wso2/oxygen-ui";
import { ChevronDown, Download } from "@wso2/oxygen-ui-icons-react";
import { useCallback, useRef, useState, type JSX, type MouseEvent } from "react";
import { useAuthApiClient } from "@hooks/useAuthApiClient";
import { useErrorBanner } from "@context/error-banner/ErrorBannerContext";
import { fetchAllCaseTimeCards } from "@features/usage-metrics/api/fetchAllCaseTimeCards";
import { downloadTimeCardsCsv, downloadTimeCardsPdf } from "@features/project-details/utils/timeCardsCsvExport";
import type { CaseTimeCard } from "@features/usage-metrics/types/timeTracking";
import type { TimeCardSearchRequest } from "@features/usage-metrics/types/timeTracking";

type ExportFormat = "csv" | "pdf";

export type TimeCardsCsvExportButtonProps = {
projectId: string;
projectName?: string;
filters: TimeCardSearchRequest["filters"];
prefetchedCards?: CaseTimeCard[];
totalRecords?: number;
disabled?: boolean;
};

/**
* Button that exports the current time card list as CSV or PDF.
*
* @param {TimeCardsCsvExportButtonProps} props - Export configuration.
* @returns {JSX.Element} Export button with CSV / PDF options.
*/
export default function TimeCardsCsvExportButton({
projectId,
projectName,
filters,
prefetchedCards = [],
totalRecords = 0,
disabled = false,
}: TimeCardsCsvExportButtonProps): JSX.Element {
const authFetch = useAuthApiClient();
const { showError } = useErrorBanner();
const [exportingFormat, setExportingFormat] = useState<ExportFormat | null>(null);
const isExportingRef = useRef(false);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);

const isExporting = exportingFormat !== null;
const filenamePrefix = projectName?.trim()
? projectName.trim().replace(/\s+/g, "-").toLowerCase()
: projectId;

const handleOpen = (event: MouseEvent<HTMLElement>) => {
if (!isExporting) setAnchorEl(event.currentTarget);
};

const handleClose = () => setAnchorEl(null);

const handleExport = useCallback(
async (format: ExportFormat) => {
if (!projectId || isExportingRef.current) return;
handleClose();
isExportingRef.current = true;
setExportingFormat(format);
try {
const needsFullFetch = totalRecords > 0 && prefetchedCards.length < totalRecords;
const cards =
needsFullFetch || prefetchedCards.length === 0
? await fetchAllCaseTimeCards(authFetch, projectId, filters)
: prefetchedCards;

if (cards.length === 0) {
showError("No time cards to export for the current filters.");
return;
}

if (format === "csv") {
downloadTimeCardsCsv(cards, filenamePrefix);
} else {
downloadTimeCardsPdf(cards, filenamePrefix, projectName);
}
} catch (error) {
showError(error instanceof Error ? error.message : "Failed to export time cards.");
} finally {
isExportingRef.current = false;
setExportingFormat(null);
}
},
[authFetch, filters, filenamePrefix, prefetchedCards, projectId, projectName, showError, totalRecords],
);

return (
<>
<Button
type="button"
variant="outlined"
size="small"
onClick={handleOpen}
disabled={disabled || isExporting || !projectId}
startIcon={
isExporting ? (
<CircularProgress size={16} color="inherit" />
) : (
<Download size={16} />
)
}
endIcon={<ChevronDown size={16} />}
>
{isExporting ? "Exporting..." : "Export"}
</Button>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleClose}>
<MenuItem onClick={() => void handleExport("csv")}>Export to CSV</MenuItem>
<MenuItem onClick={() => void handleExport("pdf")}>Export to PDF</MenuItem>
</Menu>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// 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 { CaseTimeCard } from "@features/usage-metrics/types/timeTracking";
import { formatMinutesAsHrMin } from "@features/project-details/utils/projectDetails";
import { buildCsvContent, downloadCsvFile } from "@utils/csv";
import { downloadPdfFile } from "@utils/pdf";

export const TIME_CARDS_CSV_HEADERS = [
"Case Number",
"Case Title",
"Billable Hours",
"Non-billable Hours",
"Total Hours",
] as const;

function buildTimeCardRows(cards: CaseTimeCard[]): string[][] {
const rows = cards.map((card) => [
card.case.number,
card.case.name,
formatMinutesAsHrMin(card.billable.totalTime),
formatMinutesAsHrMin(card.nonBillable.totalTime),
formatMinutesAsHrMin(card.totalTime),
]);

const totalBillable = cards.reduce((sum, c) => sum + c.billable.totalTime, 0);
const totalNonBillable = cards.reduce((sum, c) => sum + c.nonBillable.totalTime, 0);
const totalAll = cards.reduce((sum, c) => sum + c.totalTime, 0);

rows.push([
"Total",
"",
formatMinutesAsHrMin(totalBillable),
formatMinutesAsHrMin(totalNonBillable),
formatMinutesAsHrMin(totalAll),
]);

return rows;
}

function buildFilename(prefix: string, ext: "csv" | "pdf"): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return `${prefix}-time-cards-${y}-${m}-${d}.${ext}`;
}

/**
* Builds and downloads a CSV file of case time cards.
*/
export function downloadTimeCardsCsv(
cards: CaseTimeCard[],
filenamePrefix: string,
): void {
const rows = buildTimeCardRows(cards);
const content = buildCsvContent([...TIME_CARDS_CSV_HEADERS], rows);
downloadCsvFile(buildFilename(filenamePrefix, "csv"), content);
}

/**
* Builds and downloads a PDF file of case time cards.
*/
export function downloadTimeCardsPdf(
cards: CaseTimeCard[],
filenamePrefix: string,
projectName?: string,
): void {
const rows = buildTimeCardRows(cards);
const title = projectName ? `Time Tracking — ${projectName}` : "Time Tracking";
downloadPdfFile(
buildFilename(filenamePrefix, "pdf"),
title,
[...TIME_CARDS_CSV_HEADERS],
rows,
{
0: { cellWidth: 30 },
1: { cellWidth: 80 },
2: { cellWidth: 35, halign: "right" },
3: { cellWidth: 40, halign: "right" },
4: { cellWidth: 30, halign: "right" },
},
);
}
Loading