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