| @@ -34,3 +34,5 @@ yarn-error.log* | |||
| # typescript | |||
| *.tsbuildinfo | |||
| next-env.d.ts | |||
| .vscode | |||
| @@ -23,7 +23,7 @@ export interface CreateProjectInputs { | |||
| tasks: { | |||
| [taskId: Task["id"]]: { | |||
| 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 { useTranslation } from "react-i18next"; | |||
| 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 RestartAlt from "@mui/icons-material/RestartAlt"; | |||
| import { | |||
| Alert, | |||
| Box, | |||
| FormControl, | |||
| Grid, | |||
| InputLabel, | |||
| List, | |||
| ListItemButton, | |||
| ListItemText, | |||
| MenuItem, | |||
| Paper, | |||
| Select, | |||
| SelectChangeEvent, | |||
| Stack, | |||
| TextField, | |||
| } 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"; | |||
| import { useFormContext } from "react-hook-form"; | |||
| import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||
| import MilestoneSection from "./MilestoneSection"; | |||
| import ResourceSection from "./ResourceSection"; | |||
| interface Props { | |||
| export interface Props { | |||
| 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 }) => { | |||
| @@ -97,7 +83,7 @@ const ResourceMilestone: React.FC<Props> = ({ allTasks }) => { | |||
| onSetManhours={() => {}} | |||
| onAllocateManhours={() => {}} | |||
| /> | |||
| <MilestoneSection /> | |||
| <MilestoneSection taskGroupId={currentTaskGroupId} /> | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button variant="text" startIcon={<RestartAlt />}> | |||
| {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 { t } = useTranslation(); | |||
| return ( | |||
| <Card> | |||
| <CardContent> | |||
| <Alert severity="error"> | |||
| <Alert severity="warning"> | |||
| {t('Please add some tasks in "Project Task Setup" first!')} | |||
| </Alert> | |||
| </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: { | |||
| paddingBlock: "1rem", | |||
| paddingInline: "1rem", | |||
| "&.MuiDataGrid-menuList": { | |||
| paddingBlock: "0.25rem", | |||
| paddingInline: "0.25rem", | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| @@ -28,6 +28,7 @@ const palette = { | |||
| }, | |||
| warning, | |||
| neutral, | |||
| grey: neutral, | |||
| }; | |||
| export const paletteOptions: PaletteOptions = { ...palette, mode: "light" }; | |||