@@ -12,7 +12,7 @@ export interface Task { | |||
id: number; | |||
name: string; | |||
description: string | null; | |||
taskGroup: TaskGroup | null; | |||
taskGroup: TaskGroup; | |||
} | |||
export interface TaskTemplate { | |||
@@ -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", | |||
}); |
@@ -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")} | |||
@@ -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; |
@@ -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; |
@@ -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; |
@@ -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 /> | |||
</> | |||
); | |||
}; | |||
@@ -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 @@ | |||
export { default } from "./CreateProject"; | |||
export { default } from "./CreateProjectWrapper"; |
@@ -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> | |||
); | |||
@@ -47,6 +47,13 @@ const components: ThemeOptions["components"] = { | |||
}, | |||
}, | |||
}, | |||
MuiAlert: { | |||
styleOverrides: { | |||
root: { | |||
borderRadius: 8, | |||
}, | |||
}, | |||
}, | |||
MuiPaper: { | |||
styleOverrides: { | |||
rounded: { | |||