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