@@ -8,7 +8,10 @@ import { | |||||
} from "@/app/api/timesheets"; | } from "@/app/api/timesheets"; | ||||
import { authOptions } from "@/config/authConfig"; | import { authOptions } from "@/config/authConfig"; | ||||
import { getServerSession } from "next-auth"; | import { getServerSession } from "next-auth"; | ||||
import { fetchAssignedProjects } from "@/app/api/projects"; | |||||
import { | |||||
fetchAssignedProjects, | |||||
fetchProjectWithTasks, | |||||
} from "@/app/api/projects"; | |||||
export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
title: "User Workspace", | title: "User Workspace", | ||||
@@ -23,6 +26,7 @@ const Home: React.FC = async () => { | |||||
fetchAssignedProjects(username); | fetchAssignedProjects(username); | ||||
fetchLeaves(username); | fetchLeaves(username); | ||||
fetchLeaveTypes(); | fetchLeaveTypes(); | ||||
fetchProjectWithTasks(); | |||||
return ( | return ( | ||||
<I18nProvider namespaces={["home"]}> | <I18nProvider namespaces={["home"]}> | ||||
@@ -49,7 +49,7 @@ export interface WorkNature { | |||||
name: string; | name: string; | ||||
} | } | ||||
export interface AssignedProject { | |||||
export interface ProjectWithTasks { | |||||
id: number; | id: number; | ||||
code: string; | code: string; | ||||
name: string; | name: string; | ||||
@@ -60,6 +60,9 @@ export interface AssignedProject { | |||||
endDate?: string; | endDate?: string; | ||||
}; | }; | ||||
}; | }; | ||||
} | |||||
export interface AssignedProject extends ProjectWithTasks { | |||||
// Manhour info | // Manhour info | ||||
hoursSpent: number; | hoursSpent: number; | ||||
hoursSpentOther: number; | hoursSpentOther: number; | ||||
@@ -147,6 +150,15 @@ export const fetchAssignedProjects = cache(async (username: string) => { | |||||
); | ); | ||||
}); | }); | ||||
export const fetchProjectWithTasks = cache(async () => { | |||||
return serverFetchJson<ProjectWithTasks[]>( | |||||
`${BASE_API_URL}/projects/allProjectWithTasks`, | |||||
{ | |||||
next: { tags: ["allProjectWithTasks"] }, | |||||
}, | |||||
); | |||||
}); | |||||
export const fetchProjectDetails = cache(async (projectId: string) => { | export const fetchProjectDetails = cache(async (projectId: string) => { | ||||
return serverFetchJson<CreateProjectInputs>( | return serverFetchJson<CreateProjectInputs>( | ||||
`${BASE_API_URL}/projects/projectDetails/${projectId}`, | `${BASE_API_URL}/projects/projectDetails/${projectId}`, | ||||
@@ -8,10 +8,11 @@ import { revalidateTag } from "next/cache"; | |||||
export interface TimeEntry { | export interface TimeEntry { | ||||
id: number; | id: number; | ||||
projectId: ProjectResult["id"]; | |||||
taskGroupId: TaskGroup["id"]; | |||||
taskId: Task["id"]; | |||||
projectId?: ProjectResult["id"]; | |||||
taskGroupId?: TaskGroup["id"]; | |||||
taskId?: Task["id"]; | |||||
inputHours: number; | inputHours: number; | ||||
remark?: string; | |||||
} | } | ||||
export interface RecordTimesheetInput { | export interface RecordTimesheetInput { | ||||
@@ -22,6 +23,7 @@ export interface LeaveEntry { | |||||
id: number; | id: number; | ||||
inputHours: number; | inputHours: number; | ||||
leaveTypeId: number; | leaveTypeId: number; | ||||
remark?: string; | |||||
} | } | ||||
export interface RecordLeaveInput { | export interface RecordLeaveInput { | ||||
@@ -0,0 +1,44 @@ | |||||
import { LeaveEntry, TimeEntry } from "./actions"; | |||||
/** | |||||
* @param entry - the time entry | |||||
* @returns the field where there is an error, or an empty string if there is none | |||||
*/ | |||||
export const isValidTimeEntry = (entry: Partial<TimeEntry>): string => { | |||||
// Test for errors | |||||
let error: keyof TimeEntry | "" = ""; | |||||
// If there is a project id, there should also be taskGroupId, taskId, inputHours | |||||
if (entry.projectId) { | |||||
if (!entry.taskGroupId) { | |||||
error = "taskGroupId"; | |||||
} else if (!entry.taskId) { | |||||
error = "taskId"; | |||||
} else if (!entry.inputHours || !(entry.inputHours >= 0)) { | |||||
error = "inputHours"; | |||||
} | |||||
} else { | |||||
if (!entry.inputHours || !(entry.inputHours >= 0)) { | |||||
error = "inputHours"; | |||||
} else if (!entry.remark) { | |||||
error = "remark"; | |||||
} | |||||
} | |||||
return error; | |||||
}; | |||||
export const isValidLeaveEntry = (entry: Partial<LeaveEntry>): string => { | |||||
// Test for errrors | |||||
let error: keyof LeaveEntry | "" = ""; | |||||
if (!entry.leaveTypeId) { | |||||
error = "leaveTypeId"; | |||||
} else if (!entry.inputHours || !(entry.inputHours >= 0)) { | |||||
error = "inputHours"; | |||||
} | |||||
return error; | |||||
}; | |||||
export const LEAVE_DAILY_MAX_HOURS = 8; | |||||
export const TIMESHEET_DAILY_MAX_HOURS = 20; |
@@ -21,6 +21,7 @@ import { manhourFormatter } from "@/app/utils/formatUtil"; | |||||
import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
import isBetween from "dayjs/plugin/isBetween"; | import isBetween from "dayjs/plugin/isBetween"; | ||||
import { LeaveType } from "@/app/api/timesheets"; | import { LeaveType } from "@/app/api/timesheets"; | ||||
import { isValidLeaveEntry } from "@/app/api/timesheets/utils"; | |||||
dayjs.extend(isBetween); | dayjs.extend(isBetween); | ||||
@@ -63,13 +64,7 @@ const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => { | |||||
"", | "", | ||||
) as LeaveEntryRow; | ) as LeaveEntryRow; | ||||
// Test for errrors | |||||
let error: keyof LeaveEntry | "" = ""; | |||||
if (!row.leaveTypeId) { | |||||
error = "leaveTypeId"; | |||||
} else if (!row.inputHours || !(row.inputHours >= 0)) { | |||||
error = "inputHours"; | |||||
} | |||||
const error = isValidLeaveEntry(row); | |||||
apiRef.current.updateRows([{ id, _error: error }]); | apiRef.current.updateRows([{ id, _error: error }]); | ||||
return !error; | return !error; | ||||
@@ -182,6 +177,13 @@ const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => { | |||||
return manhourFormatter.format(params.value); | return manhourFormatter.format(params.value); | ||||
}, | }, | ||||
}, | }, | ||||
{ | |||||
field: "remark", | |||||
headerName: t("Remark"), | |||||
sortable: false, | |||||
flex: 1, | |||||
editable: true, | |||||
}, | |||||
], | ], | ||||
[t, rowModesModel, handleDelete, handleSave, handleCancel, leaveTypes], | [t, rowModesModel, handleDelete, handleSave, handleCancel, leaveTypes], | ||||
); | ); | ||||
@@ -197,6 +199,7 @@ const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => { | |||||
id: e.id!, | id: e.id!, | ||||
inputHours: e.inputHours!, | inputHours: e.inputHours!, | ||||
leaveTypeId: e.leaveTypeId!, | leaveTypeId: e.leaveTypeId!, | ||||
remark: e.remark, | |||||
})), | })), | ||||
]); | ]); | ||||
}, [getValues, entries, setValue, day]); | }, [getValues, entries, setValue, day]); | ||||
@@ -19,13 +19,12 @@ import { useFormContext } from "react-hook-form"; | |||||
import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
import LeaveEntryTable from "./LeaveEntryTable"; | import LeaveEntryTable from "./LeaveEntryTable"; | ||||
import { LeaveType } from "@/app/api/timesheets"; | import { LeaveType } from "@/app/api/timesheets"; | ||||
import { LEAVE_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils"; | |||||
interface Props { | interface Props { | ||||
leaveTypes: LeaveType[]; | leaveTypes: LeaveType[]; | ||||
} | } | ||||
const MAX_HOURS = 8; | |||||
const LeaveTable: React.FC<Props> = ({ leaveTypes }) => { | const LeaveTable: React.FC<Props> = ({ leaveTypes }) => { | ||||
const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
@@ -94,17 +93,22 @@ const DayRow: React.FC<{ | |||||
{shortDateFormatter(language).format(dayJsObj.toDate())} | {shortDateFormatter(language).format(dayJsObj.toDate())} | ||||
</TableCell> | </TableCell> | ||||
<TableCell | <TableCell | ||||
sx={{ color: totalHours > MAX_HOURS ? "error.main" : undefined }} | |||||
sx={{ | |||||
color: | |||||
totalHours > LEAVE_DAILY_MAX_HOURS ? "error.main" : undefined, | |||||
}} | |||||
> | > | ||||
{manhourFormatter.format(totalHours)} | {manhourFormatter.format(totalHours)} | ||||
{totalHours > MAX_HOURS && ( | |||||
{totalHours > LEAVE_DAILY_MAX_HOURS && ( | |||||
<Typography | <Typography | ||||
color="error.main" | color="error.main" | ||||
variant="body2" | variant="body2" | ||||
component="span" | component="span" | ||||
sx={{ marginInlineStart: 1 }} | sx={{ marginInlineStart: 1 }} | ||||
> | > | ||||
{t("(the daily total hours cannot be more than 8.)")} | |||||
{t("(the daily total hours cannot be more than {{hours}})", { | |||||
hours: LEAVE_DAILY_MAX_HOURS, | |||||
})} | |||||
</Typography> | </Typography> | ||||
)} | )} | ||||
</TableCell> | </TableCell> | ||||
@@ -19,11 +19,12 @@ import { | |||||
} from "@/app/api/timesheets/actions"; | } from "@/app/api/timesheets/actions"; | ||||
import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | ||||
import { AssignedProject } from "@/app/api/projects"; | |||||
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||||
interface Props { | interface Props { | ||||
isOpen: boolean; | isOpen: boolean; | ||||
onClose: () => void; | onClose: () => void; | ||||
allProjects: ProjectWithTasks[]; | |||||
assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
username: string; | username: string; | ||||
defaultTimesheets?: RecordTimesheetInput; | defaultTimesheets?: RecordTimesheetInput; | ||||
@@ -42,6 +43,7 @@ const modalSx: SxProps = { | |||||
const TimesheetModal: React.FC<Props> = ({ | const TimesheetModal: React.FC<Props> = ({ | ||||
isOpen, | isOpen, | ||||
onClose, | onClose, | ||||
allProjects, | |||||
assignedProjects, | assignedProjects, | ||||
username, | username, | ||||
defaultTimesheets, | defaultTimesheets, | ||||
@@ -106,7 +108,10 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
marginBlock: 4, | marginBlock: 4, | ||||
}} | }} | ||||
> | > | ||||
<TimesheetTable assignedProjects={assignedProjects} /> | |||||
<TimesheetTable | |||||
assignedProjects={assignedProjects} | |||||
allProjects={allProjects} | |||||
/> | |||||
</Box> | </Box> | ||||
<CardActions sx={{ justifyContent: "flex-end" }}> | <CardActions sx={{ justifyContent: "flex-end" }}> | ||||
<Button | <Button | ||||
@@ -5,6 +5,7 @@ import { | |||||
GridActionsCellItem, | GridActionsCellItem, | ||||
GridColDef, | GridColDef, | ||||
GridEventListener, | GridEventListener, | ||||
GridRenderEditCellParams, | |||||
GridRowId, | GridRowId, | ||||
GridRowModel, | GridRowModel, | ||||
GridRowModes, | GridRowModes, | ||||
@@ -18,31 +19,40 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||||
import { useFormContext } from "react-hook-form"; | import { useFormContext } from "react-hook-form"; | ||||
import { RecordTimesheetInput, TimeEntry } from "@/app/api/timesheets/actions"; | import { RecordTimesheetInput, TimeEntry } from "@/app/api/timesheets/actions"; | ||||
import { manhourFormatter } from "@/app/utils/formatUtil"; | import { manhourFormatter } from "@/app/utils/formatUtil"; | ||||
import { AssignedProject } from "@/app/api/projects"; | |||||
import { AssignedProject, ProjectWithTasks } 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"; | import dayjs from "dayjs"; | ||||
import isBetween from "dayjs/plugin/isBetween"; | import isBetween from "dayjs/plugin/isBetween"; | ||||
import ProjectSelect from "./ProjectSelect"; | |||||
import TaskGroupSelect from "./TaskGroupSelect"; | |||||
import TaskSelect from "./TaskSelect"; | |||||
import { isValidTimeEntry } from "@/app/api/timesheets/utils"; | |||||
dayjs.extend(isBetween); | dayjs.extend(isBetween); | ||||
interface Props { | interface Props { | ||||
day: string; | day: string; | ||||
allProjects: ProjectWithTasks[]; | |||||
assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
} | } | ||||
type TimeEntryRow = Partial< | |||||
export type TimeEntryRow = Partial< | |||||
TimeEntry & { | TimeEntry & { | ||||
_isNew: boolean; | _isNew: boolean; | ||||
_error: string; | _error: string; | ||||
isPlanned: boolean; | |||||
isPlanned?: boolean; | |||||
} | } | ||||
>; | >; | ||||
const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||||
const EntryInputTable: React.FC<Props> = ({ | |||||
day, | |||||
allProjects, | |||||
assignedProjects, | |||||
}) => { | |||||
const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
const taskGroupsByProject = useMemo(() => { | const taskGroupsByProject = useMemo(() => { | ||||
return assignedProjects.reduce<{ | |||||
return allProjects.reduce<{ | |||||
[projectId: AssignedProject["id"]]: { | [projectId: AssignedProject["id"]]: { | ||||
value: TaskGroup["id"]; | value: TaskGroup["id"]; | ||||
label: string; | label: string; | ||||
@@ -59,7 +69,7 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||||
), | ), | ||||
}; | }; | ||||
}, {}); | }, {}); | ||||
}, [assignedProjects]); | |||||
}, [allProjects]); | |||||
// To check for start / end planned dates | // To check for start / end planned dates | ||||
const milestonesByProject = useMemo(() => { | const milestonesByProject = useMemo(() => { | ||||
@@ -94,20 +104,10 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||||
"", | "", | ||||
) as TimeEntryRow; | ) as TimeEntryRow; | ||||
// Test for errrors | |||||
let error: keyof TimeEntry | "" = ""; | |||||
if (!row.projectId) { | |||||
error = "projectId"; | |||||
} else if (!row.taskGroupId) { | |||||
error = "taskGroupId"; | |||||
} else if (!row.taskId) { | |||||
error = "taskId"; | |||||
} else if (!row.inputHours || !(row.inputHours >= 0)) { | |||||
error = "inputHours"; | |||||
} | |||||
const error = isValidTimeEntry(row); | |||||
// Test for warnings | // Test for warnings | ||||
let isPlanned = false; | |||||
let isPlanned; | |||||
if ( | if ( | ||||
row.projectId && | row.projectId && | ||||
row.taskGroupId && | row.taskGroupId && | ||||
@@ -211,14 +211,28 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||||
{ | { | ||||
field: "projectId", | field: "projectId", | ||||
headerName: t("Project Code and Name"), | headerName: t("Project Code and Name"), | ||||
width: 200, | |||||
width: 400, | |||||
editable: true, | editable: true, | ||||
type: "singleSelect", | |||||
valueOptions() { | |||||
return assignedProjects.map((p) => ({ value: p.id, label: p.name })); | |||||
valueFormatter(params) { | |||||
const project = assignedProjects.find((p) => p.id === params.value); | |||||
return project ? `${project.code} - ${project.name}` : t("None"); | |||||
}, | }, | ||||
valueGetter({ value }) { | |||||
return value ?? ""; | |||||
renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) { | |||||
return ( | |||||
<ProjectSelect | |||||
allProjects={allProjects} | |||||
assignedProjects={assignedProjects} | |||||
value={params.value} | |||||
onProjectSelect={(projectId) => { | |||||
params.api.setEditCellValue({ | |||||
id: params.id, | |||||
field: params.field, | |||||
value: projectId, | |||||
}); | |||||
params.api.setCellFocus(params.id, "taskGroupId"); | |||||
}} | |||||
/> | |||||
); | |||||
}, | }, | ||||
}, | }, | ||||
{ | { | ||||
@@ -226,27 +240,29 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||||
headerName: t("Stage"), | headerName: t("Stage"), | ||||
width: 200, | width: 200, | ||||
editable: true, | editable: true, | ||||
type: "singleSelect", | |||||
valueGetter({ value }) { | |||||
return value ?? ""; | |||||
}, | |||||
valueOptions(params) { | |||||
const updatedRow = params.id | |||||
? apiRef.current.getRowWithUpdatedValues(params.id, "") | |||||
: null; | |||||
if (!updatedRow) { | |||||
return []; | |||||
} | |||||
const projectInfo = assignedProjects.find( | |||||
(p) => p.id === updatedRow.projectId, | |||||
renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) { | |||||
return ( | |||||
<TaskGroupSelect | |||||
projectId={params.row.projectId} | |||||
value={params.value} | |||||
taskGroupsByProject={taskGroupsByProject} | |||||
onTaskGroupSelect={(taskGroupId) => { | |||||
params.api.setEditCellValue({ | |||||
id: params.id, | |||||
field: params.field, | |||||
value: taskGroupId, | |||||
}); | |||||
params.api.setCellFocus(params.id, "taskId"); | |||||
}} | |||||
/> | |||||
); | ); | ||||
if (!projectInfo) { | |||||
return []; | |||||
} | |||||
return taskGroupsByProject[projectInfo.id]; | |||||
}, | |||||
valueFormatter(params) { | |||||
const taskGroups = params.id | |||||
? taskGroupsByProject[params.api.getRow(params.id).projectId] || [] | |||||
: []; | |||||
const taskGroup = taskGroups.find((tg) => tg.value === params.value); | |||||
return taskGroup ? taskGroup.label : t("None"); | |||||
}, | }, | ||||
}, | }, | ||||
{ | { | ||||
@@ -254,32 +270,37 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||||
headerName: t("Task"), | headerName: t("Task"), | ||||
width: 200, | width: 200, | ||||
editable: true, | editable: true, | ||||
type: "singleSelect", | |||||
valueGetter({ value }) { | |||||
return value ?? ""; | |||||
}, | |||||
valueOptions(params) { | |||||
const updatedRow = params.id | |||||
? apiRef.current.getRowWithUpdatedValues(params.id, "") | |||||
: null; | |||||
if (!updatedRow) { | |||||
return []; | |||||
} | |||||
const projectInfo = assignedProjects.find( | |||||
(p) => p.id === updatedRow.projectId, | |||||
renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) { | |||||
return ( | |||||
<TaskSelect | |||||
value={params.value} | |||||
projectId={params.row.projectId} | |||||
taskGroupId={params.row.taskGroupId} | |||||
allProjects={allProjects} | |||||
editCellProps={params} | |||||
onTaskSelect={(taskId) => { | |||||
params.api.setEditCellValue({ | |||||
id: params.id, | |||||
field: params.field, | |||||
value: taskId, | |||||
}); | |||||
params.api.setCellFocus(params.id, "inputHours"); | |||||
}} | |||||
/> | |||||
); | ); | ||||
}, | |||||
valueFormatter(params) { | |||||
const projectId = params.id | |||||
? params.api.getRow(params.id).projectId | |||||
: undefined; | |||||
if (!projectInfo) { | |||||
return []; | |||||
} | |||||
const task = projectId | |||||
? assignedProjects | |||||
.find((p) => p.id === projectId) | |||||
?.tasks.find((t) => t.id === params.value) | |||||
: undefined; | |||||
return projectInfo.tasks | |||||
.filter((t) => t.taskGroup.id === updatedRow.taskGroupId) | |||||
.map((t) => ({ | |||||
value: t.id, | |||||
label: t.name, | |||||
})); | |||||
return task ? task.name : t("None"); | |||||
}, | }, | ||||
}, | }, | ||||
{ | { | ||||
@@ -292,6 +313,13 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||||
return manhourFormatter.format(params.value); | return manhourFormatter.format(params.value); | ||||
}, | }, | ||||
}, | }, | ||||
{ | |||||
field: "remark", | |||||
headerName: t("Remark"), | |||||
sortable: false, | |||||
flex: 1, | |||||
editable: true, | |||||
}, | |||||
], | ], | ||||
[ | [ | ||||
t, | t, | ||||
@@ -299,31 +327,20 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||||
handleDelete, | handleDelete, | ||||
handleSave, | handleSave, | ||||
handleCancel, | handleCancel, | ||||
apiRef, | |||||
taskGroupsByProject, | |||||
assignedProjects, | assignedProjects, | ||||
allProjects, | |||||
taskGroupsByProject, | |||||
], | ], | ||||
); | ); | ||||
useEffect(() => { | useEffect(() => { | ||||
setValue(day, [ | setValue(day, [ | ||||
...entries | ...entries | ||||
.filter( | |||||
(e) => | |||||
!e._isNew && | |||||
!e._error && | |||||
e.inputHours && | |||||
e.projectId && | |||||
e.taskId && | |||||
e.taskGroupId && | |||||
e.id, | |||||
) | |||||
.map((e) => ({ | |||||
id: e.id!, | |||||
inputHours: e.inputHours!, | |||||
projectId: e.projectId!, | |||||
taskId: e.taskId!, | |||||
taskGroupId: e.taskGroupId!, | |||||
.filter((e) => !e._isNew && !e._error && e.id && e.inputHours) | |||||
.map(({ isPlanned, _error, _isNew, ...entry }) => ({ | |||||
id: entry.id!, | |||||
inputHours: entry.inputHours!, | |||||
...entry, | |||||
})), | })), | ||||
]); | ]); | ||||
}, [getValues, entries, setValue, day]); | }, [getValues, entries, setValue, day]); | ||||
@@ -0,0 +1,89 @@ | |||||
import React, { useCallback, useMemo } from "react"; | |||||
import { | |||||
ListSubheader, | |||||
MenuItem, | |||||
Select, | |||||
SelectChangeEvent, | |||||
} from "@mui/material"; | |||||
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||||
import { useTranslation } from "react-i18next"; | |||||
import differenceBy from "lodash/differenceBy"; | |||||
interface Props { | |||||
allProjects: ProjectWithTasks[]; | |||||
assignedProjects: AssignedProject[]; | |||||
value: number | undefined; | |||||
onProjectSelect: (projectId: number | string) => void; | |||||
} | |||||
const ProjectSelect: React.FC<Props> = ({ | |||||
allProjects, | |||||
assignedProjects, | |||||
value, | |||||
onProjectSelect, | |||||
}) => { | |||||
const { t } = useTranslation("home"); | |||||
const nonAssignedProjects = useMemo(() => { | |||||
return differenceBy(allProjects, assignedProjects, "id"); | |||||
}, [allProjects, assignedProjects]); | |||||
const onChange = useCallback( | |||||
(event: SelectChangeEvent<number>) => { | |||||
const newValue = event.target.value; | |||||
onProjectSelect(newValue); | |||||
}, | |||||
[onProjectSelect], | |||||
); | |||||
return ( | |||||
<Select | |||||
displayEmpty | |||||
value={value || ""} | |||||
onChange={onChange} | |||||
sx={{ width: "100%" }} | |||||
MenuProps={{ | |||||
slotProps: { | |||||
paper: { | |||||
sx: { maxHeight: 400 }, | |||||
}, | |||||
}, | |||||
anchorOrigin: { | |||||
vertical: "bottom", | |||||
horizontal: "left", | |||||
}, | |||||
transformOrigin: { | |||||
vertical: "top", | |||||
horizontal: "left", | |||||
}, | |||||
}} | |||||
> | |||||
<ListSubheader>{t("Non-billable")}</ListSubheader> | |||||
<MenuItem value={""}>{t("None")}</MenuItem> | |||||
{assignedProjects.length > 0 && [ | |||||
<ListSubheader key="assignedProjectsSubHeader"> | |||||
{t("Assigned Projects")} | |||||
</ListSubheader>, | |||||
...assignedProjects.map((project) => ( | |||||
<MenuItem | |||||
key={project.id} | |||||
value={project.id} | |||||
>{`${project.code} - ${project.name}`}</MenuItem> | |||||
)), | |||||
]} | |||||
{nonAssignedProjects.length > 0 && [ | |||||
<ListSubheader key="nonAssignedProjectsSubHeader"> | |||||
{t("Non-assigned Projects")} | |||||
</ListSubheader>, | |||||
...nonAssignedProjects.map((project) => ( | |||||
<MenuItem | |||||
key={project.id} | |||||
value={project.id} | |||||
>{`${project.code} - ${project.name}`}</MenuItem> | |||||
)), | |||||
]} | |||||
</Select> | |||||
); | |||||
}; | |||||
export default ProjectSelect; |
@@ -0,0 +1,69 @@ | |||||
import React, { useCallback } from "react"; | |||||
import { MenuItem, Select, SelectChangeEvent } from "@mui/material"; | |||||
import { useTranslation } from "react-i18next"; | |||||
import { TaskGroup } from "@/app/api/tasks"; | |||||
interface Props { | |||||
taskGroupsByProject: { | |||||
[projectId: number]: { | |||||
value: TaskGroup["id"]; | |||||
label: string; | |||||
}[]; | |||||
}; | |||||
projectId: number | undefined; | |||||
value: number | undefined; | |||||
onTaskGroupSelect: (taskGroupId: number | string) => void; | |||||
} | |||||
const TaskGroupSelect: React.FC<Props> = ({ | |||||
value, | |||||
projectId, | |||||
onTaskGroupSelect, | |||||
taskGroupsByProject, | |||||
}) => { | |||||
const { t } = useTranslation("home"); | |||||
const taskGroups = projectId ? taskGroupsByProject[projectId] : []; | |||||
const onChange = useCallback( | |||||
(event: SelectChangeEvent<number>) => { | |||||
const newValue = event.target.value; | |||||
onTaskGroupSelect(newValue); | |||||
}, | |||||
[onTaskGroupSelect], | |||||
); | |||||
return ( | |||||
<Select | |||||
displayEmpty | |||||
disabled={taskGroups.length === 0} | |||||
value={value || ""} | |||||
onChange={onChange} | |||||
sx={{ width: "100%" }} | |||||
MenuProps={{ | |||||
slotProps: { | |||||
paper: { | |||||
sx: { maxHeight: 400 }, | |||||
}, | |||||
}, | |||||
anchorOrigin: { | |||||
vertical: "bottom", | |||||
horizontal: "left", | |||||
}, | |||||
transformOrigin: { | |||||
vertical: "top", | |||||
horizontal: "left", | |||||
}, | |||||
}} | |||||
> | |||||
{taskGroups.length === 0 && <MenuItem value={""}>{t("None")}</MenuItem>} | |||||
{taskGroups.map((taskGroup) => ( | |||||
<MenuItem key={taskGroup.value} value={taskGroup.value}> | |||||
{taskGroup.label} | |||||
</MenuItem> | |||||
))} | |||||
</Select> | |||||
); | |||||
}; | |||||
export default TaskGroupSelect; |
@@ -0,0 +1,72 @@ | |||||
import React, { useCallback } from "react"; | |||||
import { MenuItem, Select, SelectChangeEvent } from "@mui/material"; | |||||
import { GridRenderEditCellParams } from "@mui/x-data-grid"; | |||||
import { TimeEntryRow } from "./EntryInputTable"; | |||||
import { useTranslation } from "react-i18next"; | |||||
import { ProjectWithTasks } from "@/app/api/projects"; | |||||
interface Props { | |||||
allProjects: ProjectWithTasks[]; | |||||
value: number | undefined; | |||||
projectId: number | undefined; | |||||
taskGroupId: number | undefined; | |||||
editCellProps: GridRenderEditCellParams<TimeEntryRow, number>; | |||||
onTaskSelect: (taskId: number | string) => void; | |||||
} | |||||
const TaskSelect: React.FC<Props> = ({ | |||||
value, | |||||
allProjects, | |||||
projectId, | |||||
taskGroupId, | |||||
onTaskSelect, | |||||
}) => { | |||||
const { t } = useTranslation("home"); | |||||
const project = allProjects.find((p) => p.id === projectId); | |||||
const tasks = project | |||||
? project.tasks.filter((task) => task.taskGroup.id === taskGroupId) | |||||
: []; | |||||
const onChange = useCallback( | |||||
(event: SelectChangeEvent<number>) => { | |||||
const newValue = event.target.value; | |||||
onTaskSelect(newValue); | |||||
}, | |||||
[onTaskSelect], | |||||
); | |||||
return ( | |||||
<Select | |||||
displayEmpty | |||||
disabled={tasks.length === 0} | |||||
value={value || ""} | |||||
onChange={onChange} | |||||
sx={{ width: "100%" }} | |||||
MenuProps={{ | |||||
slotProps: { | |||||
paper: { | |||||
sx: { maxHeight: 400 }, | |||||
}, | |||||
}, | |||||
anchorOrigin: { | |||||
vertical: "bottom", | |||||
horizontal: "left", | |||||
}, | |||||
transformOrigin: { | |||||
vertical: "top", | |||||
horizontal: "left", | |||||
}, | |||||
}} | |||||
> | |||||
{tasks.length === 0 && <MenuItem value={""}>{t("None")}</MenuItem>} | |||||
{tasks.map((task) => ( | |||||
<MenuItem key={task.id} value={task.id}> | |||||
{task.name} | |||||
</MenuItem> | |||||
))} | |||||
</Select> | |||||
); | |||||
}; | |||||
export default TaskSelect; |
@@ -18,13 +18,15 @@ import React, { useState } from "react"; | |||||
import { useFormContext } from "react-hook-form"; | import { useFormContext } from "react-hook-form"; | ||||
import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
import EntryInputTable from "./EntryInputTable"; | import EntryInputTable from "./EntryInputTable"; | ||||
import { AssignedProject } from "@/app/api/projects"; | |||||
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||||
import { TIMESHEET_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils"; | |||||
interface Props { | interface Props { | ||||
allProjects: ProjectWithTasks[]; | |||||
assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
} | } | ||||
const TimesheetTable: React.FC<Props> = ({ assignedProjects }) => { | |||||
const TimesheetTable: React.FC<Props> = ({ allProjects, assignedProjects }) => { | |||||
const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
const { watch } = useFormContext<RecordTimesheetInput>(); | const { watch } = useFormContext<RecordTimesheetInput>(); | ||||
@@ -49,6 +51,7 @@ const TimesheetTable: React.FC<Props> = ({ assignedProjects }) => { | |||||
key={`${day}${index}`} | key={`${day}${index}`} | ||||
day={day} | day={day} | ||||
entries={entries} | entries={entries} | ||||
allProjects={allProjects} | |||||
assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
/> | /> | ||||
); | ); | ||||
@@ -62,8 +65,9 @@ const TimesheetTable: React.FC<Props> = ({ assignedProjects }) => { | |||||
const DayRow: React.FC<{ | const DayRow: React.FC<{ | ||||
day: string; | day: string; | ||||
entries: TimeEntry[]; | entries: TimeEntry[]; | ||||
allProjects: ProjectWithTasks[]; | |||||
assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
}> = ({ day, entries, assignedProjects }) => { | |||||
}> = ({ day, entries, allProjects, assignedProjects }) => { | |||||
const { | const { | ||||
t, | t, | ||||
i18n: { language }, | i18n: { language }, | ||||
@@ -91,16 +95,23 @@ const DayRow: React.FC<{ | |||||
> | > | ||||
{shortDateFormatter(language).format(dayJsObj.toDate())} | {shortDateFormatter(language).format(dayJsObj.toDate())} | ||||
</TableCell> | </TableCell> | ||||
<TableCell sx={{ color: totalHours > 20 ? "error.main" : undefined }}> | |||||
<TableCell | |||||
sx={{ | |||||
color: | |||||
totalHours > TIMESHEET_DAILY_MAX_HOURS ? "error.main" : undefined, | |||||
}} | |||||
> | |||||
{manhourFormatter.format(totalHours)} | {manhourFormatter.format(totalHours)} | ||||
{totalHours > 20 && ( | |||||
{totalHours > TIMESHEET_DAILY_MAX_HOURS && ( | |||||
<Typography | <Typography | ||||
color="error.main" | color="error.main" | ||||
variant="body2" | variant="body2" | ||||
component="span" | component="span" | ||||
sx={{ marginInlineStart: 1 }} | sx={{ marginInlineStart: 1 }} | ||||
> | > | ||||
{t("(the daily total hours cannot be more than 20.)")} | |||||
{t("(the daily total hours cannot be more than {{hours}})", { | |||||
hours: TIMESHEET_DAILY_MAX_HOURS, | |||||
})} | |||||
</Typography> | </Typography> | ||||
)} | )} | ||||
</TableCell> | </TableCell> | ||||
@@ -117,7 +128,11 @@ const DayRow: React.FC<{ | |||||
> | > | ||||
<Collapse in={open} timeout="auto" unmountOnExit> | <Collapse in={open} timeout="auto" unmountOnExit> | ||||
<Box> | <Box> | ||||
<EntryInputTable day={day} assignedProjects={assignedProjects} /> | |||||
<EntryInputTable | |||||
day={day} | |||||
assignedProjects={assignedProjects} | |||||
allProjects={allProjects} | |||||
/> | |||||
</Box> | </Box> | ||||
</Collapse> | </Collapse> | ||||
</TableCell> | </TableCell> | ||||
@@ -9,7 +9,7 @@ import { Typography } from "@mui/material"; | |||||
import ButtonGroup from "@mui/material/ButtonGroup"; | import ButtonGroup from "@mui/material/ButtonGroup"; | ||||
import AssignedProjects from "./AssignedProjects"; | import AssignedProjects from "./AssignedProjects"; | ||||
import TimesheetModal from "../TimesheetModal"; | import TimesheetModal from "../TimesheetModal"; | ||||
import { AssignedProject } from "@/app/api/projects"; | |||||
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||||
import { | import { | ||||
RecordLeaveInput, | RecordLeaveInput, | ||||
RecordTimesheetInput, | RecordTimesheetInput, | ||||
@@ -19,6 +19,7 @@ import { LeaveType } from "@/app/api/timesheets"; | |||||
export interface Props { | export interface Props { | ||||
leaveTypes: LeaveType[]; | leaveTypes: LeaveType[]; | ||||
allProjects: ProjectWithTasks[]; | |||||
assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
username: string; | username: string; | ||||
defaultLeaveRecords: RecordLeaveInput; | defaultLeaveRecords: RecordLeaveInput; | ||||
@@ -27,6 +28,7 @@ export interface Props { | |||||
const UserWorkspacePage: React.FC<Props> = ({ | const UserWorkspacePage: React.FC<Props> = ({ | ||||
leaveTypes, | leaveTypes, | ||||
allProjects, | |||||
assignedProjects, | assignedProjects, | ||||
username, | username, | ||||
defaultLeaveRecords, | defaultLeaveRecords, | ||||
@@ -82,6 +84,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
<TimesheetModal | <TimesheetModal | ||||
isOpen={isTimeheetModalVisible} | isOpen={isTimeheetModalVisible} | ||||
onClose={handleCloseTimesheetModal} | onClose={handleCloseTimesheetModal} | ||||
allProjects={allProjects} | |||||
assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
username={username} | username={username} | ||||
defaultTimesheets={defaultTimesheets} | defaultTimesheets={defaultTimesheets} | ||||
@@ -1,4 +1,7 @@ | |||||
import { fetchAssignedProjects } from "@/app/api/projects"; | |||||
import { | |||||
fetchAssignedProjects, | |||||
fetchProjectWithTasks, | |||||
} from "@/app/api/projects"; | |||||
import UserWorkspacePage from "./UserWorkspacePage"; | import UserWorkspacePage from "./UserWorkspacePage"; | ||||
import { | import { | ||||
fetchLeaveTypes, | fetchLeaveTypes, | ||||
@@ -11,15 +14,18 @@ interface Props { | |||||
} | } | ||||
const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => { | const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => { | ||||
const [assignedProjects, timesheets, leaves, leaveTypes] = await Promise.all([ | |||||
fetchAssignedProjects(username), | |||||
fetchTimesheets(username), | |||||
fetchLeaves(username), | |||||
fetchLeaveTypes(), | |||||
]); | |||||
const [assignedProjects, allProjects, timesheets, leaves, leaveTypes] = | |||||
await Promise.all([ | |||||
fetchAssignedProjects(username), | |||||
fetchProjectWithTasks(), | |||||
fetchTimesheets(username), | |||||
fetchLeaves(username), | |||||
fetchLeaveTypes(), | |||||
]); | |||||
return ( | return ( | ||||
<UserWorkspacePage | <UserWorkspacePage | ||||
allProjects={allProjects} | |||||
assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
username={username} | username={username} | ||||
defaultTimesheets={timesheets} | defaultTimesheets={timesheets} | ||||