Parcourir la source

Add resource capacity and small fixes

tags/Baseline_30082024_FRONTEND_UAT
Wayne il y a 1 an
Parent
révision
7c709d28b3
11 fichiers modifiés avec 439 ajouts et 148 suppressions
  1. +1
    -1
      src/app/api/tasks/index.ts
  2. +9
    -0
      src/app/utils/formatUtil.ts
  3. +7
    -2
      src/components/CreateProject/CreateProject.tsx
  4. +10
    -0
      src/components/CreateProject/CreateProjectWrapper.tsx
  5. +149
    -0
      src/components/CreateProject/ResourceCapacity.tsx
  6. +117
    -12
      src/components/CreateProject/ResourceMilestone.tsx
  7. +84
    -80
      src/components/CreateProject/StaffAllocation.tsx
  8. +5
    -5
      src/components/CreateProject/TaskSetup.tsx
  9. +1
    -1
      src/components/CreateProject/index.ts
  10. +49
    -47
      src/components/SearchBox/SearchBox.tsx
  11. +7
    -0
      src/theme/devias-material-kit/components.ts

+ 1
- 1
src/app/api/tasks/index.ts Voir le fichier

@@ -12,7 +12,7 @@ export interface Task {
id: number;
name: string;
description: string | null;
taskGroup: TaskGroup | null;
taskGroup: TaskGroup;
}

export interface TaskTemplate {


+ 9
- 0
src/app/utils/formatUtil.ts Voir le fichier

@@ -0,0 +1,9 @@
export const manhourFormatter = new Intl.NumberFormat("en-HK", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});

export const moneyFormatter = new Intl.NumberFormat("en-HK", {
style: "currency",
currency: "HKD",
});

+ 7
- 2
src/components/CreateProject/CreateProject.tsx Voir le fichier

@@ -13,8 +13,13 @@ import ProjectClientDetails from "./ProjectClientDetails";
import TaskSetup from "./TaskSetup";
import StaffAllocation from "./StaffAllocation";
import ResourceMilestone from "./ResourceMilestone";
import { Task } from "@/app/api/tasks";

