| @@ -34,3 +34,5 @@ yarn-error.log* | |||||
| # typescript | # typescript | ||||
| *.tsbuildinfo | *.tsbuildinfo | ||||
| next-env.d.ts | next-env.d.ts | ||||
| .vscode | |||||
| @@ -23,7 +23,7 @@ export interface CreateProjectInputs { | |||||
| tasks: { | tasks: { | ||||
| [taskId: Task["id"]]: { | [taskId: Task["id"]]: { | ||||
| manhourAllocation: { | manhourAllocation: { | ||||
| [grade: string]: number; | |||||
| [gradeId: number]: number; | |||||
| }; | }; | ||||
| }; | }; | ||||
| }; | }; | ||||
| @@ -0,0 +1,154 @@ | |||||
| import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||||
| import { TaskGroup } from "@/app/api/tasks"; | |||||
| import { Add, Delete } from "@mui/icons-material"; | |||||
| import { | |||||
| Stack, | |||||
| Typography, | |||||
| Grid, | |||||
| FormControl, | |||||
| Box, | |||||
| Button, | |||||
| } from "@mui/material"; | |||||
| import { | |||||
| GridColDef, | |||||
| GridActionsCellItem, | |||||
| GridToolbarContainer, | |||||
| } from "@mui/x-data-grid"; | |||||
| import { LocalizationProvider, DatePicker } from "@mui/x-date-pickers"; | |||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||||
| import dayjs from "dayjs"; | |||||
| import "dayjs/locale/zh-hk"; | |||||
| import React, { useMemo } from "react"; | |||||
| import { useFormContext } from "react-hook-form"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import StyledDataGrid from "../StyledDataGrid"; | |||||
| interface Props { | |||||
| taskGroupId: TaskGroup["id"]; | |||||
| } | |||||
| interface FooterToolbarProps { | |||||
| onAdd: () => void; | |||||
| } | |||||
| const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
| const { t } = useTranslation(); | |||||
| const {} = useFormContext<CreateProjectInputs>(); | |||||
| const columns = useMemo<GridColDef[]>( | |||||
| () => [ | |||||
| { | |||||
| type: "actions", | |||||
| field: "actions", | |||||
| headerName: t("Actions"), | |||||
| getActions: () => [ | |||||
| <GridActionsCellItem | |||||
| key="delete-action" | |||||
| icon={<Delete />} | |||||
| label={t("Remove")} | |||||
| onClick={() => {}} | |||||
| />, | |||||
| ], | |||||
| }, | |||||
| { | |||||
| field: "description", | |||||
| headerName: t("Payment Milestone Description"), | |||||
| width: 300, | |||||
| editable: true, | |||||
| }, | |||||
| { | |||||
| field: "date", | |||||
| headerName: t("Payment Milestone Date"), | |||||
| width: 200, | |||||
| type: "date", | |||||
| editable: true, | |||||
| }, | |||||
| { | |||||
| field: "amount", | |||||
| headerName: t("Payment Milestone Amount"), | |||||
| width: 300, | |||||
| editable: true, | |||||
| type: "number", | |||||
| }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| return ( | |||||
| <Stack gap={1}> | |||||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
| {t("Milestone")} | |||||
| </Typography> | |||||
| <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk"> | |||||
| <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> | |||||
| <Box | |||||
| sx={(theme) => ({ | |||||
| marginBlockStart: 1, | |||||
| marginInline: -3, | |||||
| borderBottom: `1px solid ${theme.palette.divider}`, | |||||
| })} | |||||
| > | |||||
| <StyledDataGrid | |||||
| autoHeight | |||||
| sx={{ "--DataGrid-overlayHeight": "100px" }} | |||||
| disableColumnMenu | |||||
| rows={[]} | |||||
| columns={columns} | |||||
| slots={{ | |||||
| footer: FooterToolbar, | |||||
| noRowsOverlay: NoRowsOverlay, | |||||
| }} | |||||
| slotProps={{ | |||||
| footer: undefined, | |||||
| }} | |||||
| /> | |||||
| </Box> | |||||
| </Stack> | |||||
| ); | |||||
| }; | |||||
| const NoRowsOverlay: React.FC = () => { | |||||
| const { t } = useTranslation(); | |||||
| return ( | |||||
| <Box | |||||
| display="flex" | |||||
| justifyContent="center" | |||||
| alignItems="center" | |||||
| height="100%" | |||||
| > | |||||
| <Typography variant="caption">{t("Add some milestones!")}</Typography> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| const FooterToolbar: React.FC<FooterToolbarProps> = ({ onAdd }) => { | |||||
| const { t } = useTranslation(); | |||||
| return ( | |||||
| <GridToolbarContainer sx={{ p: 2 }}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Add />} | |||||
| onClick={onAdd} | |||||
| size="small" | |||||
| > | |||||
| {t("Add Milestone")} | |||||
| </Button> | |||||
| </GridToolbarContainer> | |||||
| ); | |||||
| }; | |||||
| export default MilestoneSection; | |||||
| @@ -5,43 +5,29 @@ import CardContent from "@mui/material/CardContent"; | |||||
| import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import React, { useCallback, useMemo, useState } from "react"; | |||||
| import CardActions from "@mui/material/CardActions"; | import CardActions from "@mui/material/CardActions"; | ||||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | import RestartAlt from "@mui/icons-material/RestartAlt"; | ||||
| import { | import { | ||||
| Alert, | Alert, | ||||
| Box, | |||||
| FormControl, | FormControl, | ||||
| Grid, | |||||
| InputLabel, | InputLabel, | ||||
| List, | |||||
| ListItemButton, | |||||
| ListItemText, | |||||
| MenuItem, | MenuItem, | ||||
| Paper, | |||||
| Select, | Select, | ||||
| SelectChangeEvent, | SelectChangeEvent, | ||||
| Stack, | Stack, | ||||
| TextField, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { Task, TaskGroup } from "@/app/api/tasks"; | import { Task, TaskGroup } from "@/app/api/tasks"; | ||||
| import uniqBy from "lodash/uniqBy"; | import uniqBy from "lodash/uniqBy"; | ||||
| import { moneyFormatter } from "@/app/utils/formatUtil"; | 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"; | |||||
| import { useFormContext } from "react-hook-form"; | import { useFormContext } from "react-hook-form"; | ||||
| import { CreateProjectInputs } from "@/app/api/projects/actions"; | import { CreateProjectInputs } from "@/app/api/projects/actions"; | ||||
| import MilestoneSection from "./MilestoneSection"; | |||||
| import ResourceSection from "./ResourceSection"; | |||||
| interface Props { | |||||
| export interface Props { | |||||
| allTasks: Task[]; | allTasks: Task[]; | ||||
| } | |||||
| interface ResourceSectionProps { | |||||
| tasks: Task[]; | |||||
| defaultManhourBreakdownByGrade: { [grade: string]: number }; | |||||
| onSetManhours: (hours: number, taskId: Task["id"]) => void; | |||||
| onAllocateManhours: () => void; | |||||
| defaultManhourBreakdownByGrade?: { [gradeId: number]: number }; | |||||
| } | } | ||||
| const ResourceMilestone: React.FC<Props> = ({ allTasks }) => { | const ResourceMilestone: React.FC<Props> = ({ allTasks }) => { | ||||
| @@ -97,7 +83,7 @@ const ResourceMilestone: React.FC<Props> = ({ allTasks }) => { | |||||
| onSetManhours={() => {}} | onSetManhours={() => {}} | ||||
| onAllocateManhours={() => {}} | onAllocateManhours={() => {}} | ||||
| /> | /> | ||||
| <MilestoneSection /> | |||||
| <MilestoneSection taskGroupId={currentTaskGroupId} /> | |||||
| <CardActions sx={{ justifyContent: "flex-end" }}> | <CardActions sx={{ justifyContent: "flex-end" }}> | ||||
| <Button variant="text" startIcon={<RestartAlt />}> | <Button variant="text" startIcon={<RestartAlt />}> | ||||
| {t("Reset")} | {t("Reset")} | ||||
| @@ -117,91 +103,12 @@ const ResourceMilestone: React.FC<Props> = ({ allTasks }) => { | |||||
| ); | ); | ||||
| }; | }; | ||||
| const ResourceSection: React.FC<ResourceSectionProps> = ({ | |||||
| tasks, | |||||
| onAllocateManhours, | |||||
| onSetManhours, | |||||
| defaultManhourBreakdownByGrade, | |||||
| }) => { | |||||
| const { t } = useTranslation(); | |||||
| const [selectedTaskId, setSelectedTaskId] = useState(tasks[0].id); | |||||
| const makeOnTaskSelect = useCallback( | |||||
| (taskId: Task["id"]): React.MouseEventHandler => | |||||
| () => { | |||||
| return setSelectedTaskId(taskId); | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| useEffect(() => { | |||||
| setSelectedTaskId(tasks[0].id); | |||||
| }, [tasks]); | |||||
| return ( | |||||
| <Box marginBlock={4}> | |||||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
| {t("Resource")} | |||||
| </Typography> | |||||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||||
| <Grid item xs={6}> | |||||
| <Paper elevation={2}> | |||||
| <List dense sx={{ maxHeight: 300, overflow: "auto" }}> | |||||
| {tasks.map((task, index) => { | |||||
| return ( | |||||
| <ListItemButton | |||||
| selected={selectedTaskId === task.id} | |||||
| key={`${task.id}-${index}`} | |||||
| onClick={makeOnTaskSelect(task.id)} | |||||
| > | |||||
| <ListItemText primary={task.name} /> | |||||
| </ListItemButton> | |||||
| ); | |||||
| })} | |||||
| </List> | |||||
| </Paper> | |||||
| </Grid> | |||||
| <Grid item xs={6}> | |||||
| <TextField label={t("Mahours Allocated to Task")} fullWidth /> | |||||
| </Grid> | |||||
| </Grid> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| const MilestoneSection: React.FC = () => { | |||||
| const { t } = useTranslation(); | |||||
| return ( | |||||
| <Box> | |||||
| <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> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| const NoTaskState: React.FC = () => { | const NoTaskState: React.FC = () => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| return ( | return ( | ||||
| <Card> | <Card> | ||||
| <CardContent> | <CardContent> | ||||
| <Alert severity="error"> | |||||
| <Alert severity="warning"> | |||||
| {t('Please add some tasks in "Project Task Setup" first!')} | {t('Please add some tasks in "Project Task Setup" first!')} | ||||
| </Alert> | </Alert> | ||||
| </CardContent> | </CardContent> | ||||
| @@ -0,0 +1,74 @@ | |||||
| import { Task } from "@/app/api/tasks"; | |||||
| import { | |||||
| Box, | |||||
| Typography, | |||||
| Grid, | |||||
| Paper, | |||||
| List, | |||||
| ListItemButton, | |||||
| ListItemText, | |||||
| TextField, | |||||
| } from "@mui/material"; | |||||
| import { useState, useCallback, useEffect } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { Props as ResourceMilestoneProps } from "./ResourceMilestone"; | |||||
| interface Props { | |||||
| tasks: Task[]; | |||||
| defaultManhourBreakdownByGrade: ResourceMilestoneProps["defaultManhourBreakdownByGrade"]; | |||||
| onSetManhours: (hours: number, taskId: Task["id"]) => void; | |||||
| onAllocateManhours: () => void; | |||||
| } | |||||
| const ResourceSection: React.FC<Props> = ({ | |||||
| tasks, | |||||
| onAllocateManhours, | |||||
| onSetManhours, | |||||
| defaultManhourBreakdownByGrade, | |||||
| }) => { | |||||
| const { t } = useTranslation(); | |||||
| const [selectedTaskId, setSelectedTaskId] = useState(tasks[0].id); | |||||
| const makeOnTaskSelect = useCallback( | |||||
| (taskId: Task["id"]): React.MouseEventHandler => | |||||
| () => { | |||||
| return setSelectedTaskId(taskId); | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| useEffect(() => { | |||||
| setSelectedTaskId(tasks[0].id); | |||||
| }, [tasks]); | |||||
| return ( | |||||
| <Box marginBlock={4}> | |||||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
| {t("Resource")} | |||||
| </Typography> | |||||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||||
| <Grid item xs={6}> | |||||
| <Paper elevation={2}> | |||||
| <List dense sx={{ maxHeight: 300, overflow: "auto" }}> | |||||
| {tasks.map((task, index) => { | |||||
| return ( | |||||
| <ListItemButton | |||||
| selected={selectedTaskId === task.id} | |||||
| key={`${task.id}-${index}`} | |||||
| onClick={makeOnTaskSelect(task.id)} | |||||
| > | |||||
| <ListItemText primary={task.name} /> | |||||
| </ListItemButton> | |||||
| ); | |||||
| })} | |||||
| </List> | |||||
| </Paper> | |||||
| </Grid> | |||||
| <Grid item xs={6}> | |||||
| <TextField label={t("Mahours Allocated to Task")} fullWidth /> | |||||
| </Grid> | |||||
| </Grid> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default ResourceSection; | |||||
| @@ -0,0 +1,22 @@ | |||||
| import { styled } from "@mui/material"; | |||||
| import { DataGrid } from "@mui/x-data-grid"; | |||||
| const StyledDataGrid = styled(DataGrid)(({ theme }) => ({ | |||||
| "--unstable_DataGrid-radius": 0, | |||||
| "& .MuiDataGrid-columnHeaders": { | |||||
| backgroundColor: theme.palette.grey[50], | |||||
| }, | |||||
| "& .MuiDataGrid-columnHeaderTitle": { | |||||
| color: theme.palette.grey[700], | |||||
| fontSize: 12, | |||||
| fontWeight: 600, | |||||
| lineHeight: 2, | |||||
| letterSpacing: 0.5, | |||||
| textTransform: "uppercase", | |||||
| }, | |||||
| "& .MuiDataGrid-columnSeparator": { | |||||
| color: theme.palette.primary.main, | |||||
| }, | |||||
| })); | |||||
| export default StyledDataGrid; | |||||
| @@ -0,0 +1 @@ | |||||
| export { default } from "./StyledDataGrid"; | |||||
| @@ -340,6 +340,10 @@ const components: ThemeOptions["components"] = { | |||||
| padding: { | padding: { | ||||
| paddingBlock: "1rem", | paddingBlock: "1rem", | ||||
| paddingInline: "1rem", | paddingInline: "1rem", | ||||
| "&.MuiDataGrid-menuList": { | |||||
| paddingBlock: "0.25rem", | |||||
| paddingInline: "0.25rem", | |||||
| }, | |||||
| }, | }, | ||||
| }, | }, | ||||
| }, | }, | ||||
| @@ -28,6 +28,7 @@ const palette = { | |||||
| }, | }, | ||||
| warning, | warning, | ||||
| neutral, | neutral, | ||||
| grey: neutral, | |||||
| }; | }; | ||||
| export const paletteOptions: PaletteOptions = { ...palette, mode: "light" }; | export const paletteOptions: PaletteOptions = { ...palette, mode: "light" }; | ||||