| @@ -2,7 +2,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| import "server-only"; | import "server-only"; | ||||
| import { Task } from "../tasks"; | |||||
| import { Task, TaskGroup } from "../tasks"; | |||||
| export interface ProjectResult { | export interface ProjectResult { | ||||
| id: number; | id: number; | ||||
| @@ -53,6 +53,12 @@ export interface AssignedProject { | |||||
| code: string; | code: string; | ||||
| name: string; | name: string; | ||||
| tasks: Task[]; | tasks: Task[]; | ||||
| milestones: { | |||||
| [taskGroupId: TaskGroup["id"]]: { | |||||
| startDate: string; | |||||
| endDate: string; | |||||
| }; | |||||
| }; | |||||
| } | } | ||||
| export const preloadProjects = () => { | export const preloadProjects = () => { | ||||
| @@ -36,12 +36,6 @@ interface Props { | |||||
| taskGroupId: TaskGroup["id"]; | taskGroupId: TaskGroup["id"]; | ||||
| } | } | ||||
| declare module "@mui/x-data-grid" { | |||||
| interface FooterPropsOverrides { | |||||
| onAdd: () => void; | |||||
| } | |||||
| } | |||||
| type PaymentRow = Partial<PaymentInputs & { _isNew: boolean; _error: string }>; | type PaymentRow = Partial<PaymentInputs & { _isNew: boolean; _error: string }>; | ||||
| const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | ||||
| @@ -218,6 +212,17 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
| }); | }); | ||||
| }, [getValues, payments, setValue, taskGroupId]); | }, [getValues, payments, setValue, taskGroupId]); | ||||
| const footer = ( | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Add />} | |||||
| onClick={addRow} | |||||
| size="small" | |||||
| > | |||||
| {t("Add Payment Milestone")} | |||||
| </Button> | |||||
| ); | |||||
| return ( | return ( | ||||
| <Stack gap={1}> | <Stack gap={1}> | ||||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | <Typography variant="overline" display="block" marginBlockEnd={1}> | ||||
| @@ -301,7 +306,7 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
| noRowsOverlay: NoRowsOverlay, | noRowsOverlay: NoRowsOverlay, | ||||
| }} | }} | ||||
| slotProps={{ | slotProps={{ | ||||
| footer: { onAdd: addRow }, | |||||
| footer: { child: footer }, | |||||
| }} | }} | ||||
| /> | /> | ||||
| </Box> | </Box> | ||||
| @@ -325,20 +330,8 @@ const NoRowsOverlay: React.FC = () => { | |||||
| ); | ); | ||||
| }; | }; | ||||
| const FooterToolbar: React.FC<FooterPropsOverrides> = ({ onAdd }) => { | |||||
| const { t } = useTranslation(); | |||||
| return ( | |||||
| <GridToolbarContainer sx={{ p: 2 }}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Add />} | |||||
| onClick={onAdd} | |||||
| size="small" | |||||
| > | |||||
| {t("Add Payment Milestone")} | |||||
| </Button> | |||||
| </GridToolbarContainer> | |||||
| ); | |||||
| const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => { | |||||
| return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>; | |||||
| }; | }; | ||||
| export default MilestoneSection; | export default MilestoneSection; | ||||
| @@ -1,6 +1,13 @@ | |||||
| import { styled } from "@mui/material"; | import { styled } from "@mui/material"; | ||||
| import { DataGrid } from "@mui/x-data-grid"; | import { DataGrid } from "@mui/x-data-grid"; | ||||
| declare module "@mui/x-data-grid" { | |||||
| interface FooterPropsOverrides { | |||||
| onAdd?: () => void; | |||||
| child?: React.ReactNode; | |||||
| } | |||||
| } | |||||
| const StyledDataGrid = styled(DataGrid)(({ theme }) => ({ | const StyledDataGrid = styled(DataGrid)(({ theme }) => ({ | ||||
| "--unstable_DataGrid-radius": 0, | "--unstable_DataGrid-radius": 0, | ||||
| "& .MuiDataGrid-columnHeaders": { | "& .MuiDataGrid-columnHeaders": { | ||||
| @@ -21,6 +21,7 @@ import { manhourFormatter } from "@/app/utils/formatUtil"; | |||||
| import { AssignedProject } from "@/app/api/projects"; | import { AssignedProject } from "@/app/api/projects"; | ||||
| import uniqBy from "lodash/uniqBy"; | import uniqBy from "lodash/uniqBy"; | ||||
| import { TaskGroup } from "@/app/api/tasks"; | import { TaskGroup } from "@/app/api/tasks"; | ||||
| import dayjs from "dayjs"; | |||||
| const mockProjects: AssignedProject[] = [ | const mockProjects: AssignedProject[] = [ | ||||
| { | { | ||||
| @@ -47,6 +48,16 @@ const mockProjects: AssignedProject[] = [ | |||||
| }, | }, | ||||
| }, | }, | ||||
| ], | ], | ||||
| milestones: { | |||||
| 1: { | |||||
| startDate: "2000-01-01", | |||||
| endDate: "2100-01-01", | |||||
| }, | |||||
| 2: { | |||||
| startDate: "2100-01-01", | |||||
| endDate: "2100-01-02", | |||||
| }, | |||||
| }, | |||||
| }, | }, | ||||
| { | { | ||||
| id: 2, | id: 2, | ||||
| @@ -72,6 +83,16 @@ const mockProjects: AssignedProject[] = [ | |||||
| }, | }, | ||||
| }, | }, | ||||
| ], | ], | ||||
| milestones: { | |||||
| 1: { | |||||
| startDate: "2000-01-01", | |||||
| endDate: "2100-01-01", | |||||
| }, | |||||
| 3: { | |||||
| startDate: "2100-01-01", | |||||
| endDate: "2100-01-02", | |||||
| }, | |||||
| }, | |||||
| }, | }, | ||||
| { | { | ||||
| id: 3, | id: 3, | ||||
| @@ -97,6 +118,16 @@ const mockProjects: AssignedProject[] = [ | |||||
| }, | }, | ||||
| }, | }, | ||||
| ], | ], | ||||
| milestones: { | |||||
| 1: { | |||||
| startDate: "2000-01-01", | |||||
| endDate: "2100-01-01", | |||||
| }, | |||||
| 4: { | |||||
| startDate: "2100-01-01", | |||||
| endDate: "2100-01-02", | |||||
| }, | |||||
| }, | |||||
| }, | }, | ||||
| ]; | ]; | ||||
| @@ -104,6 +135,7 @@ type TimeEntryRow = Partial< | |||||
| TimeEntry & { | TimeEntry & { | ||||
| _isNew: boolean; | _isNew: boolean; | ||||
| _error: string; | _error: string; | ||||
| isPlanned: boolean; | |||||
| id: string; | id: string; | ||||
| taskGroupId: number; | taskGroupId: number; | ||||
| } | } | ||||
| @@ -131,6 +163,15 @@ const EntryInputTable: React.FC<{ day: string }> = ({ day }) => { | |||||
| }, {}); | }, {}); | ||||
| }, []); | }, []); | ||||
| // To check for start / end planned dates | |||||
| const milestonesByProject = useMemo(() => { | |||||
| return mockProjects.reduce<{ | |||||
| [projectId: AssignedProject["id"]]: AssignedProject["milestones"]; | |||||
| }>((acc, project) => { | |||||
| return { ...acc, [project.id]: { ...project.milestones } }; | |||||
| }, {}); | |||||
| }, []); | |||||
| const { getValues, setValue } = useFormContext<RecordTimesheetInput>(); | const { getValues, setValue } = useFormContext<RecordTimesheetInput>(); | ||||
| const currentEntries = getValues(day); | const currentEntries = getValues(day); | ||||
| @@ -156,7 +197,9 @@ const EntryInputTable: React.FC<{ day: string }> = ({ day }) => { | |||||
| id, | id, | ||||
| "", | "", | ||||
| ) as TimeEntryRow; | ) as TimeEntryRow; | ||||
| let error: keyof TimeEntry | "taskGroupId" | "" = ""; | |||||
| // Test for errrors | |||||
| let error: keyof TimeEntry | "" = ""; | |||||
| if (!row.projectId) { | if (!row.projectId) { | ||||
| error = "projectId"; | error = "projectId"; | ||||
| } else if (!row.taskGroupId) { | } else if (!row.taskGroupId) { | ||||
| @@ -167,10 +210,24 @@ const EntryInputTable: React.FC<{ day: string }> = ({ day }) => { | |||||
| error = "inputHours"; | error = "inputHours"; | ||||
| } | } | ||||
| apiRef.current.updateRows([{ id, _error: error }]); | |||||
| // Test for warnings | |||||
| let isPlanned = false; | |||||
| if ( | |||||
| row.projectId && | |||||
| row.taskGroupId && | |||||
| milestonesByProject[row.projectId] | |||||
| ) { | |||||
| const milestone = | |||||
| milestonesByProject[row.projectId][row.taskGroupId] || {}; | |||||
| const { startDate, endDate } = milestone; | |||||
| // Check if the current day is between the start and end date inclusively | |||||
| isPlanned = dayjs(day).isBetween(startDate, endDate, "day", "[]"); | |||||
| } | |||||
| apiRef.current.updateRows([{ id, _error: error, isPlanned }]); | |||||
| return !error; | return !error; | ||||
| }, | }, | ||||
| [apiRef], | |||||
| [apiRef, day, milestonesByProject], | |||||
| ); | ); | ||||
| const handleCancel = useCallback( | const handleCancel = useCallback( | ||||
| @@ -363,6 +420,29 @@ const EntryInputTable: React.FC<{ day: string }> = ({ day }) => { | |||||
| ]); | ]); | ||||
| }, [getValues, entries, setValue, day]); | }, [getValues, entries, setValue, day]); | ||||
| const hasOutOfPlannedStages = entries.some( | |||||
| (entry) => entry.isPlanned !== undefined && !entry.isPlanned, | |||||
| ); | |||||
| const footer = ( | |||||
| <Box display="flex" gap={2} alignItems="center"> | |||||
| <Button | |||||
| disableRipple | |||||
| variant="outlined" | |||||
| startIcon={<Add />} | |||||
| onClick={addRow} | |||||
| size="small" | |||||
| > | |||||
| {t("Record time")} | |||||
| </Button> | |||||
| {hasOutOfPlannedStages && ( | |||||
| <Typography color="warning.main" variant="body2"> | |||||
| {t("There are entries for stages out of planned dates!")} | |||||
| </Typography> | |||||
| )} | |||||
| </Box> | |||||
| ); | |||||
| return ( | return ( | ||||
| <StyledDataGrid | <StyledDataGrid | ||||
| apiRef={apiRef} | apiRef={apiRef} | ||||
| @@ -373,6 +453,10 @@ const EntryInputTable: React.FC<{ day: string }> = ({ day }) => { | |||||
| border: "1px solid", | border: "1px solid", | ||||
| borderColor: "error.main", | borderColor: "error.main", | ||||
| }, | }, | ||||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": { | |||||
| border: "1px solid", | |||||
| borderColor: "warning.main", | |||||
| }, | |||||
| }} | }} | ||||
| disableColumnMenu | disableColumnMenu | ||||
| editMode="row" | editMode="row" | ||||
| @@ -383,14 +467,24 @@ const EntryInputTable: React.FC<{ day: string }> = ({ day }) => { | |||||
| processRowUpdate={processRowUpdate} | processRowUpdate={processRowUpdate} | ||||
| columns={columns} | columns={columns} | ||||
| getCellClassName={(params) => { | getCellClassName={(params) => { | ||||
| return params.row._error === params.field ? "hasError" : ""; | |||||
| let classname = ""; | |||||
| if (params.row._error === params.field) { | |||||
| classname = "hasError"; | |||||
| } else if ( | |||||
| params.field === "taskGroupId" && | |||||
| params.row.isPlanned !== undefined && | |||||
| !params.row.isPlanned | |||||
| ) { | |||||
| classname = "hasWarning"; | |||||
| } | |||||
| return classname; | |||||
| }} | }} | ||||
| slots={{ | slots={{ | ||||
| footer: FooterToolbar, | footer: FooterToolbar, | ||||
| noRowsOverlay: NoRowsOverlay, | noRowsOverlay: NoRowsOverlay, | ||||
| }} | }} | ||||
| slotProps={{ | slotProps={{ | ||||
| footer: { onAdd: addRow }, | |||||
| footer: { child: footer }, | |||||
| }} | }} | ||||
| /> | /> | ||||
| ); | ); | ||||
| @@ -410,21 +504,8 @@ const NoRowsOverlay: React.FC = () => { | |||||
| ); | ); | ||||
| }; | }; | ||||
| const FooterToolbar: React.FC<FooterPropsOverrides> = ({ onAdd }) => { | |||||
| const { t } = useTranslation(); | |||||
| return ( | |||||
| <GridToolbarContainer sx={{ p: 2 }}> | |||||
| <Button | |||||
| disableRipple | |||||
| variant="outlined" | |||||
| startIcon={<Add />} | |||||
| onClick={onAdd} | |||||
| size="small" | |||||
| > | |||||
| {t("Record time")} | |||||
| </Button> | |||||
| </GridToolbarContainer> | |||||
| ); | |||||
| const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => { | |||||
| return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>; | |||||
| }; | }; | ||||
| export default EntryInputTable; | export default EntryInputTable; | ||||
| @@ -11,6 +11,7 @@ import { | |||||
| TableContainer, | TableContainer, | ||||
| TableHead, | TableHead, | ||||
| TableRow, | TableRow, | ||||
| Typography, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import React, { useState } from "react"; | import React, { useState } from "react"; | ||||
| @@ -53,6 +54,7 @@ const DayRow: React.FC<{ day: string; entries: TimeEntry[] }> = ({ | |||||
| entries, | entries, | ||||
| }) => { | }) => { | ||||
| const { | const { | ||||
| t, | |||||
| i18n: { language }, | i18n: { language }, | ||||
| } = useTranslation("home"); | } = useTranslation("home"); | ||||
| const dayJsObj = dayjs(day); | const dayJsObj = dayjs(day); | ||||
| @@ -80,6 +82,16 @@ const DayRow: React.FC<{ day: string; entries: TimeEntry[] }> = ({ | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell sx={{ color: totalHours > 20 ? "error.main" : undefined }}> | <TableCell sx={{ color: totalHours > 20 ? "error.main" : undefined }}> | ||||
| {manhourFormatter.format(totalHours)} | {manhourFormatter.format(totalHours)} | ||||
| {totalHours > 20 && ( | |||||
| <Typography | |||||
| color="error.main" | |||||
| variant="body2" | |||||
| component="span" | |||||
| sx={{ marginInlineStart: 1 }} | |||||
| > | |||||
| {t("(the daily total hours cannot be more than 20.)")} | |||||
| </Typography> | |||||
| )} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| <TableRow> | <TableRow> | ||||
| @@ -6,8 +6,6 @@ import Button from "@mui/material/Button"; | |||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import { Add } from "@mui/icons-material"; | import { Add } from "@mui/icons-material"; | ||||
| import { Typography } from "@mui/material"; | import { Typography } from "@mui/material"; | ||||
| import EnterTimesheetModal from "../EnterTimesheet/EnterTimesheetModal"; | |||||
| import EnterLeaveModal from "../EnterLeave/EnterLeaveModal"; | |||||
| import ButtonGroup from "@mui/material/ButtonGroup"; | import ButtonGroup from "@mui/material/ButtonGroup"; | ||||
| import AssignedProjects from "./AssignedProjects"; | import AssignedProjects from "./AssignedProjects"; | ||||
| import { ProjectHours } from "./UserWorkspaceWrapper"; | import { ProjectHours } from "./UserWorkspaceWrapper"; | ||||
| @@ -65,14 +63,11 @@ const UserWorkspacePage: React.FC<Props> = ({ allProjects }) => { | |||||
| </ButtonGroup> | </ButtonGroup> | ||||
| </Stack> | </Stack> | ||||
| </Stack> | </Stack> | ||||
| <EnterTimesheetModal | |||||
| <TimesheetModal | |||||
| timesheetType="time" | |||||
| isOpen={isTimeheetModalVisible} | isOpen={isTimeheetModalVisible} | ||||
| onClose={handleCloseTimesheetModal} | onClose={handleCloseTimesheetModal} | ||||
| /> | /> | ||||
| {/* <EnterLeaveModal | |||||
| isOpen={isLeaveModalVisible} | |||||
| onClose={handleCloseLeaveModal} | |||||
| /> */} | |||||
| <TimesheetModal | <TimesheetModal | ||||
| timesheetType="leave" | timesheetType="leave" | ||||
| isOpen={isLeaveModalVisible} | isOpen={isLeaveModalVisible} | ||||