@@ -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} | ||||