diff --git a/src/app/api/reports/index.ts b/src/app/api/reports/index.ts index 32e9cee..93793eb 100644 --- a/src/app/api/reports/index.ts +++ b/src/app/api/reports/index.ts @@ -19,7 +19,10 @@ export interface ProjectPandLReportRequest { // - Project Cash Flow Report export interface ProjectCashFlowReportFilter { - project: string[]; + project: { + value: number; + label: string; + }[]; dateType: string[]; } @@ -30,8 +33,8 @@ export interface ProjectCashFlowReportRequest { // - Project Potential Delay Report export interface ProjectPotentialDelayReportFilter { - team: string[]; - client: string[]; + team: string; + client: string; numberOfDays: number; projectCompletion: number; } diff --git a/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReport.tsx b/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReport.tsx index a1554b7..bdb4b23 100644 --- a/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReport.tsx +++ b/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReport.tsx @@ -18,11 +18,14 @@ type SearchParamNames = keyof SearchQuery; const GenerateProjectCashFlowReport: React.FC = ({ projects }) => { const { t } = useTranslation("report"); - const projectCombo = projects.map(project => `${project.code} - ${project.name}`) + const projectCombo = projects.map(project => ({ + value: project.id, + label: `${project.code} - ${project.name}` + })) const searchCriteria: Criterion[] = useMemo( () => [ - { label: t("Project"), paramName: "project", type: "select", options: projectCombo, needAll: false}, + { label: t("Project"), paramName: "project", type: "autocomplete", options: projectCombo, needAll: false}, { label: t("Date Type"), paramName: "dateType", type: "select", options: dateTypeCombo, needAll: false}, ], [t], @@ -33,10 +36,9 @@ const GenerateProjectCashFlowReport: React.FC = ({ projects }) => { { - - if (query.project.length > 0 && query.project.toLocaleLowerCase() !== "all") { - const projectIndex = projectCombo.findIndex(project => project === query.project) - const response = await fetchProjectCashFlowReport({ projectId: projects[projectIndex].id, dateType: query.dateType }) + if (Boolean(query.project) && query.project !== "All") { + // const projectIndex = projectCombo.findIndex(({value}) => value === parseInt(query.project)) + const response = await fetchProjectCashFlowReport({ projectId: parseInt(query.project), dateType: query.dateType }) if (response) { downloadFile(new Uint8Array(response.blobValue), response.filename!!) } diff --git a/src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReport.tsx b/src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReport.tsx index faaa66f..d955ae9 100644 --- a/src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReport.tsx +++ b/src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReport.tsx @@ -8,19 +8,35 @@ import { fetchProjectPotentialDelayReport } from "@/app/api/reports/actions"; import { downloadFile } from "@/app/utils/commonUtil"; import { TeamResult } from "@/app/api/team"; import { Customer } from "@/app/api/customer"; +import { Subsidiary } from "@/app/api/subsidiary"; interface Props { teams: TeamResult[]; clients: Customer[]; + subsidiaries: Subsidiary[]; } type SearchQuery = Partial>; type SearchParamNames = keyof SearchQuery; -const GenerateProjectPotentialDelayReport: React.FC = ({ teams, clients }) => { +const GenerateProjectPotentialDelayReport: React.FC = ({ teams, clients, subsidiaries }) => { const { t } = useTranslation("report"); - const teamCombo = teams.map(team => `${team.code} - ${team.name}`) - const clientCombo = clients.map(client => `${client.code} - ${client.name}`) + const teamCombo = teams.map(team => ({ + value: team.id, + label: `${team.code} - ${team.name}`, + })) + const clientCombo = clients.map(client => ({ + value: `client: ${client.id}` , + label: `${client.code} - ${client.name}`, + group: t("Client") + })) + + const subsidiaryCombo = subsidiaries.map(subsidiary => ({ + value: `subsidiary: ${subsidiary.id}`, + label: `${subsidiary.code} - ${subsidiary.name}`, + group: t("Subsidiary") + })) + const [errors, setErrors] = React.useState({ numberOfDays: false, projectCompletion: false, @@ -28,8 +44,8 @@ const GenerateProjectPotentialDelayReport: React.FC = ({ teams, clients } const searchCriteria: Criterion[] = useMemo( () => [ - { label: t("Team"), paramName: "team", type: "select", options: teamCombo }, - { label: t("Client"), paramName: "client", type: "select", options: clientCombo }, + { label: t("Team"), paramName: "team", type: "autocomplete", options: teamCombo }, + { label: t("Client"), paramName: "client", type: "autocomplete", options: [...subsidiaryCombo, ...clientCombo] }, { label: t("Number Of Days"), paramName: "numberOfDays", type: "text", textType: "number", error: errors.numberOfDays, helperText: t("Can not be null and decimal, and should be >= 0") }, { label: t("Project Completion (<= %)"), paramName: "projectCompletion", type: "text", textType: "number", error: errors.projectCompletion, helperText: t("Can not be null and decimal, and should be in range of 0 - 100") }, ], @@ -42,6 +58,7 @@ const GenerateProjectPotentialDelayReport: React.FC = ({ teams, clients } criteria={searchCriteria} onSearch={async (query) => { + console.log(query) let hasError = false if (query.numberOfDays.length === 0 || !Number.isInteger(parseFloat(query.numberOfDays)) || parseInt(query.numberOfDays) < 0) { setErrors((prev) => ({...prev, numberOfDays: true})) @@ -59,12 +76,9 @@ const GenerateProjectPotentialDelayReport: React.FC = ({ teams, clients } if (hasError) return false - const teamIndex = teamCombo.findIndex(team => team === query.team) - const clientIndex = clientCombo.findIndex(client => client === query.client) - const response = await fetchProjectPotentialDelayReport({ - teamId: teams[teamIndex]?.id ?? "All", - clientId: clients[clientIndex]?.id ?? "All", + teamId: typeof query.team === "number" ? query.team : "All", + clientId: typeof query.client === "number" ? query.client : "All", numberOfDays: parseInt(query.numberOfDays), projectCompletion: parseInt(query.projectCompletion) }) diff --git a/src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReportWrapper.tsx b/src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReportWrapper.tsx index 51316c6..ea62e6e 100644 --- a/src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReportWrapper.tsx +++ b/src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReportWrapper.tsx @@ -3,15 +3,16 @@ import GenerateProjectPotentialDelayReportLoading from "./GenerateProjectPotenti import GenerateProjectPotentialDelayReport from "./GenerateProjectPotentialDelayReport"; import { fetchTeam } from "@/app/api/team"; import { fetchAllCustomers } from "@/app/api/customer"; +import { fetchAllSubsidiaries } from "@/app/api/subsidiary"; interface SubComponents { Loading: typeof GenerateProjectPotentialDelayReportLoading; } const GenerateProjectPotentialDelayReportWrapper: React.FC & SubComponents = async () => { - const [teams, clients] = await Promise.all([fetchTeam(), fetchAllCustomers()]) + const [teams, clients, subsidiaries] = await Promise.all([fetchTeam(), fetchAllCustomers(), fetchAllSubsidiaries()]) - return ; + return ; }; GenerateProjectPotentialDelayReportWrapper.Loading = GenerateProjectPotentialDelayReportLoading; diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index ef56e26..f033048 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -164,7 +164,7 @@ const NavigationContent: React.FC = ({ abilities }) => { }, { icon: , - label: "Completion Report", + label: "Project Completion Report", path: "/analytics/ProjectCompletionReport", }, // { diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index e3cf5e8..6418932 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -4,7 +4,7 @@ import Grid from "@mui/material/Grid"; import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import Typography from "@mui/material/Typography"; -import React, { FocusEvent, KeyboardEvent, PointerEvent, useCallback, useMemo, useState } from "react"; +import React, { FocusEvent, KeyboardEvent, PointerEvent, SyntheticEvent, useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import TextField from "@mui/material/TextField"; import FormControl from "@mui/material/FormControl"; @@ -21,8 +21,9 @@ import "dayjs/locale/zh-hk"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; -import { Box, FormHelperText } from "@mui/material"; +import { Autocomplete, Box, Checkbox, Chip, FormHelperText, ListSubheader } from "@mui/material"; import { InputAdornment, NumberInput } from "../utils/numberInput"; +import { intersectionWith } from "lodash"; interface BaseCriterion { label: string; @@ -44,6 +45,20 @@ interface SelectCriterion extends BaseCriterion { needAll?: boolean; } +interface AutocompleteOptions { + value: string | number; + label: string; + group?: string; +} + +interface AutocompleteCriterion extends BaseCriterion { + type: "autocomplete"; + options: AutocompleteOptions[]; + multiple?: boolean; + noOptionsText?: string; + needAll?: boolean; +} + interface DateRangeCriterion extends BaseCriterion { type: "dateRange"; needMonth?: boolean; @@ -60,6 +75,7 @@ interface NumberCriterion extends BaseCriterion { export type Criterion = | TextCriterion | SelectCriterion + | AutocompleteCriterion | DateRangeCriterion | MonthYearCriterion | NumberCriterion; @@ -78,6 +94,7 @@ function SearchBox({ formType, }: Props) { const { t } = useTranslation("common"); + const defaultAll = { value: "All", label: t("All"), group: t("All") } const defaultInputs = useMemo( () => criteria.reduce>( @@ -85,13 +102,13 @@ function SearchBox({ return { ...acc, [c.paramName]: - c.type === "select" + c.type === "select" || c.type === "autocomplete" ? !(c.needAll === false) ? "All" : c.options.length > 0 - ? c.options[0] + ? c.type === "autocomplete" ? c.options[0].value : c.options[0] : "" - : "", + : "" }; }, {} as Record @@ -99,7 +116,7 @@ function SearchBox({ [criteria] ); const [inputs, setInputs] = useState(defaultInputs); - + const makeInputChangeHandler = useCallback( (paramName: T): React.ChangeEventHandler => { return (e) => { @@ -108,7 +125,7 @@ function SearchBox({ }, [] ); - + const makeNumberChangeHandler = useCallback( (paramName: T): (event: FocusEvent | PointerEvent | KeyboardEvent, value: number | null) => void => { return (event, value) => { @@ -124,14 +141,26 @@ function SearchBox({ }; }, []); + const makeAutocompleteChangeHandler = useCallback((paramName: T, multiple: boolean) => { + return (e: SyntheticEvent, newValue: AutocompleteOptions | AutocompleteOptions[]) => { + if (multiple) { + const multiNewValue = newValue as AutocompleteOptions[]; + setInputs((i) => ({ ...i, [paramName]: multiNewValue.map(({ value }) => value) })); + } else { + const singleNewVal = newValue as AutocompleteOptions; + setInputs((i) => ({ ...i, [paramName]: singleNewVal.value })); + } + }; + }, []); + const makeDateChangeHandler = useCallback((paramName: T, needMonth?: boolean) => { return (e: any) => { - if(needMonth){ + if (needMonth) { setInputs((i) => ({ ...i, [paramName]: dayjs(e).format("YYYY-MM") })); - }else{ + } else { setInputs((i) => ({ ...i, [paramName]: dayjs(e).format("YYYY-MM-DD") })); } - + }; }, []); @@ -144,17 +173,17 @@ function SearchBox({ const makeDateToChangeHandler = useCallback((paramName: T, needMonth?: boolean) => { return (e: any) => { - if(needMonth){ - setInputs((i) => ({ - ...i, - [paramName + "To"]: dayjs(e).format("YYYY-MM"), - })); - }else{ - setInputs((i) => ({ - ...i, - [paramName + "To"]: dayjs(e).format("YYYY-MM-DD"), - })); - } + if (needMonth) { + setInputs((i) => ({ + ...i, + [paramName + "To"]: dayjs(e).format("YYYY-MM"), + })); + } else { + setInputs((i) => ({ + ...i, + [paramName + "To"]: dayjs(e).format("YYYY-MM-DD"), + })); + } }; }, []); @@ -205,9 +234,134 @@ function SearchBox({ )} + {c.type === "autocomplete" && c.options.some(option => Boolean(option.group)) && ( + { + return option.value === (v ?? "") + }) + : c.options.find((option) => option.value === inputs[c.paramName]) ?? defaultAll} + onChange={makeAutocompleteChangeHandler(c.paramName, Boolean(c.multiple))} + groupBy={(option) => option.group!!} + getOptionLabel={(option) => option.label} + options={!(c.needAll === false) ? [defaultAll, ...c.options] : c.options} + disableCloseOnSelect={Boolean(c.multiple)} + renderTags={ + c.multiple + ? (value, getTagProps) => + value.map((option, index) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { key, ...chipProps } = getTagProps({ index }); + return ( + + ); + }) + : undefined + } + renderGroup={(params) => ( + + {params.group} + {params.children} + + )} + renderOption={( + params: React.HTMLAttributes & { key?: React.Key }, + option, + { selected }, + ) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { key, ...rest } = params; + return ( + + {c.multiple && ( + + )} + {option.label} + + ); + }} + renderInput={(params) => } + /> + )} + {c.type === "autocomplete" && !c.options.some(option => Boolean(option.group)) && ( + { + return option.value === (v ?? "") + }) + : c.options.find((option) => option.value === inputs[c.paramName]) ?? defaultAll} + onChange={makeAutocompleteChangeHandler(c.paramName, Boolean(c.multiple))} + getOptionLabel={(option) => option.label} + options={!Boolean(c.multiple) && !(c.needAll === false) ? [defaultAll, ...c.options] : c.options} + disableCloseOnSelect={Boolean(c.multiple)} + renderTags={ + c.multiple + ? (value, getTagProps) => + value.map((option, index) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { key, ...chipProps } = getTagProps({ index }); + return ( + + ); + }) + : undefined + } + renderOption={( + params: React.HTMLAttributes & { key?: React.Key }, + option, + { selected }, + ) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { key, ...rest } = params; + return ( + + {c.multiple && ( + + )} + {option.label} + + ); + }} + renderInput={(params) => } + /> + )} {c.type === "number" && ( - ({ + label={c.label} + onChange={makeMonthYearChangeHandler(c.paramName)} + value={ + inputs[c.paramName] + ? dayjs(inputs[c.paramName]) + : dayjs() + } + views={["month", "year"]} + /> diff --git a/src/i18n/en/report.json b/src/i18n/en/report.json index df2a3ae..6a2e756 100644 --- a/src/i18n/en/report.json +++ b/src/i18n/en/report.json @@ -3,5 +3,11 @@ "Project Completion (<= %)": "Project Completion (<= %)", "Project": "Project", - "Date Type": "Date Type" + "Date Type": "Date Type", + "Date": "Date", + "Team": "Team", + "Client": "Client", + "Subsidiary": "Subsidiary", + "Status": "Status", + "Staff": "Staff" } \ No newline at end of file diff --git a/src/i18n/zh/report.json b/src/i18n/zh/report.json index d85d97b..f15cc8a 100644 --- a/src/i18n/zh/report.json +++ b/src/i18n/zh/report.json @@ -10,6 +10,7 @@ "Date": "日期", "Team": "隊伍", "Client": "客戶", + "Subsidiary": "子公司", "Status": "狀態", "Staff": "員工" } \ No newline at end of file