From 77b42048dc877477d8d092e601b1a3b65c774e7a Mon Sep 17 00:00:00 2001 From: Wayne Date: Wed, 24 Jul 2024 00:06:11 +0900 Subject: [PATCH] Add client-side support for task selection for non-billable project --- src/app/(main)/home/page.tsx | 2 + src/app/api/tasks/index.ts | 4 +- src/app/api/timesheets/utils.ts | 4 +- .../TimeLeaveModal/TimeLeaveInputTable.tsx | 101 +++++++++++++----- .../TimeLeaveModal/TimeLeaveMobileEntry.tsx | 9 +- .../TimeLeaveModal/TimeLeaveModal.tsx | 7 +- .../TimesheetTable/FastTimeEntryModal.tsx | 6 +- .../TimesheetTable/TaskGroupSelect.tsx | 76 ++++++++++--- src/components/TimesheetTable/TaskSelect.tsx | 11 +- .../TimesheetTable/TimesheetEditModal.tsx | 77 ++++++++----- .../UserWorkspacePage/UserWorkspacePage.tsx | 4 + .../UserWorkspaceWrapper.tsx | 6 ++ 12 files changed, 227 insertions(+), 80 deletions(-) diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx index a86ab12..4102f94 100644 --- a/src/app/(main)/home/page.tsx +++ b/src/app/(main)/home/page.tsx @@ -13,6 +13,7 @@ import { fetchProjectWithTasks, } from "@/app/api/projects"; import { fetchHolidays } from "@/app/api/holidays"; +import { fetchAllTasks } from "@/app/api/tasks"; export const metadata: Metadata = { title: "User Workspace", @@ -27,6 +28,7 @@ const Home: React.FC = async () => { fetchHolidays(); fetchTeamMemberTimesheets(); fetchTeamMemberLeaves(); + fetchAllTasks(); return ( diff --git a/src/app/api/tasks/index.ts b/src/app/api/tasks/index.ts index 675ff1b..97ce342 100644 --- a/src/app/api/tasks/index.ts +++ b/src/app/api/tasks/index.ts @@ -51,7 +51,9 @@ export const preloadAllTasks = () => { }; export const fetchAllTasks = cache(async () => { - return serverFetchJson(`${BASE_API_URL}/tasks`); + return serverFetchJson(`${BASE_API_URL}/tasks`, { + next: { tags: ["tasks"] }, + }); }); export const fetchTaskTemplateDetail = cache(async (id: string) => { diff --git a/src/app/api/timesheets/utils.ts b/src/app/api/timesheets/utils.ts index 10b128b..94c0dc6 100644 --- a/src/app/api/timesheets/utils.ts +++ b/src/app/api/timesheets/utils.ts @@ -43,7 +43,9 @@ export const validateTimeEntry = ( error.taskId = "Required"; } } else { - if (!entry.remark) { + if (entry.taskGroupId && !entry.taskId) { + error.taskId = "Required"; + } else if (!entry.remark) { error.remark = "Required for non-billable tasks"; } } diff --git a/src/components/TimeLeaveModal/TimeLeaveInputTable.tsx b/src/components/TimeLeaveModal/TimeLeaveInputTable.tsx index 2440741..e623112 100644 --- a/src/components/TimeLeaveModal/TimeLeaveInputTable.tsx +++ b/src/components/TimeLeaveModal/TimeLeaveInputTable.tsx @@ -27,11 +27,13 @@ import { import { manhourFormatter } from "@/app/utils/formatUtil"; import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; import uniqBy from "lodash/uniqBy"; -import { TaskGroup } from "@/app/api/tasks"; +import { Task, TaskGroup } from "@/app/api/tasks"; import dayjs from "dayjs"; import isBetween from "dayjs/plugin/isBetween"; import ProjectSelect from "../TimesheetTable/ProjectSelect"; -import TaskGroupSelect from "../TimesheetTable/TaskGroupSelect"; +import TaskGroupSelect, { + TaskGroupSelectWithoutProject, +} from "../TimesheetTable/TaskGroupSelect"; import TaskSelect from "../TimesheetTable/TaskSelect"; import { DAILY_NORMAL_MAX_HOURS, @@ -54,6 +56,7 @@ interface Props { assignedProjects: AssignedProject[]; fastEntryEnabled?: boolean; leaveTypes: LeaveType[]; + miscTasks: Task[]; } export type TimeLeaveRow = Partial< @@ -71,6 +74,7 @@ const TimeLeaveInputTable: React.FC = ({ isHoliday, fastEntryEnabled, leaveTypes, + miscTasks, }) => { const { t } = useTranslation("home"); const taskGroupsByProject = useMemo(() => { @@ -92,6 +96,14 @@ const TimeLeaveInputTable: React.FC = ({ }; }, {}); }, [allProjects]); + const taskGroupsWithoutProject = useMemo( + () => + uniqBy( + miscTasks.map((t) => t.taskGroup), + "id", + ), + [miscTasks], + ); // To check for start / end planned dates const milestonesByProject = useMemo(() => { @@ -314,31 +326,62 @@ const TimeLeaveInputTable: React.FC = ({ editable: true, renderEditCell(params: GridRenderEditCellParams) { if (params.row.type === "timeEntry") { - return ( - { - params.api.setEditCellValue({ - id: params.id, - field: params.field, - value: taskGroupId, - }); - params.api.setCellFocus(params.id, "taskId"); - }} - /> - ); + if (params.row.projectId) { + return ( + { + params.api.setEditCellValue({ + id: params.id, + field: params.field, + value: taskGroupId, + }); + params.api.setCellFocus(params.id, "taskId"); + }} + /> + ); + } else { + return ( + { + params.api.setEditCellValue({ + id: params.id, + field: params.field, + value: taskGroupId, + }); + params.api.setCellFocus(params.id, "taskId"); + }} + taskGroups={taskGroupsWithoutProject} + /> + ); + } } else { return ; } }, 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"); + if (!params.id) { + return null; + } + + const projectId = params.api.getRow(params.id).projectId; + if (projectId) { + const taskGroups = + taskGroupsByProject[params.api.getRow(params.id).projectId] || []; + const taskGroup = taskGroups.find( + (tg) => tg.value === params.value, + ); + return taskGroup ? taskGroup.label : t("None"); + } else { + const taskGroupId = params.value; + return ( + taskGroupsWithoutProject.find((tg) => tg.id === taskGroupId) + ?.name || t("None") + ); + } }, }, { @@ -362,6 +405,7 @@ const TimeLeaveInputTable: React.FC = ({ }); params.api.setCellFocus(params.id, "inputHours"); }} + miscTasks={miscTasks} /> ); } else { @@ -373,12 +417,11 @@ const TimeLeaveInputTable: React.FC = ({ ? params.api.getRow(params.id).projectId : undefined; - const task = projectId - ? allProjects - .find((p) => p.id === projectId) - ?.tasks.find((t) => t.id === params.value) - : undefined; - + const task = ( + projectId + ? allProjects.find((p) => p.id === projectId)?.tasks || [] + : miscTasks + ).find((t) => t.id === params.value); return task ? task.name : t("None"); }, }, @@ -475,6 +518,8 @@ const TimeLeaveInputTable: React.FC = ({ leaveTypes, assignedProjects, taskGroupsByProject, + taskGroupsWithoutProject, + miscTasks, ], ); diff --git a/src/components/TimeLeaveModal/TimeLeaveMobileEntry.tsx b/src/components/TimeLeaveModal/TimeLeaveMobileEntry.tsx index ddc923b..f4e551d 100644 --- a/src/components/TimeLeaveModal/TimeLeaveMobileEntry.tsx +++ b/src/components/TimeLeaveModal/TimeLeaveMobileEntry.tsx @@ -23,6 +23,7 @@ import { getHolidayForDate } from "@/app/utils/holidayUtils"; import FastTimeEntryModal from "../TimesheetTable/FastTimeEntryModal"; import { LeaveType } from "@/app/api/timesheets"; import LeaveEntryCard from "../LeaveTable/LeaveEntryCard"; +import { Task } from "@/app/api/tasks"; interface Props { date: string; @@ -31,6 +32,7 @@ interface Props { companyHolidays: HolidaysResult[]; fastEntryEnabled?: boolean; leaveTypes: LeaveType[]; + miscTasks: Task[]; } const TimeLeaveMobileEntry: React.FC = ({ @@ -40,6 +42,7 @@ const TimeLeaveMobileEntry: React.FC = ({ companyHolidays, fastEntryEnabled, leaveTypes, + miscTasks, }) => { const { t, @@ -222,7 +225,9 @@ const TimeLeaveMobileEntry: React.FC = ({ ? projectMap[entry.projectId] : undefined; - const task = project?.tasks.find((t) => t.id === entry.taskId); + const task = (project ? project.tasks : miscTasks).find( + (t) => t.id === entry.taskId, + ); return ( = ({ onClose={closeEditTimeModal} onSave={onSaveTimeEntry} isHoliday={Boolean(isHoliday)} - fastEntryEnabled={fastEntryEnabled} + miscTasks={miscTasks} {...editTimeModalProps} /> = ({ companyHolidays, fastEntryEnabled, leaveTypes, - isFullTime + isFullTime, + miscTasks, }) => { const { t } = useTranslation("home"); @@ -212,6 +215,7 @@ const TimeLeaveModal: React.FC = ({ allProjects, fastEntryEnabled, leaveTypes, + miscTasks, }} /> @@ -261,6 +265,7 @@ const TimeLeaveModal: React.FC = ({ companyHolidays, fastEntryEnabled, leaveTypes, + miscTasks, }} errorComponent={errorComponent} /> diff --git a/src/components/TimesheetTable/FastTimeEntryModal.tsx b/src/components/TimesheetTable/FastTimeEntryModal.tsx index c025b16..e027147 100644 --- a/src/components/TimesheetTable/FastTimeEntryModal.tsx +++ b/src/components/TimesheetTable/FastTimeEntryModal.tsx @@ -68,7 +68,7 @@ const getID = () => { }; const MISC_TASK_GROUP_ID = 5; -const FAST_ENTRY_TASK_ID = 40; +const FAST_ENTRY_TASK_ID = 43; const FastTimeEntryModal: React.FC = ({ onSave, @@ -140,8 +140,8 @@ const FastTimeEntryModal: React.FC = ({ projectId, inputHours: hour, otHours: othour, - taskGroupId: projectId ? MISC_TASK_GROUP_ID : undefined, - taskId: projectId ? FAST_ENTRY_TASK_ID : undefined, + taskGroupId: MISC_TASK_GROUP_ID, + taskId: FAST_ENTRY_TASK_ID, remark, }; }), diff --git a/src/components/TimesheetTable/TaskGroupSelect.tsx b/src/components/TimesheetTable/TaskGroupSelect.tsx index 8278156..df77154 100644 --- a/src/components/TimesheetTable/TaskGroupSelect.tsx +++ b/src/components/TimesheetTable/TaskGroupSelect.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from "react"; -import { MenuItem, Select, SelectChangeEvent } from "@mui/material"; +import { MenuItem, MenuProps, Select, SelectChangeEvent } from "@mui/material"; import { useTranslation } from "react-i18next"; import { TaskGroup } from "@/app/api/tasks"; @@ -16,6 +16,22 @@ interface Props { error?: boolean; } +const menuProps: Partial = { + slotProps: { + paper: { + sx: { maxHeight: 400 }, + }, + }, + anchorOrigin: { + vertical: "bottom", + horizontal: "left", + }, + transformOrigin: { + vertical: "top", + horizontal: "left", + }, +}; + const TaskGroupSelect: React.FC = ({ value, projectId, @@ -43,21 +59,7 @@ const TaskGroupSelect: React.FC = ({ value={value || ""} onChange={onChange} sx={{ width: "100%" }} - MenuProps={{ - slotProps: { - paper: { - sx: { maxHeight: 400 }, - }, - }, - anchorOrigin: { - vertical: "bottom", - horizontal: "left", - }, - transformOrigin: { - vertical: "top", - horizontal: "left", - }, - }} + MenuProps={menuProps} > {taskGroups.length === 0 && {t("None")}} {taskGroups.map((taskGroup) => ( @@ -73,4 +75,46 @@ const TaskGroupSelect: React.FC = ({ ); }; +type TaskGroupSelectWithoutProjectProps = Pick< + Props, + "value" | "onTaskGroupSelect" | "error" +> & { taskGroups: TaskGroup[] }; + +export const TaskGroupSelectWithoutProject: React.FC< + TaskGroupSelectWithoutProjectProps +> = ({ value, onTaskGroupSelect, error, taskGroups }) => { + const { t } = useTranslation("home"); + + const onChange = useCallback( + (event: SelectChangeEvent) => { + const newValue = event.target.value; + onTaskGroupSelect(newValue); + }, + [onTaskGroupSelect], + ); + + return ( + + ); +}; + export default TaskGroupSelect; diff --git a/src/components/TimesheetTable/TaskSelect.tsx b/src/components/TimesheetTable/TaskSelect.tsx index 1809c59..d40414c 100644 --- a/src/components/TimesheetTable/TaskSelect.tsx +++ b/src/components/TimesheetTable/TaskSelect.tsx @@ -2,6 +2,7 @@ import React, { useCallback } from "react"; import { MenuItem, Select, SelectChangeEvent } from "@mui/material"; import { useTranslation } from "react-i18next"; import { ProjectWithTasks } from "@/app/api/projects"; +import { Task } from "@/app/api/tasks"; interface Props { allProjects: ProjectWithTasks[]; @@ -10,6 +11,7 @@ interface Props { taskGroupId: number | undefined; onTaskSelect: (taskId: number | string) => void; error?: boolean; + miscTasks?: Task[]; } const TaskSelect: React.FC = ({ @@ -18,14 +20,15 @@ const TaskSelect: React.FC = ({ projectId, taskGroupId, onTaskSelect, - error + error, + miscTasks, }) => { const { t } = useTranslation("home"); const project = allProjects.find((p) => p.id === projectId); - const tasks = project - ? project.tasks.filter((task) => task.taskGroup.id === taskGroupId) - : []; + const tasks = (project ? project.tasks : miscTasks || []).filter( + (task) => task.taskGroup.id === taskGroupId, + ); const onChange = useCallback( (event: SelectChangeEvent) => { diff --git a/src/components/TimesheetTable/TimesheetEditModal.tsx b/src/components/TimesheetTable/TimesheetEditModal.tsx index bfec0ef..889d4a4 100644 --- a/src/components/TimesheetTable/TimesheetEditModal.tsx +++ b/src/components/TimesheetTable/TimesheetEditModal.tsx @@ -17,9 +17,11 @@ import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import ProjectSelect from "./ProjectSelect"; import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; -import TaskGroupSelect from "./TaskGroupSelect"; +import TaskGroupSelect, { + TaskGroupSelectWithoutProject, +} from "./TaskGroupSelect"; import TaskSelect from "./TaskSelect"; -import { TaskGroup } from "@/app/api/tasks"; +import { Task, TaskGroup } from "@/app/api/tasks"; import uniqBy from "lodash/uniqBy"; import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; import { shortDateFormatter } from "@/app/utils/formatUtil"; @@ -37,7 +39,7 @@ export interface Props extends Omit { modalSx?: SxProps; recordDate?: string; isHoliday?: boolean; - fastEntryEnabled?: boolean; + miscTasks: Task[]; } const modalSx: SxProps = { @@ -63,7 +65,7 @@ const TimesheetEditModal: React.FC = ({ modalSx: mSx, recordDate, isHoliday, - fastEntryEnabled, + miscTasks, }) => { const { t, @@ -90,6 +92,15 @@ const TimesheetEditModal: React.FC = ({ }, {}); }, [allProjects]); + const taskGroupsWithoutProject = useMemo( + () => + uniqBy( + miscTasks.map((t) => t.taskGroup), + "id", + ), + [miscTasks], + ); + const { register, control, @@ -174,23 +185,35 @@ const TimesheetEditModal: React.FC = ({ ( - { - field.onChange(newId ?? null); - }} - /> - )} + render={({ field }) => + projectId ? ( + { + field.onChange(newId ?? null); + }} + /> + ) : ( + { + field.onChange(newId ?? null); + if (!newId) { + setValue("taskId", undefined); + } + }} + taskGroups={taskGroupsWithoutProject} + /> + ) + } rules={{ validate: (id) => { if (!projectId) { - return !id; + return true; } - if (fastEntryEnabled) return true; const taskGroups = taskGroupsByProject[projectId]; return taskGroups.some((tg) => tg.value === id); }, @@ -213,17 +236,23 @@ const TimesheetEditModal: React.FC = ({ onTaskSelect={(newId) => { field.onChange(newId ?? null); }} + miscTasks={miscTasks} /> )} rules={{ validate: (id) => { - if (!projectId) { - return !id; - } - if (fastEntryEnabled) return true; - const projectTasks = allProjects.find((p) => p.id === projectId) - ?.tasks; - return Boolean(projectTasks?.some((task) => task.id === id)); + const tasks = projectId + ? allProjects.find((p) => p.id === projectId)?.tasks + : miscTasks; + + return taskGroupId + ? Boolean( + tasks?.some( + (task) => + task.id === id && task.taskGroup.id === taskGroupId, + ), + ) + : !id; }, }} /> diff --git a/src/components/UserWorkspacePage/UserWorkspacePage.tsx b/src/components/UserWorkspacePage/UserWorkspacePage.tsx index 56b170a..1086405 100644 --- a/src/components/UserWorkspacePage/UserWorkspacePage.tsx +++ b/src/components/UserWorkspacePage/UserWorkspacePage.tsx @@ -25,6 +25,7 @@ import { HolidaysResult } from "@/app/api/holidays"; import { TimesheetAmendmentModal } from "../TimesheetAmendment/TimesheetAmendmentModal"; import TimeLeaveModal from "../TimeLeaveModal/TimeLeaveModal"; import LeaveModal from "../LeaveModal"; +import { Task } from "@/app/api/tasks"; export interface Props { leaveTypes: LeaveType[]; @@ -39,6 +40,7 @@ export interface Props { maintainNormalStaffWorkspaceAbility: boolean; maintainManagementStaffWorkspaceAbility: boolean; isFullTime: boolean; + miscTasks: Task[]; } const menuItemSx: SxProps = { @@ -59,6 +61,7 @@ const UserWorkspacePage: React.FC = ({ maintainNormalStaffWorkspaceAbility, maintainManagementStaffWorkspaceAbility, isFullTime, + miscTasks }) => { const [anchorEl, setAnchorEl] = useState(null); @@ -189,6 +192,7 @@ const UserWorkspacePage: React.FC = ({ timesheetRecords={defaultTimesheets} leaveRecords={defaultLeaveRecords} isFullTime={isFullTime} + miscTasks={miscTasks} /> { const [ @@ -30,6 +31,7 @@ const UserWorkspaceWrapper: React.FC = async () => { holidays, abilities, userStaff, + allTasks, ] = await Promise.all([ fetchTeamMemberLeaves(), fetchTeamMemberTimesheets(), @@ -41,6 +43,7 @@ const UserWorkspaceWrapper: React.FC = async () => { fetchHolidays(), getUserAbilities(), getUserStaff(), + fetchAllTasks(), ]); const fastEntryEnabled = abilities.includes( @@ -54,6 +57,8 @@ const UserWorkspaceWrapper: React.FC = async () => { ); const isFullTime = userStaff?.employType === "FT"; + const miscTasks = allTasks.filter((t) => t.taskGroup.id === 5); + return ( { defaultLeaveRecords={leaves} leaveTypes={leaveTypes} holidays={holidays} + miscTasks={miscTasks} // Change to access check fastEntryEnabled={fastEntryEnabled} maintainNormalStaffWorkspaceAbility={maintainNormalStaffWorkspaceAbility}