@@ -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"; | "use server"; | ||||
import { serverFetchBlob } from "@/app/utils/fetchUtil"; | 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"; | import { BASE_API_URL } from "@/config/api"; | ||||
export interface FileResponse { | export interface FileResponse { | ||||
@@ -123,3 +123,15 @@ export const fetchCostAndExpenseReport = async (data: CostAndExpenseReportReques | |||||
return reportBlob | 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; | budgetPercentage: number | null; | ||||
type: string; | 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/FinancialStatusReport": "Financial Status Report", | ||||
"/analytics/ProjectCashFlowReport": "Project Cash Flow Report", | "/analytics/ProjectCashFlowReport": "Project Cash Flow Report", | ||||
"/analytics/StaffMonthlyWorkHoursAnalysisReport": "Staff Monthly Work Hours Analysis Report", | "/analytics/StaffMonthlyWorkHoursAnalysisReport": "Staff Monthly Work Hours Analysis Report", | ||||
"/analytics/CrossTeamChargeReport": "Cross Team Charge Report", | |||||
"/invoice": "Invoice", | "/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", | label: "Staff Monthly Work Hours Analysis Report", | ||||
path: "/analytics/StaffMonthlyWorkHoursAnalysisReport", | 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>>( | criteria.reduce<Record<T, string>>( | ||||
(acc, c) => { | (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 { | return { | ||||
...acc, | ...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> | {} as Record<T, string> | ||||
@@ -297,7 +311,7 @@ function SearchBox<T extends string>({ | |||||
</MenuItem> | </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)) && ( | {c.type === "autocomplete" && !c.options.some(option => Boolean(option.group)) && ( | ||||
@@ -356,7 +370,7 @@ function SearchBox<T extends string>({ | |||||
</MenuItem> | </MenuItem> | ||||
); | ); | ||||
}} | }} | ||||
renderInput={(params) => <TextField {...params} variant="outlined" label={c.label}/>} | |||||
renderInput={(params) => <TextField {...params} variant="outlined" label={c.label} />} | |||||
/> | /> | ||||
)} | )} | ||||
{c.type === "number" && ( | {c.type === "number" && ( | ||||