@@ -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 ( | |||
<I18nProvider namespaces={["home", "common"]}> | |||
@@ -51,7 +51,9 @@ export const preloadAllTasks = () => { | |||
}; | |||
export const fetchAllTasks = cache(async () => { | |||
return serverFetchJson<Task[]>(`${BASE_API_URL}/tasks`); | |||
return serverFetchJson<Task[]>(`${BASE_API_URL}/tasks`, { | |||
next: { tags: ["tasks"] }, | |||
}); | |||
}); | |||
export const fetchTaskTemplateDetail = cache(async (id: string) => { | |||
@@ -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"; | |||
} | |||
} | |||
@@ -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<Props> = ({ | |||
isHoliday, | |||
fastEntryEnabled, | |||
leaveTypes, | |||
miscTasks, | |||
}) => { | |||
const { t } = useTranslation("home"); | |||
const taskGroupsByProject = useMemo(() => { | |||
@@ -92,6 +96,14 @@ const TimeLeaveInputTable: React.FC<Props> = ({ | |||
}; | |||
}, {}); | |||
}, [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<Props> = ({ | |||
editable: true, | |||
renderEditCell(params: GridRenderEditCellParams<TimeLeaveRow, number>) { | |||
if (params.row.type === "timeEntry") { | |||
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 (params.row.projectId) { | |||
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"); | |||
}} | |||
/> | |||
); | |||
} else { | |||
return ( | |||
<TaskGroupSelectWithoutProject | |||
value={params.value} | |||
onTaskGroupSelect={(taskGroupId) => { | |||
params.api.setEditCellValue({ | |||
id: params.id, | |||
field: params.field, | |||
value: taskGroupId, | |||
}); | |||
params.api.setCellFocus(params.id, "taskId"); | |||
}} | |||
taskGroups={taskGroupsWithoutProject} | |||
/> | |||
); | |||
} | |||
} else { | |||
return <DisabledEdit />; | |||
} | |||
}, | |||
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<Props> = ({ | |||
}); | |||
params.api.setCellFocus(params.id, "inputHours"); | |||
}} | |||
miscTasks={miscTasks} | |||
/> | |||
); | |||
} else { | |||
@@ -373,12 +417,11 @@ const TimeLeaveInputTable: React.FC<Props> = ({ | |||
? 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<Props> = ({ | |||
leaveTypes, | |||
assignedProjects, | |||
taskGroupsByProject, | |||
taskGroupsWithoutProject, | |||
miscTasks, | |||
], | |||
); | |||
@@ -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<Props> = ({ | |||
@@ -40,6 +42,7 @@ const TimeLeaveMobileEntry: React.FC<Props> = ({ | |||
companyHolidays, | |||
fastEntryEnabled, | |||
leaveTypes, | |||
miscTasks, | |||
}) => { | |||
const { | |||
t, | |||
@@ -222,7 +225,9 @@ const TimeLeaveMobileEntry: React.FC<Props> = ({ | |||
? 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 ( | |||
<TimeEntryCard | |||
@@ -269,7 +274,7 @@ const TimeLeaveMobileEntry: React.FC<Props> = ({ | |||
onClose={closeEditTimeModal} | |||
onSave={onSaveTimeEntry} | |||
isHoliday={Boolean(isHoliday)} | |||
fastEntryEnabled={fastEntryEnabled} | |||
miscTasks={miscTasks} | |||
{...editTimeModalProps} | |||
/> | |||
<LeaveEditModal | |||
@@ -38,6 +38,7 @@ import mapValues from "lodash/mapValues"; | |||
import DateHoursList from "../DateHoursTable/DateHoursList"; | |||
import TimeLeaveInputTable from "./TimeLeaveInputTable"; | |||
import TimeLeaveMobileEntry from "./TimeLeaveMobileEntry"; | |||
import { Task } from "@/app/api/tasks"; | |||
interface Props { | |||
isOpen: boolean; | |||
@@ -50,6 +51,7 @@ interface Props { | |||
fastEntryEnabled?: boolean; | |||
leaveTypes: LeaveType[]; | |||
isFullTime: boolean; | |||
miscTasks: Task[]; | |||
} | |||
const modalSx: SxProps = { | |||
@@ -72,7 +74,8 @@ const TimeLeaveModal: React.FC<Props> = ({ | |||
companyHolidays, | |||
fastEntryEnabled, | |||
leaveTypes, | |||
isFullTime | |||
isFullTime, | |||
miscTasks, | |||
}) => { | |||
const { t } = useTranslation("home"); | |||
@@ -212,6 +215,7 @@ const TimeLeaveModal: React.FC<Props> = ({ | |||
allProjects, | |||
fastEntryEnabled, | |||
leaveTypes, | |||
miscTasks, | |||
}} | |||
/> | |||
</Box> | |||
@@ -261,6 +265,7 @@ const TimeLeaveModal: React.FC<Props> = ({ | |||
companyHolidays, | |||
fastEntryEnabled, | |||
leaveTypes, | |||
miscTasks, | |||
}} | |||
errorComponent={errorComponent} | |||
/> | |||
@@ -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<Props> = ({ | |||
onSave, | |||
@@ -140,8 +140,8 @@ const FastTimeEntryModal: React.FC<Props> = ({ | |||
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, | |||
}; | |||
}), | |||
@@ -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<MenuProps> = { | |||
slotProps: { | |||
paper: { | |||
sx: { maxHeight: 400 }, | |||
}, | |||
}, | |||
anchorOrigin: { | |||
vertical: "bottom", | |||
horizontal: "left", | |||
}, | |||
transformOrigin: { | |||
vertical: "top", | |||
horizontal: "left", | |||
}, | |||
}; | |||
const TaskGroupSelect: React.FC<Props> = ({ | |||
value, | |||
projectId, | |||
@@ -43,21 +59,7 @@ const TaskGroupSelect: React.FC<Props> = ({ | |||
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 && <MenuItem value={""}>{t("None")}</MenuItem>} | |||
{taskGroups.map((taskGroup) => ( | |||
@@ -73,4 +75,46 @@ const TaskGroupSelect: React.FC<Props> = ({ | |||
); | |||
}; | |||
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<number>) => { | |||
const newValue = event.target.value; | |||
onTaskGroupSelect(newValue); | |||
}, | |||
[onTaskGroupSelect], | |||
); | |||
return ( | |||
<Select | |||
error={error} | |||
displayEmpty | |||
disabled={taskGroups.length === 0} | |||
value={value || ""} | |||
onChange={onChange} | |||
sx={{ width: "100%" }} | |||
MenuProps={menuProps} | |||
> | |||
{<MenuItem value={""}>{t("None")}</MenuItem>} | |||
{taskGroups.map((taskGroup) => ( | |||
<MenuItem | |||
key={taskGroup.id} | |||
value={taskGroup.id} | |||
sx={{ whiteSpace: "wrap" }} | |||
> | |||
{taskGroup.name} | |||
</MenuItem> | |||
))} | |||
</Select> | |||
); | |||
}; | |||
export default TaskGroupSelect; |
@@ -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<Props> = ({ | |||
@@ -18,14 +20,15 @@ const TaskSelect: React.FC<Props> = ({ | |||
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<number>) => { | |||
@@ -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<ModalProps, "children"> { | |||
modalSx?: SxProps; | |||
recordDate?: string; | |||
isHoliday?: boolean; | |||
fastEntryEnabled?: boolean; | |||
miscTasks: Task[]; | |||
} | |||
const modalSx: SxProps = { | |||
@@ -63,7 +65,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||
modalSx: mSx, | |||
recordDate, | |||
isHoliday, | |||
fastEntryEnabled, | |||
miscTasks, | |||
}) => { | |||
const { | |||
t, | |||
@@ -90,6 +92,15 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||
}, {}); | |||
}, [allProjects]); | |||
const taskGroupsWithoutProject = useMemo( | |||
() => | |||
uniqBy( | |||
miscTasks.map((t) => t.taskGroup), | |||
"id", | |||
), | |||
[miscTasks], | |||
); | |||
const { | |||
register, | |||
control, | |||
@@ -174,23 +185,35 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||
<Controller | |||
control={control} | |||
name="taskGroupId" | |||
render={({ field }) => ( | |||
<TaskGroupSelect | |||
error={Boolean(formState.errors.taskGroupId)} | |||
projectId={projectId} | |||
taskGroupsByProject={taskGroupsByProject} | |||
value={field.value} | |||
onTaskGroupSelect={(newId) => { | |||
field.onChange(newId ?? null); | |||
}} | |||
/> | |||
)} | |||
render={({ field }) => | |||
projectId ? ( | |||
<TaskGroupSelect | |||
error={Boolean(formState.errors.taskGroupId)} | |||
projectId={projectId} | |||
taskGroupsByProject={taskGroupsByProject} | |||
value={field.value} | |||
onTaskGroupSelect={(newId) => { | |||
field.onChange(newId ?? null); | |||
}} | |||
/> | |||
) : ( | |||
<TaskGroupSelectWithoutProject | |||
value={field.value} | |||
onTaskGroupSelect={(newId) => { | |||
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<Props> = ({ | |||
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; | |||
}, | |||
}} | |||
/> | |||
@@ -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<Props> = ({ | |||
maintainNormalStaffWorkspaceAbility, | |||
maintainManagementStaffWorkspaceAbility, | |||
isFullTime, | |||
miscTasks | |||
}) => { | |||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | |||
@@ -189,6 +192,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
timesheetRecords={defaultTimesheets} | |||
leaveRecords={defaultLeaveRecords} | |||
isFullTime={isFullTime} | |||
miscTasks={miscTasks} | |||
/> | |||
<LeaveModal | |||
open={isLeaveCalendarVisible} | |||
@@ -17,6 +17,7 @@ import { | |||
MAINTAIN_NORMAL_STAFF_WORKSPACE, | |||
MAINTAIN_MANAGEMENT_STAFF_WORKSPACE, | |||
} from "@/middleware"; | |||
import { fetchAllTasks } from "@/app/api/tasks"; | |||
const UserWorkspaceWrapper: React.FC = async () => { | |||
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 ( | |||
<UserWorkspacePage | |||
isFullTime={isFullTime} | |||
@@ -65,6 +70,7 @@ const UserWorkspaceWrapper: React.FC = async () => { | |||
defaultLeaveRecords={leaves} | |||
leaveTypes={leaveTypes} | |||
holidays={holidays} | |||
miscTasks={miscTasks} | |||
// Change to access check | |||
fastEntryEnabled={fastEntryEnabled} | |||
maintainNormalStaffWorkspaceAbility={maintainNormalStaffWorkspaceAbility} | |||