| @@ -0,0 +1,29 @@ | |||
| import { Metadata } from "next"; | |||
| import { Suspense } from "react"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import { fetchProjects } from "@/app/api/projects"; | |||
| import GenerateCrossTeamChargeReport from "@/components/GenerateCrossTeamChargeReport"; | |||
| import { Typography } from "@mui/material"; | |||
| export const metadata: Metadata = { | |||
| title: "Cross Team Charge Report", | |||
| }; | |||
| const CrossTeamChargeReport: React.FC = async () => { | |||
| const { t } = await getServerI18n("reports"); | |||
| return ( | |||
| <> | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Cross Team Charge Report")} | |||
| </Typography> | |||
| <I18nProvider namespaces={["report", "common"]}> | |||
| <Suspense fallback={<GenerateCrossTeamChargeReport.Loading />}> | |||
| <GenerateCrossTeamChargeReport /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default CrossTeamChargeReport; | |||
| @@ -1,7 +1,7 @@ | |||
| "use server"; | |||
| import { serverFetchBlob } from "@/app/utils/fetchUtil"; | |||
| import { MonthlyWorkHoursReportRequest, ProjectCashFlowReportRequest, LateStartReportRequest, ProjectResourceOverconsumptionReportRequest, ProjectPandLReportRequest, ProjectCompletionReportRequest, ProjectPotentialDelayReportRequest, CostAndExpenseReportRequest } from "."; | |||
| import { MonthlyWorkHoursReportRequest, ProjectCashFlowReportRequest, LateStartReportRequest, ProjectResourceOverconsumptionReportRequest, ProjectPandLReportRequest, ProjectCompletionReportRequest, ProjectPotentialDelayReportRequest, CostAndExpenseReportRequest, CrossTeamChargeReportRequest } from "."; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| export interface FileResponse { | |||
| @@ -123,3 +123,15 @@ export const fetchCostAndExpenseReport = async (data: CostAndExpenseReportReques | |||
| return reportBlob | |||
| }; | |||
| export const fetchCrossTeamChargeReport = async (data: CrossTeamChargeReportRequest) => { | |||
| const reportBlob = await serverFetchBlob<FileResponse>( | |||
| `${BASE_API_URL}/reports/CrossTeamChargeReport`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }, | |||
| ); | |||
| return reportBlob | |||
| }; | |||
| @@ -115,3 +115,12 @@ export interface CostAndExpenseReportRequest { | |||
| budgetPercentage: number | null; | |||
| type: string; | |||
| } | |||
| // - Cross Team Charge Report | |||
| export interface CrossTeamChargeReportFilter { | |||
| month: string; | |||
| } | |||
| export interface CrossTeamChargeReportRequest { | |||
| month: string; | |||
| } | |||
| @@ -72,6 +72,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||
| "/analytics/FinancialStatusReport": "Financial Status Report", | |||
| "/analytics/ProjectCashFlowReport": "Project Cash Flow Report", | |||
| "/analytics/StaffMonthlyWorkHoursAnalysisReport": "Staff Monthly Work Hours Analysis Report", | |||
| "/analytics/CrossTeamChargeReport": "Cross Team Charge Report", | |||
| "/invoice": "Invoice", | |||
| }; | |||
| @@ -0,0 +1,51 @@ | |||
| "use client"; | |||
| import React, { useMemo } from "react"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { CrossTeamChargeReportFilter } from "@/app/api/reports"; | |||
| import { fetchCrossTeamChargeReport } from "@/app/api/reports/actions"; | |||
| import { downloadFile } from "@/app/utils/commonUtil"; | |||
| interface Props { | |||
| } | |||
| type SearchQuery = Partial<Omit<CrossTeamChargeReportFilter, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const GenerateCrossTeamChargeReport: React.FC<Props> = () => { | |||
| const { t } = useTranslation("report"); | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| { | |||
| label: t("Month"), | |||
| paramName: "month", | |||
| type: "monthYear", | |||
| }, | |||
| ], | |||
| [t], | |||
| ); | |||
| return ( | |||
| <> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={async (query) => { | |||
| console.log(query.month) | |||
| if (Boolean(query.month)) { | |||
| // const projectIndex = projectCombo.findIndex(({value}) => value === parseInt(query.project)) | |||
| const response = await fetchCrossTeamChargeReport({ month: query.month }) | |||
| if (response) { | |||
| downloadFile(new Uint8Array(response.blobValue), response.filename!!) | |||
| } | |||
| } | |||
| }} | |||
| formType={"download"} | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default GenerateCrossTeamChargeReport; | |||
| @@ -0,0 +1,38 @@ | |||
| import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import Skeleton from "@mui/material/Skeleton"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import React from "react"; | |||
| // Can make this nicer | |||
| export const GenerateProjectCashFlowReportLoading: React.FC = () => { | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton | |||
| variant="rounded" | |||
| height={50} | |||
| width={100} | |||
| sx={{ alignSelf: "flex-end" }} | |||
| /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| </> | |||
| ); | |||
| }; | |||
| export default GenerateProjectCashFlowReportLoading; | |||
| @@ -0,0 +1,15 @@ | |||
| import React from "react"; | |||
| import GenerateCrossTeamChargeReportLoading from "./GenerateCrossTeamChargeReportLoading"; | |||
| import GenerateCrossTeamChargeReport from "./GenerateCrossTeamChargeReport"; | |||
| interface SubComponents { | |||
| Loading: typeof GenerateCrossTeamChargeReportLoading; | |||
| } | |||
| const GenerateCrossTeamChargeReportWrapper: React.FC & SubComponents = async () => { | |||
| return <GenerateCrossTeamChargeReport/>; | |||
| }; | |||
| GenerateCrossTeamChargeReportWrapper.Loading = GenerateCrossTeamChargeReportLoading; | |||
| export default GenerateCrossTeamChargeReportWrapper; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./GenerateCrossTeamChargeReportWrapper"; | |||
| @@ -230,6 +230,11 @@ const NavigationContent: React.FC<Props> = ({ abilities, username }) => { | |||
| label: "Staff Monthly Work Hours Analysis Report", | |||
| path: "/analytics/StaffMonthlyWorkHoursAnalysisReport", | |||
| }, | |||
| { | |||
| icon: <Analytics />, | |||
| label: "Cross Team Charge Report", | |||
| path: "/analytics/CrossTeamChargeReport", | |||
| }, | |||
| ], | |||
| }, | |||
| { | |||
| @@ -99,16 +99,30 @@ function SearchBox<T extends string>({ | |||
| () => | |||
| criteria.reduce<Record<T, string>>( | |||
| (acc, c) => { | |||
| let defaultValue: string | number = "" | |||
| switch (c.type) { | |||
| case "select": | |||
| if (!(c.needAll === false)) { | |||
| defaultValue = "All" | |||
| } else if (c.options.length > 0) { | |||
| defaultValue = c.options[0] | |||
| } | |||
| break; | |||
| case "autocomplete": | |||
| if (!(c.needAll === false)) { | |||
| defaultValue = "All" | |||
| } else if (c.options.length > 0) { | |||
| defaultValue = c.options[0].value | |||
| } | |||
| break; | |||
| case "monthYear": | |||
| defaultValue = dayjs().format("YYYY-MM") | |||
| break; | |||
| } | |||
| return { | |||
| ...acc, | |||
| [c.paramName]: | |||
| c.type === "select" || c.type === "autocomplete" | |||
| ? !(c.needAll === false) | |||
| ? "All" | |||
| : c.options.length > 0 | |||
| ? c.type === "autocomplete" ? c.options[0].value : c.options[0] | |||
| : "" | |||
| : "" | |||
| [c.paramName]: defaultValue | |||
| }; | |||
| }, | |||
| {} as Record<T, string> | |||
| @@ -297,7 +311,7 @@ function SearchBox<T extends string>({ | |||
| </MenuItem> | |||
| ); | |||
| }} | |||
| renderInput={(params) => <TextField {...params} variant="outlined" label={c.label}/>} | |||
| renderInput={(params) => <TextField {...params} variant="outlined" label={c.label} />} | |||
| /> | |||
| )} | |||
| {c.type === "autocomplete" && !c.options.some(option => Boolean(option.group)) && ( | |||
| @@ -356,7 +370,7 @@ function SearchBox<T extends string>({ | |||
| </MenuItem> | |||
| ); | |||
| }} | |||
| renderInput={(params) => <TextField {...params} variant="outlined" label={c.label}/>} | |||
| renderInput={(params) => <TextField {...params} variant="outlined" label={c.label} />} | |||
| /> | |||
| )} | |||
| {c.type === "number" && ( | |||