@@ -8,7 +8,10 @@ import { | |||
} from "@/app/api/timesheets"; | |||
import { authOptions } from "@/config/authConfig"; | |||
import { getServerSession } from "next-auth"; | |||
import { fetchAssignedProjects } from "@/app/api/projects"; | |||
import { | |||
fetchAssignedProjects, | |||
fetchProjectWithTasks, | |||
} from "@/app/api/projects"; | |||
export const metadata: Metadata = { | |||
title: "User Workspace", | |||
@@ -23,6 +26,7 @@ const Home: React.FC = async () => { | |||
fetchAssignedProjects(username); | |||
fetchLeaves(username); | |||
fetchLeaveTypes(); | |||
fetchProjectWithTasks(); | |||
return ( | |||
<I18nProvider namespaces={["home"]}> | |||
@@ -49,7 +49,7 @@ export interface WorkNature { | |||
name: string; | |||
} | |||
export interface AssignedProject { | |||
export interface ProjectWithTasks { | |||
id: number; | |||
code: string; | |||
name: string; | |||
@@ -60,6 +60,9 @@ export interface AssignedProject { | |||
endDate?: string; | |||
}; | |||
}; | |||
} | |||
export interface AssignedProject extends ProjectWithTasks { | |||
// Manhour info | |||
hoursSpent: 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) => { | |||
return serverFetchJson<CreateProjectInputs>( | |||
`${BASE_API_URL}/projects/projectDetails/${projectId}`, | |||
@@ -8,10 +8,11 @@ import { revalidateTag } from "next/cache"; | |||
export interface TimeEntry { | |||
id: number; | |||
projectId: ProjectResult["id"]; | |||
taskGroupId: TaskGroup["id"]; | |||
taskId: Task["id"]; | |||
projectId?: ProjectResult["id"]; | |||
taskGroupId?: TaskGroup["id"]; | |||
taskId?: Task["id"]; | |||
inputHours: number; | |||
remark?: string; | |||
} | |||
export interface RecordTimesheetInput { | |||
@@ -22,6 +23,7 @@ export interface LeaveEntry { | |||
id: number; | |||
inputHours: number; | |||
leaveTypeId: number; | |||
remark?: string; | |||
} | |||
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 isBetween from "dayjs/plugin/isBetween"; | |||
import { LeaveType } from "@/app/api/timesheets"; | |||
import { isValidLeaveEntry } from "@/app/api/timesheets/utils"; | |||
dayjs.extend(isBetween); | |||
@@ -63,13 +64,7 @@ const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => { | |||
"", | |||
) 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 }]); | |||
return !error; | |||
@@ -182,6 +177,13 @@ const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => { | |||
return manhourFormatter.format(params.value); | |||
}, | |||
}, | |||
{ | |||
field: "remark", | |||
headerName: t("Remark"), | |||
sortable: false, | |||
flex: 1, | |||
editable: true, | |||
}, | |||
], | |||
[t, rowModesModel, handleDelete, handleSave, handleCancel, leaveTypes], | |||
); | |||
@@ -197,6 +199,7 @@ const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => { | |||
id: e.id!, | |||
inputHours: e.inputHours!, | |||
leaveTypeId: e.leaveTypeId!, | |||
remark: e.remark, | |||
})), | |||
]); | |||
}, [getValues, entries, setValue, day]); | |||
@@ -19,13 +19,12 @@ import { useFormContext } from "react-hook-form"; | |||
import { useTranslation } from "react-i18next"; | |||
import LeaveEntryTable from "./LeaveEntryTable"; | |||
import { LeaveType } from "@/app/api/timesheets"; | |||
import { LEAVE_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils"; | |||
interface Props { | |||
leaveTypes: LeaveType[]; | |||
} | |||
const MAX_HOURS = 8; | |||
const LeaveTable: React.FC<Props> = ({ leaveTypes }) => { | |||
const { t } = useTranslation("home"); | |||
@@ -94,17 +93,22 @@ const DayRow: React.FC<{ | |||
{shortDateFormatter(language).format(dayJsObj.toDate())} | |||
</TableCell> | |||
<TableCell | |||
sx={{ color: totalHours > MAX_HOURS ? "error.main" : undefined }} | |||
sx={{ | |||
color: | |||
totalHours > LEAVE_DAILY_MAX_HOURS ? "error.main" : undefined, | |||
}} | |||
> | |||
{manhourFormatter.format(totalHours)} | |||
{totalHours > MAX_HOURS && ( | |||
{totalHours > LEAVE_DAILY_MAX_HOURS && ( | |||
<Typography | |||
color="error.main" | |||
variant="body2" | |||
component="span" | |||
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> | |||
)} | |||
</TableCell> | |||
@@ -19,11 +19,12 @@ import { | |||
} from "@/app/api/timesheets/actions"; | |||
import dayjs from "dayjs"; | |||
import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
import { AssignedProject } from "@/app/api/projects"; | |||
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||
interface Props { | |||
isOpen: boolean; | |||
onClose: () => void; | |||
allProjects: ProjectWithTasks[]; | |||
assignedProjects: AssignedProject[]; | |||
username: string; | |||
defaultTimesheets?: RecordTimesheetInput; | |||
@@ -42,6 +43,7 @@ const modalSx: SxProps = { | |||
const TimesheetModal: React.FC<Props> = ({ | |||
isOpen, | |||
onClose, | |||
allProjects, | |||
assignedProjects, | |||
username, | |||
defaultTimesheets, | |||
@@ -106,7 +108,10 @@ const TimesheetModal: React.FC<Props> = ({ | |||
marginBlock: 4, | |||
}} | |||
> | |||
<TimesheetTable assignedProjects={assignedProjects} /> | |||
<TimesheetTable | |||
assignedProjects={assignedProjects} | |||
allProjects={allProjects} | |||
/> | |||
</Box> | |||
<CardActions sx={{ justifyContent: "flex-end" }}> | |||
<Button | |||
@@ -5,6 +5,7 @@ import { | |||
GridActionsCellItem, | |||
GridColDef, | |||
GridEventListener, | |||
GridRenderEditCellParams, | |||
GridRowId, | |||
GridRowModel, | |||
GridRowModes, | |||
@@ -18,31 +19,40 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
import { useFormContext } from "react-hook-form"; | |||
import { RecordTimesheetInput, TimeEntry } from "@/app/api/timesheets/actions"; | |||
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 { TaskGroup } from "@/app/api/tasks"; | |||
import dayjs from "dayjs"; | |||
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); | |||
interface Props { | |||
day: string; | |||
allProjects: ProjectWithTasks[]; | |||
assignedProjects: AssignedProject[]; | |||
} | |||
type TimeEntryRow = Partial< | |||
export type TimeEntryRow = Partial< | |||
TimeEntry & { | |||
_isNew: boolean; | |||
_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 taskGroupsByProject = useMemo(() => { | |||
return assignedProjects.reduce<{ | |||
return allProjects.reduce<{ | |||
[projectId: AssignedProject["id"]]: { | |||
value: TaskGroup["id"]; | |||
label: string; | |||
@@ -59,7 +69,7 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||
), | |||
}; | |||
}, {}); | |||
}, [assignedProjects]); | |||
}, [allProjects]); | |||
// To check for start / end planned dates | |||
const milestonesByProject = useMemo(() => { | |||
@@ -94,20 +104,10 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||
"", | |||
) 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 | |||
let isPlanned = false; | |||
let isPlanned; | |||
if ( | |||
row.projectId && | |||
row.taskGroupId && | |||
@@ -211,14 +211,28 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||
{ | |||
field: "projectId", | |||
headerName: t("Project Code and Name"), | |||
width: 200, | |||
width: 400, | |||
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"), | |||
width: 200, | |||
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"), | |||
width: 200, | |||
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); | |||
}, | |||
}, | |||
{ | |||
field: "remark", | |||
headerName: t("Remark"), | |||
sortable: false, | |||
flex: 1, | |||
editable: true, | |||
}, | |||
], | |||
[ | |||
t, | |||
@@ -299,31 +327,20 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||
handleDelete, | |||
handleSave, | |||
handleCancel, | |||
apiRef, | |||
taskGroupsByProject, | |||
assignedProjects, | |||
allProjects, | |||
taskGroupsByProject, | |||
], | |||
); | |||
useEffect(() => { | |||
setValue(day, [ | |||
...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]); | |||
@@ -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 { useTranslation } from "react-i18next"; | |||
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 { | |||
allProjects: ProjectWithTasks[]; | |||
assignedProjects: AssignedProject[]; | |||
} | |||
const TimesheetTable: React.FC<Props> = ({ assignedProjects }) => { | |||
const TimesheetTable: React.FC<Props> = ({ allProjects, assignedProjects }) => { | |||
const { t } = useTranslation("home"); | |||
const { watch } = useFormContext<RecordTimesheetInput>(); | |||
@@ -49,6 +51,7 @@ const TimesheetTable: React.FC<Props> = ({ assignedProjects }) => { | |||
key={`${day}${index}`} | |||
day={day} | |||
entries={entries} | |||
allProjects={allProjects} | |||
assignedProjects={assignedProjects} | |||
/> | |||
); | |||
@@ -62,8 +65,9 @@ const TimesheetTable: React.FC<Props> = ({ assignedProjects }) => { | |||
const DayRow: React.FC<{ | |||
day: string; | |||
entries: TimeEntry[]; | |||
allProjects: ProjectWithTasks[]; | |||
assignedProjects: AssignedProject[]; | |||
}> = ({ day, entries, assignedProjects }) => { | |||
}> = ({ day, entries, allProjects, assignedProjects }) => { | |||
const { | |||
t, | |||
i18n: { language }, | |||
@@ -91,16 +95,23 @@ const DayRow: React.FC<{ | |||
> | |||
{shortDateFormatter(language).format(dayJsObj.toDate())} | |||
</TableCell> | |||
<TableCell sx={{ color: totalHours > 20 ? "error.main" : undefined }}> | |||
<TableCell | |||
sx={{ | |||
color: | |||
totalHours > TIMESHEET_DAILY_MAX_HOURS ? "error.main" : undefined, | |||
}} | |||
> | |||
{manhourFormatter.format(totalHours)} | |||
{totalHours > 20 && ( | |||
{totalHours > TIMESHEET_DAILY_MAX_HOURS && ( | |||
<Typography | |||
color="error.main" | |||
variant="body2" | |||
component="span" | |||
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> | |||
)} | |||
</TableCell> | |||
@@ -117,7 +128,11 @@ const DayRow: React.FC<{ | |||
> | |||
<Collapse in={open} timeout="auto" unmountOnExit> | |||
<Box> | |||
<EntryInputTable day={day} assignedProjects={assignedProjects} /> | |||
<EntryInputTable | |||
day={day} | |||
assignedProjects={assignedProjects} | |||
allProjects={allProjects} | |||
/> | |||
</Box> | |||
</Collapse> | |||
</TableCell> | |||
@@ -9,7 +9,7 @@ import { Typography } from "@mui/material"; | |||
import ButtonGroup from "@mui/material/ButtonGroup"; | |||
import AssignedProjects from "./AssignedProjects"; | |||
import TimesheetModal from "../TimesheetModal"; | |||
import { AssignedProject } from "@/app/api/projects"; | |||
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||
import { | |||
RecordLeaveInput, | |||
RecordTimesheetInput, | |||
@@ -19,6 +19,7 @@ import { LeaveType } from "@/app/api/timesheets"; | |||
export interface Props { | |||
leaveTypes: LeaveType[]; | |||
allProjects: ProjectWithTasks[]; | |||
assignedProjects: AssignedProject[]; | |||
username: string; | |||
defaultLeaveRecords: RecordLeaveInput; | |||
@@ -27,6 +28,7 @@ export interface Props { | |||
const UserWorkspacePage: React.FC<Props> = ({ | |||
leaveTypes, | |||
allProjects, | |||
assignedProjects, | |||
username, | |||
defaultLeaveRecords, | |||
@@ -82,6 +84,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
<TimesheetModal | |||
isOpen={isTimeheetModalVisible} | |||
onClose={handleCloseTimesheetModal} | |||
allProjects={allProjects} | |||
assignedProjects={assignedProjects} | |||
username={username} | |||
defaultTimesheets={defaultTimesheets} | |||
@@ -1,4 +1,7 @@ | |||
import { fetchAssignedProjects } from "@/app/api/projects"; | |||
import { | |||
fetchAssignedProjects, | |||
fetchProjectWithTasks, | |||
} from "@/app/api/projects"; | |||
import UserWorkspacePage from "./UserWorkspacePage"; | |||
import { | |||
fetchLeaveTypes, | |||
@@ -11,15 +14,18 @@ interface Props { | |||
} | |||
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 ( | |||
<UserWorkspacePage | |||
allProjects={allProjects} | |||
assignedProjects={assignedProjects} | |||
username={username} | |||
defaultTimesheets={timesheets} | |||