const CreateProject: React.FC = () => {
export interface Props {
mockTasks: Task[];
}

const CreateProject: React.FC<Props> = ({ mockTasks }) => {
const [tabIndex, setTabIndex] = useState(0);
const { t } = useTranslation();
const router = useRouter();
@@ -41,7 +46,7 @@ const CreateProject: React.FC = () => {
{tabIndex === 0 && <ProjectClientDetails />}
{tabIndex === 1 && <TaskSetup />}
{tabIndex === 2 && <StaffAllocation initiallySelectedStaff={[]} />}
{tabIndex === 3 && <ResourceMilestone />}
{tabIndex === 3 && <ResourceMilestone tasks={mockTasks} />}
<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button variant="outlined" startIcon={<Close />} onClick={handleCancel}>
{t("Cancel")}


+ 10
- 0
src/components/CreateProject/CreateProjectWrapper.tsx Voir le fichier

@@ -0,0 +1,10 @@
import { fetchAllTasks } from "@/app/api/tasks";
import CreateProject from "./CreateProject";

const CreateProjectWrapper: React.FC = async () => {
const tasks = await fetchAllTasks();

return <CreateProject mockTasks={tasks} />;
};

export default CreateProjectWrapper;

+ 149
- 0
src/components/CreateProject/ResourceCapacity.tsx Voir le fichier

@@ -0,0 +1,149 @@
import { manhourFormatter } from "@/app/utils/formatUtil";
import {
Box,
Card,
CardContent,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";

const mockItems: ResourceItem[] = [
{
grade: "Grade 1",
title: "A. QS / QS Trainee",
headcount: 20,
totalAvailableManhours: 39520,
loadedManhours: 3760,
remainingAvailableManhours: 35760,
},
{
grade: "Grade 2",
title: "QS",
headcount: 20,
totalAvailableManhours: 39520,
loadedManhours: 3760,
remainingAvailableManhours: 35760,
},
{
grade: "Grade 3",
title: "Senior QS",
headcount: 10,
totalAvailableManhours: 19760,
loadedManhours: 1530,
remainingAvailableManhours: 18230,
},
{
grade: "Grade 4",
title: "A. Manager / Deputy Manager / Manager / S. Manager",
headcount: 5,
totalAvailableManhours: 9880,
loadedManhours: 2760,
remainingAvailableManhours: 7120,
},
{
grade: "Grade 5",
title: "A. Director / Deputy Director / Director",
headcount: 20,
totalAvailableManhours: 1976,
loadedManhours: 374,
remainingAvailableManhours: 1602,
},
];

interface ResourceColumn {
label: string;
name: keyof ResourceItem;
}

interface ResourceItem {
grade: string;
title: string;
headcount: number;
totalAvailableManhours: number;
loadedManhours: number;
remainingAvailableManhours: number;
}

interface Props {
items?: ResourceItem[];
}

const ResourceCapacity: React.FC<Props> = ({ items = mockItems }) => {
const { t } = useTranslation();
const columns = useMemo<ResourceColumn[]>(
() => [
{ label: t("Grade"), name: "grade" },
{ label: t("Title"), name: "title" },
{ label: t("Headcount"), name: "headcount" },
{ label: t("Total Available Manhours"), name: "totalAvailableManhours" },
{ label: t("Loaded Manhours"), name: "loadedManhours" },
{
label: t("Remaining Available Manhours"),
name: "remainingAvailableManhours",
},
],
[t],
);

return (
<Card>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Stack gap={2}>
<Typography variant="overline" display="block">
{t("Resource Capacity")}
</Typography>
<Box sx={{ marginInline: -3 }}>
<TableContainer>
<Table>
<TableHead>
<TableRow>
{columns.map((column, idx) => (
<TableCell key={`${column.name.toString()}${idx}`}>
{column.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{items.map((item, index) => {
return (
<TableRow
hover
tabIndex={-1}
key={`${item.grade}-${index}`}
>
{columns.map((column, idx) => {
const columnName = column.name;
const cellData = item[columnName];

return (
<TableCell key={`${columnName.toString()}-${idx}`}>
{columnName !== "headcount" &&
typeof cellData === "number"
? manhourFormatter.format(cellData)
: cellData}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Box>
</Stack>
</CardContent>
</Card>
);
};

export default ResourceCapacity;

+ 117
- 12
src/components/CreateProject/ResourceMilestone.tsx Voir le fichier

@@ -5,27 +5,132 @@ import CardContent from "@mui/material/CardContent";
import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next";
import Button from "@mui/material/Button";
import React from "react";
import React, { useCallback, useMemo, useState } from "react";
import CardActions from "@mui/material/CardActions";
import RestartAlt from "@mui/icons-material/RestartAlt";
import {
Alert,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
SelectChangeEvent,
Stack,
} from "@mui/material";
import { Task, TaskGroup } from "@/app/api/tasks";
import uniqBy from "lodash/uniqBy";
import { moneyFormatter } from "@/app/utils/formatUtil";
import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs from "dayjs";

const ResourceMilestone = () => {
interface Props {
tasks: Task[];
}

const ResourceMilestone: React.FC<Props> = ({ tasks }) => {
const { t } = useTranslation();

const taskGroups = useMemo(() => {
return uniqBy(
tasks.map((task) => task.taskGroup),
"id",
);
}, [tasks]);
const [currentTaskGroupId, setCurrentTaskGroupId] = useState(
taskGroups[0].id,
);
const onSelectTaskGroup = useCallback(
(event: SelectChangeEvent<TaskGroup["id"]>) => {
const id = event.target.value;
setCurrentTaskGroupId(typeof id === "string" ? parseInt(id) : id);
},
[],
);

return (
<>
<Card>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<FormControl>
<InputLabel>{t("Task Stage")}</InputLabel>
<Select
label={t("Task Stage")}
onChange={onSelectTaskGroup}
value={currentTaskGroupId}
>
{taskGroups.map((taskGroup) => (
<MenuItem key={taskGroup.id} value={taskGroup.id}>
{taskGroup.name}
</MenuItem>
))}
</Select>
</FormControl>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Resource")}
</Typography>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Milestone")}
</Typography>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs>
<FormControl fullWidth>
<DatePicker
label={t("Stage Start Date")}
defaultValue={dayjs()}
/>
</FormControl>
</Grid>
<Grid item xs>
<FormControl fullWidth>
<DatePicker
label={t("Stage End Date")}
defaultValue={dayjs()}
/>
</FormControl>
</Grid>
</Grid>
</LocalizationProvider>
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button variant="text" startIcon={<RestartAlt />}>
{t("Reset")}
</Button>
</CardActions>
</CardContent>
</Card>
<Card>
<CardContent>
<Stack direction="row" justifyContent="space-between">
<Typography variant="h6">{t("Project Total Fee")}</Typography>
<Typography>{moneyFormatter.format(80000)}</Typography>
</Stack>
</CardContent>
</Card>
</>
);
};

const NoTaskState: React.FC = () => {
const { t } = useTranslation();
return (
<Card>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Resource and Milestone")}
</Typography>
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button variant="text" startIcon={<RestartAlt />}>
{t("Reset")}
</Button>
</CardActions>
<CardContent>
<Alert severity="error">
{t('Please add some tasks in "Project Task Setup" first!')}
</Alert>
</CardContent>
</Card>
);
};

export default ResourceMilestone;
const ResourceMilestoneWrapper: React.FC<Props> = (props) => {
if (props.tasks.length === 0) {
return <NoTaskState />;
}

return <ResourceMilestone {...props} />;
};

export default ResourceMilestoneWrapper;

+ 84
- 80
src/components/CreateProject/StaffAllocation.tsx Voir le fichier

@@ -28,6 +28,7 @@ import {
} from "@mui/material";
import differenceBy from "lodash/differenceBy";
import uniq from "lodash/uniq";
import ResourceCapacity from "./ResourceCapacity";

interface StaffResult {
id: string;
@@ -222,87 +223,90 @@ const StaffAllocation: React.FC<Props> = ({
}, [clearQueryInput, clearStaff, defaultFilterValues]);

return (
<Card>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Stack gap={2}>
<Typography variant="overline" display="block">
{t("Staff Allocation")}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={6} display="flex" alignItems="center">
<Search sx={{ marginInlineEnd: 1 }} />
<TextField
variant="standard"
fullWidth
onChange={onQueryInputChange}
value={query}
placeholder={t("Search by staff ID, name or title")}
InputProps={{
endAdornment: query && (
<InputAdornment position="end">
<IconButton onClick={clearQueryInput}>
<Clear />
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
{columnFilters.map((filter, idx) => {
const label = staffPoolColumns.find(
(c) => c.name === filter,
)!.label;
<>
<Card>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Stack gap={2}>
<Typography variant="overline" display="block">
{t("Staff Allocation")}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={6} display="flex" alignItems="center">
<Search sx={{ marginInlineEnd: 1 }} />
<TextField
variant="standard"
fullWidth
onChange={onQueryInputChange}
value={query}
placeholder={t("Search by staff ID, name or title")}
InputProps={{
endAdornment: query && (
<InputAdornment position="end">
<IconButton onClick={clearQueryInput}>
<Clear />
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
{columnFilters.map((filter, idx) => {
const label = staffPoolColumns.find(
(c) => c.name === filter,
)!.label;

return (
<Grid key={`${filter.toString()}-${idx}`} item xs={3}>
<FormControl fullWidth>
<InputLabel size="small">{label}</InputLabel>
<Select
label={label}
size="small"
value={filters[filter]}
onChange={makeFilterSelect(filter)}
>
<MenuItem value={"All"}>{t("All")}</MenuItem>
{filterValues[filter]?.map((option, index) => (
<MenuItem key={`${option}-${index}`} value={option}>
{option}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
);
})}
</Grid>
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={t("Staff Pool")} />
<Tab label={`${t("Allocated Staff")} (${selectedStaff.length})`} />
</Tabs>
<Box sx={{ marginInline: -3 }}>
{tabIndex === 0 && (
<SearchResults
noWrapper
items={differenceBy(filteredStaff, selectedStaff, "id")}
columns={staffPoolColumns}
/>
)}
{tabIndex === 1 && (
<SearchResults
noWrapper
items={selectedStaff}
columns={allocatedStaffColumns}
/>
)}
</Box>
</Stack>
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button variant="text" startIcon={<RestartAlt />} onClick={reset}>
{t("Reset")}
</Button>
</CardActions>
</CardContent>
</Card>
return (
<Grid key={`${filter.toString()}-${idx}`} item xs={3}>
<FormControl fullWidth>
<InputLabel size="small">{label}</InputLabel>
<Select
label={label}
size="small"
value={filters[filter]}
onChange={makeFilterSelect(filter)}
>
<MenuItem value={"All"}>{t("All")}</MenuItem>
{filterValues[filter]?.map((option, index) => (
<MenuItem key={`${option}-${index}`} value={option}>
{option}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
);
})}
</Grid>
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={t("Staff Pool")} />
<Tab label={`${t("Allocated Staff")} (${selectedStaff.length})`} />
</Tabs>
<Box sx={{ marginInline: -3 }}>
{tabIndex === 0 && (
<SearchResults
noWrapper
items={differenceBy(filteredStaff, selectedStaff, "id")}
columns={staffPoolColumns}
/>
)}
{tabIndex === 1 && (
<SearchResults
noWrapper
items={selectedStaff}
columns={allocatedStaffColumns}
/>
)}
</Box>
</Stack>
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button variant="text" startIcon={<RestartAlt />} onClick={reset}>
{t("Reset")}
</Button>
</CardActions>
</CardContent>
</Card>
<ResourceCapacity />
</>
);
};



+ 5
- 5
src/components/CreateProject/TaskSetup.tsx Voir le fichier

@@ -46,11 +46,11 @@ const TaskSetup = () => {
</Grid>
<TransferList
allItems={[
{ id: "1", label: "Task 1" },
{ id: "2", label: "Task 2" },
{ id: "3", label: "Task 3" },
{ id: "4", label: "Task 4" },
{ id: "5", label: "Task 5" },
{ id: 1, label: "Task 1" },
{ id: 2, label: "Task 2" },
{ id: 3, label: "Task 3" },
{ id: 4, label: "Task 4" },
{ id: 5, label: "Task 5" },
]}
initiallySelectedItems={[]}
onChange={() => {}}


+ 1
- 1
src/components/CreateProject/index.ts Voir le fichier

@@ -1 +1 @@
export { default } from "./CreateProject";
export { default } from "./CreateProjectWrapper";

+ 49
- 47
src/components/SearchBox/SearchBox.tsx Voir le fichier

@@ -15,10 +15,12 @@ import CardActions from "@mui/material/CardActions";
import Button from "@mui/material/Button";
import RestartAlt from "@mui/icons-material/RestartAlt";
import Search from "@mui/icons-material/Search";
import dayjs from 'dayjs';
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 dayjs from "dayjs";
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 } from "@mui/material";

interface BaseCriterion<T extends string> {
label: string;
@@ -40,7 +42,10 @@ interface DateRangeCriterion<T extends string> extends BaseCriterion<T> {
type: "dateRange";
}

export type Criterion<T extends string> = TextCriterion<T> | SelectCriterion<T> | DateRangeCriterion<T>;
export type Criterion<T extends string> =
| TextCriterion<T>
| SelectCriterion<T>
| DateRangeCriterion<T>;

interface Props<T extends string> {
criteria: Criterion<T>[];
@@ -54,7 +59,6 @@ function SearchBox<T extends string>({
onReset,
}: Props<T>) {
const { t } = useTranslation("common");
const [dayRangeFromDate, setDayRangeFromDate] :any = useState("");
const defaultInputs = useMemo(
() =>
criteria.reduce<Record<T, string>>(
@@ -82,23 +86,20 @@ function SearchBox<T extends string>({
};
}, []);

const makeDateChangeHandler = useCallback(
(paramName: T) => {
return (e:any) => {
setInputs((i) => ({ ...i, [paramName]: dayjs(e).format('YYYY-MM-DD') }));
const makeDateChangeHandler = useCallback((paramName: T) => {
return (e: any) => {
setInputs((i) => ({ ...i, [paramName]: dayjs(e).format("YYYY-MM-DD") }));
};
},
[],
);
}, []);

const makeDateToChangeHandler = useCallback(
(paramName: T) => {
return (e:any) => {
setInputs((i) => ({ ...i, [paramName + "To"]: dayjs(e).format('YYYY-MM-DD') }));
const makeDateToChangeHandler = useCallback((paramName: T) => {
return (e: any) => {
setInputs((i) => ({
...i,
[paramName + "To"]: dayjs(e).format("YYYY-MM-DD"),
}));
};
},
[],
);
}, []);

const handleReset = () => {
setInputs(defaultInputs);
@@ -143,33 +144,34 @@ function SearchBox<T extends string>({
</FormControl>
)}
{c.type === "dateRange" && (
<Grid container>
<Grid item xs={5.5} sm={5.5}>
<FormControl fullWidth>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
label={c.label}
onChange={makeDateChangeHandler(c.paramName)}
/>
</LocalizationProvider>
</FormControl>
</Grid>
<Grid item xs={1} sm={1} md={1} lg={1} sx={{ display: 'flex', justifyContent: "center", alignItems: 'center' }}>
-
</Grid>
<Grid item xs={5.5} sm={5.5}>
<FormControl fullWidth>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
label={c.label2}
onChange={makeDateToChangeHandler(c.paramName)}
/>
</LocalizationProvider>
</FormControl>
</Grid>
</Grid>
<LocalizationProvider
dateAdapter={AdapterDayjs}
// TODO: Should maybe use a custom adapterLocale here to support YYYY-MM-DD
adapterLocale="zh-hk"
>
<Box display="flex">
<FormControl fullWidth>
<DatePicker
label={c.label}
onChange={makeDateChangeHandler(c.paramName)}
/>
</FormControl>
<Box
display="flex"
alignItems="center"
justifyContent="center"
marginInline={2}
>
{"-"}
</Box>
<FormControl fullWidth>
<DatePicker
label={c.label2}
onChange={makeDateToChangeHandler(c.paramName)}
/>
</FormControl>
</Box>
</LocalizationProvider>
)}
</Grid>
);


+ 7
- 0
src/theme/devias-material-kit/components.ts Voir le fichier

@@ -47,6 +47,13 @@ const components: ThemeOptions["components"] = {
},
},
},
MuiAlert: {
styleOverrides: {
root: {
borderRadius: 8,
},
},
},
MuiPaper: {
styleOverrides: {
rounded: {


Chargement…
Annuler
Enregistrer