Browse Source

add autocomplete to searchbox, update report

tags/Baseline_30082024_FRONTEND_UAT
cyril.tsui 1 year ago
parent
commit
dac398cd95
8 changed files with 236 additions and 55 deletions
  1. +6
    -3
      src/app/api/reports/index.ts
  2. +8
    -6
      src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReport.tsx
  3. +24
    -10
      src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReport.tsx
  4. +3
    -2
      src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReportWrapper.tsx
  5. +1
    -1
      src/components/NavigationContent/NavigationContent.tsx
  6. +186
    -32
      src/components/SearchBox/SearchBox.tsx
  7. +7
    -1
      src/i18n/en/report.json
  8. +1
    -0
      src/i18n/zh/report.json

+ 6
- 3
src/app/api/reports/index.ts View File

@@ -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;
}


+ 8
- 6
src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReport.tsx View File

@@ -18,11 +18,14 @@ type SearchParamNames = keyof SearchQuery;

const GenerateProjectCashFlowReport: React.FC<Props> = ({ 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<SearchParamNames>[] = 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<Props> = ({ projects }) => {
<SearchBox
criteria={searchCriteria}
onSearch={async (query) => {

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!!)
}


+ 24
- 10
src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReport.tsx View File

@@ -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<Omit<ProjectPotentialDelayReportFilter, "id">>;
type SearchParamNames = keyof SearchQuery;

const GenerateProjectPotentialDelayReport: React.FC<Props> = ({ teams, clients }) => {
const GenerateProjectPotentialDelayReport: React.FC<Props> = ({ 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<Props> = ({ teams, clients }

const searchCriteria: Criterion<SearchParamNames>[] = 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<Props> = ({ 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<Props> = ({ 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)
})


+ 3
- 2
src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReportWrapper.tsx View File

@@ -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 <GenerateProjectPotentialDelayReport teams={teams} clients={clients}/>;
return <GenerateProjectPotentialDelayReport teams={teams} clients={clients} subsidiaries={subsidiaries}/>;
};

GenerateProjectPotentialDelayReportWrapper.Loading = GenerateProjectPotentialDelayReportLoading;


+ 1
- 1
src/components/NavigationContent/NavigationContent.tsx View File

@@ -164,7 +164,7 @@ const NavigationContent: React.FC<Props> = ({ abilities }) => {
},
{
icon: <Analytics />,
label: "Completion Report",
label: "Project Completion Report",
path: "/analytics/ProjectCompletionReport",
},
// {


+ 186
- 32
src/components/SearchBox/SearchBox.tsx View File

@@ -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<T extends string> {
label: string;
@@ -44,6 +45,20 @@ interface SelectCriterion<T extends string> extends BaseCriterion<T> {
needAll?: boolean;
}

interface AutocompleteOptions {
value: string | number;
label: string;
group?: string;
}

interface AutocompleteCriterion<T extends string> extends BaseCriterion<T> {
type: "autocomplete";
options: AutocompleteOptions[];
multiple?: boolean;
noOptionsText?: string;
needAll?: boolean;
}

interface DateRangeCriterion<T extends string> extends BaseCriterion<T> {
type: "dateRange";
needMonth?: boolean;
@@ -60,6 +75,7 @@ interface NumberCriterion<T extends string> extends BaseCriterion<T> {
export type Criterion<T extends string> =
| TextCriterion<T>
| SelectCriterion<T>
| AutocompleteCriterion<T>
| DateRangeCriterion<T>
| MonthYearCriterion<T>
| NumberCriterion<T>;
@@ -78,6 +94,7 @@ function SearchBox<T extends string>({
formType,
}: Props<T>) {
const { t } = useTranslation("common");
const defaultAll = { value: "All", label: t("All"), group: t("All") }
const defaultInputs = useMemo(
() =>
criteria.reduce<Record<T, string>>(
@@ -85,13 +102,13 @@ function SearchBox<T extends string>({
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<T, string>
@@ -99,7 +116,7 @@ function SearchBox<T extends string>({
[criteria]
);
const [inputs, setInputs] = useState(defaultInputs);
const makeInputChangeHandler = useCallback(
(paramName: T): React.ChangeEventHandler<HTMLInputElement> => {
return (e) => {
@@ -108,7 +125,7 @@ function SearchBox<T extends string>({
},
[]
);
const makeNumberChangeHandler = useCallback(
(paramName: T): (event: FocusEvent<HTMLInputElement, Element> | PointerEvent<Element> | KeyboardEvent<Element>, value: number | null) => void => {
return (event, value) => {
@@ -124,14 +141,26 @@ function SearchBox<T extends string>({
};
}, []);

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<T extends string>({

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<T extends string>({
</Select>
</FormControl>
)}
{c.type === "autocomplete" && c.options.some(option => Boolean(option.group)) && (
<Autocomplete
multiple={Boolean(c.multiple)}
noOptionsText={c.noOptionsText ?? t("No options")}
disableClearable
fullWidth
value={c.multiple ? intersectionWith(c.options, inputs[c.paramName], (option, v) => {
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 (
<Chip
{...chipProps}
key={`${option.value}-${option.label}`}
label={option.label}
/>
);
})
: undefined
}
renderGroup={(params) => (
<React.Fragment key={`${params.key}-${params.group}`}>
<ListSubheader>{params.group}</ListSubheader>
{params.children}
</React.Fragment>
)}
renderOption={(
params: React.HTMLAttributes<HTMLLIElement> & { key?: React.Key },
option,
{ selected },
) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { key, ...rest } = params;
return (
<MenuItem
{...rest}
disableRipple
value={option.value}
key={`${option.value}--${option.label}`}
>
{c.multiple && (
<Checkbox
disableRipple
key={`checkbox-${option.value}`}
checked={selected}
sx={{ transform: "translate(0)" }}
/>
)}
{option.label}
</MenuItem>
);
}}
renderInput={(params) => <TextField {...params} />}
/>
)}
{c.type === "autocomplete" && !c.options.some(option => Boolean(option.group)) && (
<Autocomplete
multiple={Boolean(c.multiple)}
noOptionsText={c.noOptionsText ?? t("No options")}
disableClearable
fullWidth
value={c.multiple ? intersectionWith(c.options, inputs[c.paramName], (option, v) => {
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 (
<Chip
{...chipProps}
key={`${option.value}-${option.label}`}
label={option.label}
/>
);
})
: undefined
}
renderOption={(
params: React.HTMLAttributes<HTMLLIElement> & { key?: React.Key },
option,
{ selected },
) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { key, ...rest } = params;
return (
<MenuItem
{...rest}
disableRipple
value={option.value}
key={`${option.value}--${option.label}`}
>
{c.multiple && (
<Checkbox
disableRipple
key={`checkbox-${option.value}`}
checked={selected}
sx={{ transform: "translate(0)" }}
/>
)}
{option.label}
</MenuItem>
);
}}
renderInput={(params) => <TextField {...params} />}
/>
)}
{c.type === "number" && (
<NumberInput
placeholder={c.label}
<NumberInput
placeholder={c.label}
min={50}
max={99}
onChange={makeNumberChangeHandler(c.paramName)}
@@ -223,15 +377,15 @@ function SearchBox<T extends string>({
<Box display="flex">
<FormControl fullWidth>
<DatePicker
label={c.label}
onChange={makeMonthYearChangeHandler(c.paramName)}
value={
inputs[c.paramName]
? dayjs(inputs[c.paramName])
: dayjs()
}
views={["month","year"]}
/>
label={c.label}
onChange={makeMonthYearChangeHandler(c.paramName)}
value={
inputs[c.paramName]
? dayjs(inputs[c.paramName])
: dayjs()
}
views={["month", "year"]}
/>
</FormControl>
</Box>
</LocalizationProvider>


+ 7
- 1
src/i18n/en/report.json View File

@@ -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"
}

+ 1
- 0
src/i18n/zh/report.json View File

@@ -10,6 +10,7 @@
"Date": "日期",
"Team": "隊伍",
"Client": "客戶",
"Subsidiary": "子公司",
"Status": "狀態",
"Staff": "員工"
}

Loading…
Cancel
Save