From cd499b434ad48ddbe8423856403ceaf4b2b8bbdd Mon Sep 17 00:00:00 2001 From: Wayne Date: Mon, 15 Jul 2024 17:20:15 +0900 Subject: [PATCH] Consolidate time and leave entry --- src/app/api/timesheets/actions.ts | 36 +- src/app/api/timesheets/utils.ts | 45 ++ .../TimeLeaveModal/DisabledEdit.tsx | 11 + .../TimeLeaveModal/TimeLeaveInputTable.tsx | 626 ++++++++++++++++++ .../TimeLeaveModal/TimeLeaveMobileEntry.tsx | 298 +++++++++ .../TimeLeaveModal/TimeLeaveModal.tsx | 272 ++++++++ .../TimesheetTable/ProjectSelect.tsx | 43 +- .../UserWorkspacePage/UserWorkspacePage.tsx | 65 +- 8 files changed, 1342 insertions(+), 54 deletions(-) create mode 100644 src/components/TimeLeaveModal/DisabledEdit.tsx create mode 100644 src/components/TimeLeaveModal/TimeLeaveInputTable.tsx create mode 100644 src/components/TimeLeaveModal/TimeLeaveMobileEntry.tsx create mode 100644 src/components/TimeLeaveModal/TimeLeaveModal.tsx diff --git a/src/app/api/timesheets/actions.ts b/src/app/api/timesheets/actions.ts index 41c7e2a..a4d11d9 100644 --- a/src/app/api/timesheets/actions.ts +++ b/src/app/api/timesheets/actions.ts @@ -31,6 +31,14 @@ export interface RecordLeaveInput { [date: string]: LeaveEntry[]; } +export type TimeLeaveEntry = + | (TimeEntry & { type: "timeEntry" }) + | (LeaveEntry & { type: "leaveEntry" }); + +export interface RecordTimeLeaveInput { + [date: string]: TimeLeaveEntry[]; +} + export const saveTimesheet = async (data: RecordTimesheetInput) => { const savedRecords = await serverFetchJson( `${BASE_API_URL}/timesheets/save`, @@ -61,6 +69,22 @@ export const saveLeave = async (data: RecordLeaveInput) => { return savedRecords; }; +export const saveTimeLeave = async (data: RecordTimeLeaveInput) => { + const savedRecords = await serverFetchJson( + `${BASE_API_URL}/timesheets/saveTimeLeave`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); + + revalidateTag(`timesheets`); + revalidateTag(`leaves`); + + return savedRecords; +}; + export const saveMemberEntry = async (data: { staffId: number; entry: TimeEntry; @@ -124,12 +148,12 @@ export const revalidateCacheAfterAmendment = () => { }; export const importTimesheets = async (data: FormData) => { - const importTimesheets = await serverFetchString( - `${BASE_API_URL}/timesheets/import`, - { - method: "POST", - body: data, - }, + const importTimesheets = await serverFetchString( + `${BASE_API_URL}/timesheets/import`, + { + method: "POST", + body: data, + }, ); return importTimesheets; diff --git a/src/app/api/timesheets/utils.ts b/src/app/api/timesheets/utils.ts index bbb13c2..714b1c8 100644 --- a/src/app/api/timesheets/utils.ts +++ b/src/app/api/timesheets/utils.ts @@ -3,6 +3,7 @@ import { HolidaysResult } from "../holidays"; import { LeaveEntry, RecordLeaveInput, + RecordTimeLeaveInput, RecordTimesheetInput, TimeEntry, } from "./actions"; @@ -158,6 +159,50 @@ export const validateLeaveRecord = ( return Object.keys(errors).length > 0 ? errors : undefined; }; +export const validateTimeLeaveRecord = ( + records: RecordTimeLeaveInput, + companyHolidays: HolidaysResult[], +): { [date: string]: string } | undefined => { + const errors: { [date: string]: string } = {}; + + const holidays = new Set( + compact([ + ...getPublicHolidaysForNYears(2).map((h) => h.date), + ...companyHolidays.map((h) => convertDateArrayToString(h.date)), + ]), + ); + + Object.keys(records).forEach((date) => { + const entries = records[date]; + // Check each entry + for (const entry of entries) { + let entryError; + if (entry.type === "leaveEntry") { + entryError = validateLeaveEntry(entry, holidays.has(date)); + } else { + entryError = validateTimeEntry(entry, holidays.has(date)); + } + + if (entryError) { + errors[date] = "There are errors in the entries"; + return; + } + } + + // Check total hours + const totalHourError = checkTotalHours( + entries.filter((e) => e.type === "timeEntry"), + entries.filter((e) => e.type === "leaveEntry"), + ); + + if (totalHourError) { + errors[date] = totalHourError; + } + }); + + return Object.keys(errors).length > 0 ? errors : undefined; +}; + export const checkTotalHours = ( timeEntries: TimeEntry[], leaves: LeaveEntry[], diff --git a/src/components/TimeLeaveModal/DisabledEdit.tsx b/src/components/TimeLeaveModal/DisabledEdit.tsx new file mode 100644 index 0000000..3f5d773 --- /dev/null +++ b/src/components/TimeLeaveModal/DisabledEdit.tsx @@ -0,0 +1,11 @@ +import { Box } from "@mui/material"; + +const DisabledEdit: React.FC = () => { + return ( + + ); +}; + +export default DisabledEdit; diff --git a/src/components/TimeLeaveModal/TimeLeaveInputTable.tsx b/src/components/TimeLeaveModal/TimeLeaveInputTable.tsx new file mode 100644 index 0000000..d102ce0 --- /dev/null +++ b/src/components/TimeLeaveModal/TimeLeaveInputTable.tsx @@ -0,0 +1,626 @@ +import { Add, Check, Close, Delete } from "@mui/icons-material"; +import { Box, Button, Tooltip, Typography } from "@mui/material"; +import { + FooterPropsOverrides, + GridActionsCellItem, + GridCellParams, + GridColDef, + GridEditInputCell, + GridEventListener, + GridRenderEditCellParams, + GridRowId, + GridRowModel, + GridRowModes, + GridRowModesModel, + GridToolbarContainer, + useGridApiRef, +} from "@mui/x-data-grid"; +import { useTranslation } from "react-i18next"; +import StyledDataGrid from "../StyledDataGrid"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { + RecordTimeLeaveInput, + TimeEntry, + TimeLeaveEntry, +} from "@/app/api/timesheets/actions"; +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 dayjs from "dayjs"; +import isBetween from "dayjs/plugin/isBetween"; +import ProjectSelect from "../TimesheetTable/ProjectSelect"; +import TaskGroupSelect from "../TimesheetTable/TaskGroupSelect"; +import TaskSelect from "../TimesheetTable/TaskSelect"; +import { + DAILY_NORMAL_MAX_HOURS, + LeaveEntryError, + TimeEntryError, + validateLeaveEntry, + validateTimeEntry, +} from "@/app/api/timesheets/utils"; +import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; +import FastTimeEntryModal from "../TimesheetTable/FastTimeEntryModal"; +import { LeaveType } from "@/app/api/timesheets"; +import DisabledEdit from "./DisabledEdit"; + +dayjs.extend(isBetween); + +interface Props { + day: string; + isHoliday: boolean; + allProjects: ProjectWithTasks[]; + assignedProjects: AssignedProject[]; + fastEntryEnabled?: boolean; + leaveTypes: LeaveType[]; +} + +export type TimeLeaveRow = Partial< + TimeLeaveEntry & { + _isNew: boolean; + _error: TimeEntryError | LeaveEntryError; + _isPlanned?: boolean; + } +>; + +const TimeLeaveInputTable: React.FC = ({ + day, + allProjects, + assignedProjects, + isHoliday, + fastEntryEnabled, + leaveTypes, +}) => { + const { t } = useTranslation("home"); + const taskGroupsByProject = useMemo(() => { + return allProjects.reduce<{ + [projectId: AssignedProject["id"]]: { + value: TaskGroup["id"]; + label: string; + }[]; + }>((acc, project) => { + return { + ...acc, + [project.id]: uniqBy( + project.tasks.map((t) => ({ + value: t.taskGroup.id, + label: t.taskGroup.name, + })), + "value", + ), + }; + }, {}); + }, [allProjects]); + + // To check for start / end planned dates + const milestonesByProject = useMemo(() => { + return assignedProjects.reduce<{ + [projectId: AssignedProject["id"]]: AssignedProject["milestones"]; + }>((acc, project) => { + return { ...acc, [project.id]: { ...project.milestones } }; + }, {}); + }, [assignedProjects]); + + const { getValues, setValue, clearErrors } = + useFormContext(); + const currentEntries = getValues(day); + + const [entries, setEntries] = useState(currentEntries || []); + + const [rowModesModel, setRowModesModel] = useState({}); + + const apiRef = useGridApiRef(); + const addRow = useCallback(() => { + const id = Date.now(); + setEntries((e) => [...e, { id, _isNew: true, type: "timeEntry" }]); + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.Edit, fieldToFocus: "projectId" }, + })); + }, []); + + const validateRow = useCallback( + (id: GridRowId) => { + const row = apiRef.current.getRowWithUpdatedValues( + id, + "", + ) as TimeLeaveRow; + + // Test for warnings + if (row.type === "timeEntry") { + const error = validateTimeEntry(row, isHoliday); + let _isPlanned; + if ( + row.projectId && + row.taskGroupId && + milestonesByProject[row.projectId] + ) { + const milestone = + milestonesByProject[row.projectId][row.taskGroupId] || {}; + const { startDate, endDate } = milestone; + // Check if the current day is between the start and end date inclusively + _isPlanned = dayjs(day).isBetween(startDate, endDate, "day", "[]"); + } + apiRef.current.updateRows([{ id, _error: error, _isPlanned }]); + return !error; + } else if (row.type === "leaveEntry") { + const error = validateLeaveEntry(row, isHoliday); + apiRef.current.updateRows([{ id, _error: error }]); + return !error; + } else { + return false; + } + }, + [apiRef, day, isHoliday, milestonesByProject], + ); + + const handleCancel = useCallback( + (id: GridRowId) => () => { + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.View, ignoreModifications: true }, + })); + const editedRow = entries.find((entry) => entry.id === id); + if (editedRow?._isNew) { + setEntries((es) => es.filter((e) => e.id !== id)); + } + }, + [entries], + ); + + const handleDelete = useCallback( + (id: GridRowId) => () => { + setEntries((es) => es.filter((e) => e.id !== id)); + }, + [], + ); + + const handleSave = useCallback( + (id: GridRowId) => () => { + if (validateRow(id)) { + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.View }, + })); + } + }, + [validateRow], + ); + + const handleEditStop = useCallback>( + (params, event) => { + if (!validateRow(params.id)) { + event.defaultMuiPrevented = true; + } + }, + [validateRow], + ); + + const processRowUpdate = useCallback((newRow: GridRowModel) => { + const updatedRow = { ...newRow, _isNew: false }; + setEntries((es) => es.map((e) => (e.id === newRow.id ? updatedRow : e))); + return updatedRow; + }, []); + + const columns = useMemo( + () => [ + { + type: "actions", + field: "actions", + headerName: t("Actions"), + getActions: ({ id }) => { + if (rowModesModel[id]?.mode === GridRowModes.Edit) { + return [ + } + label={t("Save")} + onClick={handleSave(id)} + />, + } + label={t("Cancel")} + onClick={handleCancel(id)} + />, + ]; + } + + return [ + } + label={t("Remove")} + onClick={handleDelete(id)} + />, + ]; + }, + }, + { + field: "type", + headerName: t("Project or Leave"), + width: 300, + editable: true, + valueFormatter(params) { + const row = params.id + ? params.api.getRow(params.id) + : null; + if (!row) { + return null; + } + + if (row.type === "timeEntry") { + const project = allProjects.find((p) => p.id === row.projectId); + return project ? `${project.code} - ${project.name}` : t("None"); + } else if (row.type === "leaveEntry") { + const leave = leaveTypes.find((l) => l.id === row.leaveTypeId); + return leave?.name || "Unknown leave"; + } + }, + renderEditCell(params: GridRenderEditCellParams) { + return ( + { + await params.api.setEditCellValue({ + id: params.id, + field: params.field, + value: isLeave ? "leaveEntry" : "timeEntry", + }); + + params.api.updateRows([ + { + id: params.id, + ...(isLeave + ? { + type: "leaveEntry", + leaveTypeId: projectOrLeaveId, + projectId: undefined, + } + : { + type: "timeEntry", + projectId: projectOrLeaveId, + leaveTypeId: undefined, + }), + _error: undefined, + }, + ]); + params.api.setCellFocus( + params.id, + isLeave || !projectOrLeaveId ? "inputHours" : "taskGroupId", + ); + }} + /> + ); + }, + }, + { + field: "taskGroupId", + headerName: t("Stage"), + width: 200, + 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"); + }} + /> + ); + } 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"); + }, + }, + { + field: "taskId", + headerName: t("Task"), + width: 200, + editable: true, + renderEditCell(params: GridRenderEditCellParams) { + if (params.row.type === "timeEntry") { + return ( + { + params.api.setEditCellValue({ + id: params.id, + field: params.field, + value: taskId, + }); + params.api.setCellFocus(params.id, "inputHours"); + }} + /> + ); + } else { + return ; + } + }, + valueFormatter(params) { + const projectId = params.id + ? params.api.getRow(params.id).projectId + : undefined; + + const task = projectId + ? allProjects + .find((p) => p.id === projectId) + ?.tasks.find((t) => t.id === params.value) + : undefined; + + return task ? task.name : t("None"); + }, + }, + { + field: "inputHours", + headerName: t("Hours"), + width: 100, + editable: true, + type: "number", + renderEditCell(params: GridRenderEditCellParams) { + const errorMessage = + params.row._error?.[ + params.field as keyof Omit + ]; + const content = ; + return errorMessage ? ( + + {content} + + ) : ( + content + ); + }, + valueParser(value) { + return value ? roundToNearestQuarter(value) : value; + }, + valueFormatter(params) { + return manhourFormatter.format(params.value || 0); + }, + }, + { + field: "otHours", + headerName: t("Other Hours"), + width: 150, + editable: true, + type: "number", + renderEditCell(params: GridRenderEditCellParams) { + if (params.row.type === "leaveEntry") { + return ; + } + const errorMessage = + params.row._error?.[ + params.field as keyof Omit + ]; + const content = ; + return errorMessage ? ( + + {content} + + ) : ( + content + ); + }, + valueParser(value) { + return value ? roundToNearestQuarter(value) : value; + }, + valueFormatter(params) { + return manhourFormatter.format(params.value || 0); + }, + }, + { + field: "remark", + headerName: t("Remark"), + sortable: false, + flex: 1, + editable: true, + renderEditCell(params: GridRenderEditCellParams) { + const errorMessage = + params.row._error?.[ + params.field as keyof Omit + ]; + const content = ; + return errorMessage ? ( + + {content} + + ) : ( + content + ); + }, + }, + ], + [ + t, + rowModesModel, + handleDelete, + handleSave, + handleCancel, + allProjects, + leaveTypes, + assignedProjects, + taskGroupsByProject, + ], + ); + + useEffect(() => { + const newEntries: TimeLeaveEntry[] = entries + .map((e) => { + if (e._isNew || e._error || !e.id || !e.type) { + return null; + } + + return e; + }) + .filter((e): e is TimeLeaveEntry => Boolean(e)); + + setValue(day, newEntries); + clearErrors(day); + }, [getValues, entries, setValue, day, clearErrors]); + + const hasOutOfPlannedStages = entries.some( + (entry) => entry._isPlanned !== undefined && !entry._isPlanned, + ); + + // Fast entry modal + const [fastEntryModalOpen, setFastEntryModalOpen] = useState(false); + const closeFastEntryModal = useCallback(() => { + setFastEntryModalOpen(false); + }, []); + const openFastEntryModal = useCallback(() => { + setFastEntryModalOpen(true); + }, []); + const onSaveFastEntry = useCallback(async (entries: TimeEntry[]) => { + setEntries((e) => [ + ...e, + ...entries.map((newEntry) => ({ + ...newEntry, + type: "timeEntry" as const, + })), + ]); + setFastEntryModalOpen(false); + }, []); + + const footer = ( + + + {fastEntryEnabled && ( + + )} + {hasOutOfPlannedStages && ( + + {t("There are entries for stages out of planned dates!")} + + )} + + ); + + return ( + <> + ) => { + let classname = ""; + if ( + params.row._error?.[ + params.field as keyof Omit + ] + ) { + classname = "hasError"; + } else if ( + params.field === "taskGroupId" && + params.row._isPlanned !== undefined && + !params.row._isPlanned + ) { + classname = "hasWarning"; + } + return classname; + }} + slots={{ + footer: FooterToolbar, + noRowsOverlay: NoRowsOverlay, + }} + slotProps={{ + footer: { child: footer }, + }} + /> + {fastEntryEnabled && ( + + )} + + ); +}; + +const NoRowsOverlay: React.FC = () => { + const { t } = useTranslation("home"); + return ( + + {t("Add some time entries!")} + + ); +}; + +const FooterToolbar: React.FC = ({ child }) => { + return {child}; +}; + +export default TimeLeaveInputTable; diff --git a/src/components/TimeLeaveModal/TimeLeaveMobileEntry.tsx b/src/components/TimeLeaveModal/TimeLeaveMobileEntry.tsx new file mode 100644 index 0000000..ddc923b --- /dev/null +++ b/src/components/TimeLeaveModal/TimeLeaveMobileEntry.tsx @@ -0,0 +1,298 @@ +import { + TimeEntry, + RecordTimeLeaveInput, + LeaveEntry, +} from "@/app/api/timesheets/actions"; +import { shortDateFormatter } from "@/app/utils/formatUtil"; +import { Add } from "@mui/icons-material"; +import { Box, Button, Stack, Typography } from "@mui/material"; +import dayjs from "dayjs"; +import React, { useCallback, useMemo, useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; +import TimesheetEditModal, { + Props as TimesheetEditModalProps, +} from "../TimesheetTable/TimesheetEditModal"; +import LeaveEditModal, { + Props as LeaveEditModalProps, +} from "../LeaveTable/LeaveEditModal"; +import TimeEntryCard from "../TimesheetTable/TimeEntryCard"; +import { HolidaysResult } from "@/app/api/holidays"; +import { getHolidayForDate } from "@/app/utils/holidayUtils"; +import FastTimeEntryModal from "../TimesheetTable/FastTimeEntryModal"; +import { LeaveType } from "@/app/api/timesheets"; +import LeaveEntryCard from "../LeaveTable/LeaveEntryCard"; + +interface Props { + date: string; + allProjects: ProjectWithTasks[]; + assignedProjects: AssignedProject[]; + companyHolidays: HolidaysResult[]; + fastEntryEnabled?: boolean; + leaveTypes: LeaveType[]; +} + +const TimeLeaveMobileEntry: React.FC = ({ + date, + allProjects, + assignedProjects, + companyHolidays, + fastEntryEnabled, + leaveTypes, +}) => { + const { + t, + i18n: { language }, + } = useTranslation("home"); + + const projectMap = useMemo(() => { + return allProjects.reduce<{ + [id: ProjectWithTasks["id"]]: ProjectWithTasks; + }>((acc, project) => { + return { ...acc, [project.id]: project }; + }, {}); + }, [allProjects]); + + const leaveTypeMap = useMemo<{ [id: LeaveType["id"]]: LeaveType }>(() => { + return leaveTypes.reduce( + (acc, leaveType) => ({ ...acc, [leaveType.id]: leaveType }), + {}, + ); + }, [leaveTypes]); + + const dayJsObj = dayjs(date); + const holiday = getHolidayForDate(date, companyHolidays); + const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; + + const { watch, setValue, clearErrors } = + useFormContext(); + const currentEntries = watch(date); + + // Time entry edit modal + const [editTimeModalProps, setEditTimeModalProps] = useState< + Partial + >({}); + const [editTimeModalOpen, setEditTimeModalOpen] = useState(false); + + const openEditTimeModal = useCallback( + (defaultValues?: TimeEntry) => () => { + setEditTimeModalProps({ + defaultValues: defaultValues ? { ...defaultValues } : undefined, + onDelete: defaultValues + ? async () => { + setValue( + date, + currentEntries.filter((entry) => entry.id !== defaultValues.id), + ); + clearErrors(date); + setEditTimeModalOpen(false); + } + : undefined, + }); + setEditTimeModalOpen(true); + }, + [clearErrors, currentEntries, date, setValue], + ); + + const closeEditTimeModal = useCallback(() => { + setEditTimeModalOpen(false); + }, []); + + const onSaveTimeEntry = useCallback( + async (entry: TimeEntry) => { + const existingEntry = currentEntries.find( + (e) => e.type === "timeEntry" && e.id === entry.id, + ); + const newEntry = { type: "timeEntry" as const, ...entry }; + if (existingEntry) { + setValue( + date, + currentEntries.map((e) => ({ + ...(e === existingEntry ? newEntry : e), + })), + ); + clearErrors(date); + } else { + setValue(date, [...currentEntries, newEntry]); + } + setEditTimeModalOpen(false); + }, + [clearErrors, currentEntries, date, setValue], + ); + + // Leave entry edit modal + const [editLeaveModalProps, setEditLeaveModalProps] = useState< + Partial + >({}); + const [editLeaveModalOpen, setEditLeaveModalOpen] = useState(false); + + const openEditLeaveModal = useCallback( + (defaultValues?: LeaveEntry) => () => { + setEditLeaveModalProps({ + defaultValues: defaultValues ? { ...defaultValues } : undefined, + onDelete: defaultValues + ? async () => { + setValue( + date, + currentEntries.filter((entry) => entry.id !== defaultValues.id), + ); + clearErrors(date); + setEditLeaveModalOpen(false); + } + : undefined, + }); + setEditLeaveModalOpen(true); + }, + [clearErrors, currentEntries, date, setValue], + ); + + const closeEditLeaveModal = useCallback(() => { + setEditLeaveModalOpen(false); + }, []); + + const onSaveLeaveEntry = useCallback( + async (entry: LeaveEntry) => { + const existingEntry = currentEntries.find( + (e) => e.type === "leaveEntry" && e.id === entry.id, + ); + const newEntry = { type: "leaveEntry" as const, ...entry }; + if (existingEntry) { + setValue( + date, + currentEntries.map((e) => ({ + ...(e === existingEntry ? newEntry : e), + })), + ); + clearErrors(date); + } else { + setValue(date, [...currentEntries, newEntry]); + } + setEditLeaveModalOpen(false); + }, + [clearErrors, currentEntries, date, setValue], + ); + + // Fast entry modal + const [fastEntryModalOpen, setFastEntryModalOpen] = useState(false); + const closeFastEntryModal = useCallback(() => { + setFastEntryModalOpen(false); + }, []); + const openFastEntryModal = useCallback(() => { + setFastEntryModalOpen(true); + }, []); + const onSaveFastEntry = useCallback( + async (entries: TimeEntry[]) => { + setValue(date, [ + ...currentEntries, + ...entries.map((e) => ({ type: "timeEntry" as const, ...e })), + ]); + setFastEntryModalOpen(false); + }, + [currentEntries, date, setValue], + ); + + return ( + <> + + {shortDateFormatter(language).format(dayJsObj.toDate())} + {holiday && ( + {`(${holiday.title})`} + )} + + + {currentEntries.length ? ( + currentEntries.map((entry, index) => { + if (entry.type === "timeEntry") { + const project = entry.projectId + ? projectMap[entry.projectId] + : undefined; + + const task = project?.tasks.find((t) => t.id === entry.taskId); + + return ( + + ); + } else { + return ( + + ); + } + }) + ) : ( + + {t("Add some time entries!")} + + )} + + + + {fastEntryEnabled && ( + + )} + + + + {fastEntryEnabled && ( + + )} + + + ); +}; + +export default TimeLeaveMobileEntry; diff --git a/src/components/TimeLeaveModal/TimeLeaveModal.tsx b/src/components/TimeLeaveModal/TimeLeaveModal.tsx new file mode 100644 index 0000000..ee416fa --- /dev/null +++ b/src/components/TimeLeaveModal/TimeLeaveModal.tsx @@ -0,0 +1,272 @@ +import React, { useCallback, useEffect, useMemo } from "react"; +import { + Box, + Button, + Card, + CardActions, + CardContent, + Modal, + ModalProps, + SxProps, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { Check, Close } from "@mui/icons-material"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { + LeaveEntry, + RecordLeaveInput, + RecordTimeLeaveInput, + RecordTimesheetInput, + saveTimeLeave, +} from "@/app/api/timesheets/actions"; +import dayjs from "dayjs"; +import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; +import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; +import FullscreenModal from "../FullscreenModal"; +import useIsMobile from "@/app/utils/useIsMobile"; +import { HolidaysResult } from "@/app/api/holidays"; +import { + DAILY_NORMAL_MAX_HOURS, + TIMESHEET_DAILY_MAX_HOURS, + validateTimeLeaveRecord, +} from "@/app/api/timesheets/utils"; +import ErrorAlert from "../ErrorAlert"; +import { LeaveType } from "@/app/api/timesheets"; +import DateHoursTable from "../DateHoursTable"; +import mapValues from "lodash/mapValues"; +import DateHoursList from "../DateHoursTable/DateHoursList"; +import TimeLeaveInputTable from "./TimeLeaveInputTable"; +import TimeLeaveMobileEntry from "./TimeLeaveMobileEntry"; + +interface Props { + isOpen: boolean; + onClose: () => void; + allProjects: ProjectWithTasks[]; + assignedProjects: AssignedProject[]; + timesheetRecords: RecordTimesheetInput; + leaveRecords: RecordLeaveInput; + companyHolidays: HolidaysResult[]; + fastEntryEnabled?: boolean; + leaveTypes: LeaveType[]; +} + +const modalSx: SxProps = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: { xs: "calc(100% - 2rem)", sm: "90%" }, + maxHeight: "90%", + maxWidth: 1400, +}; + +const TimeLeaveModal: React.FC = ({ + isOpen, + onClose, + allProjects, + assignedProjects, + timesheetRecords, + leaveRecords, + companyHolidays, + fastEntryEnabled, + leaveTypes, +}) => { + const { t } = useTranslation("home"); + + const defaultValues = useMemo(() => { + const today = dayjs(); + return Array(7) + .fill(undefined) + .reduce((acc, _, index) => { + const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); + const defaultTimesheets = timesheetRecords[date] ?? []; + const defaultLeaveRecords = leaveRecords[date] ?? []; + + return { + ...acc, + [date]: [ + ...defaultTimesheets.map((t) => ({ + type: "timeEntry" as const, + ...t, + })), + ...defaultLeaveRecords.map((l) => ({ + type: "leaveEntry" as const, + ...l, + })), + ], + }; + }, {}); + }, [leaveRecords, timesheetRecords]); + + const formProps = useForm({ defaultValues }); + useEffect(() => { + formProps.reset(defaultValues); + }, [defaultValues, formProps]); + + const onSubmit = useCallback>( + async (data) => { + const errors = validateTimeLeaveRecord(data, companyHolidays); + if (errors) { + Object.keys(errors).forEach((date) => + formProps.setError(date, { + message: errors[date], + }), + ); + return; + } + const savedRecords = await saveTimeLeave(data); + + const today = dayjs(); + const newFormValues = Array(7) + .fill(undefined) + .reduce((acc, _, index) => { + const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); + return { + ...acc, + [date]: savedRecords[date] ?? [], + }; + }, {}); + + formProps.reset(newFormValues); + onClose(); + }, + [companyHolidays, formProps, onClose], + ); + + const onCancel = useCallback(() => { + formProps.reset(defaultValues); + onClose(); + }, [defaultValues, formProps, onClose]); + + const onModalClose = useCallback>( + (_, reason) => { + if (reason !== "backdropClick") { + onClose(); + } + }, + [onClose], + ); + + const errorComponent = ( + { + const error = formProps.formState.errors[date]?.message; + return error + ? `${date}: ${t(error, { + TIMESHEET_DAILY_MAX_HOURS, + DAILY_NORMAL_MAX_HOURS, + })}` + : undefined; + })} + /> + ); + + const currentValue = formProps.watch(); + const currentDays = Object.keys(currentValue); + const currentTimeEntries: RecordTimesheetInput = mapValues( + currentValue, + (timeLeaveEntries) => + timeLeaveEntries.filter((entry) => entry.type === "timeEntry"), + ); + const currentLeaveEntries: RecordLeaveInput = mapValues( + currentValue, + (timeLeaveEntries) => + timeLeaveEntries.filter( + (entry): entry is LeaveEntry & { type: "leaveEntry" } => + entry.type === "leaveEntry", + ), + ); + + const matches = useIsMobile(); + + return ( + + {!matches ? ( + // Desktop version + + + + + {t("Timesheet Input")} + + + + + {errorComponent} + + + + + + + + ) : ( + // Mobile version + + + + {t("Timesheet Input")} + + + + + )} + + ); +}; + +export default TimeLeaveModal; diff --git a/src/components/TimesheetTable/ProjectSelect.tsx b/src/components/TimesheetTable/ProjectSelect.tsx index edc04db..3a8cd8f 100644 --- a/src/components/TimesheetTable/ProjectSelect.tsx +++ b/src/components/TimesheetTable/ProjectSelect.tsx @@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next"; import differenceBy from "lodash/differenceBy"; import intersectionWith from "lodash/intersectionWith"; import { TFunction } from "i18next"; +import { LeaveType } from "@/app/api/timesheets"; interface CommonProps { allProjects: ProjectWithTasks[]; @@ -19,11 +20,16 @@ interface CommonProps { error?: boolean; multiple?: boolean; showOnlyOngoing?: boolean; + includeLeaves?: boolean; + leaveTypes?: LeaveType[]; } interface SingleAutocompleteProps extends CommonProps { - value: number | undefined; - onProjectSelect: (projectId: number | string) => void; + value: number | string | undefined; + onProjectSelect: ( + projectId: number | string, + isLeave: boolean, + ) => void | Promise; multiple: false; } @@ -31,6 +37,8 @@ interface MultiAutocompleteProps extends CommonProps { value: (number | undefined)[]; onProjectSelect: (projectIds: Array) => void; multiple: true; + // No leave types for multi select (fast entry) + includeLeaves: false; } type Props = SingleAutocompleteProps | MultiAutocompleteProps; @@ -43,6 +51,8 @@ const getGroupName = (t: TFunction, groupName: string): string => { return t("Assigned Projects"); case "non-assigned": return t("Non-assigned Projects"); + case "leaves": + return t("Leave Types"); case "all-projects": return t("All projects"); default: @@ -58,6 +68,8 @@ const AutocompleteProjectSelect: React.FC = ({ onProjectSelect, error, multiple, + leaveTypes, + includeLeaves, }) => { const { t } = useTranslation("home"); const allFilteredProjects = useMemo(() => { @@ -82,13 +94,20 @@ const AutocompleteProjectSelect: React.FC = ({ label: `${p.code} - ${p.name}`, group: "assigned", })), + ...(includeLeaves && leaveTypes + ? leaveTypes.map((l) => ({ + value: `leave-${l.id}`, + label: l.name, + group: "leaves", + })) + : []), ...nonAssignedProjects.map((p) => ({ value: p.id, label: `${p.code} - ${p.name}`, group: assignedProjects.length === 0 ? "all-projects" : "non-assigned", })), ]; - }, [assignedProjects, nonAssignedProjects, t]); + }, [assignedProjects, includeLeaves, leaveTypes, nonAssignedProjects, t]); const currentValue = multiple ? intersectionWith(options, value, (option, v) => { @@ -99,14 +118,26 @@ const AutocompleteProjectSelect: React.FC = ({ const onChange = useCallback( ( event: React.SyntheticEvent, - newValue: { value: number | string } | { value: number | string }[], + newValue: + | { value: number | string; group: string } + | { value: number | string }[], ) => { if (multiple) { const multiNewValue = newValue as { value: number | string }[]; onProjectSelect(multiNewValue.map(({ value }) => value)); } else { - const singleNewVal = newValue as { value: number | string }; - onProjectSelect(singleNewVal.value); + const singleNewVal = newValue as { + value: number | string; + group: string; + }; + const isLeave = singleNewVal.group === "leaves"; + + onProjectSelect( + isLeave + ? parseInt(singleNewVal.value.toString().split("leave-")[1]) + : singleNewVal.value, + isLeave, + ); } }, [onProjectSelect, multiple], diff --git a/src/components/UserWorkspacePage/UserWorkspacePage.tsx b/src/components/UserWorkspacePage/UserWorkspacePage.tsx index 2995890..ac9aede 100644 --- a/src/components/UserWorkspacePage/UserWorkspacePage.tsx +++ b/src/components/UserWorkspacePage/UserWorkspacePage.tsx @@ -4,27 +4,21 @@ import React, { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import Button from "@mui/material/Button"; import Stack from "@mui/material/Stack"; -import { - CalendarMonth, - EditCalendar, - Luggage, - MoreTime, -} from "@mui/icons-material"; +import { CalendarMonth, EditCalendar, MoreTime } from "@mui/icons-material"; import { Menu, MenuItem, SxProps, Typography } from "@mui/material"; import AssignedProjects from "./AssignedProjects"; -import TimesheetModal from "../TimesheetModal"; import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; import { RecordLeaveInput, RecordTimesheetInput, revalidateCacheAfterAmendment, } from "@/app/api/timesheets/actions"; -import LeaveModal from "../LeaveModal"; import { LeaveType, TeamLeaves, TeamTimeSheets } from "@/app/api/timesheets"; import { CalendarIcon } from "@mui/x-date-pickers"; import PastEntryCalendarModal from "../PastEntryCalendar/PastEntryCalendarModal"; import { HolidaysResult } from "@/app/api/holidays"; import { TimesheetAmendmentModal } from "../TimesheetAmendment/TimesheetAmendmentModal"; +import TimeLeaveModal from "../TimeLeaveModal/TimeLeaveModal"; export interface Props { leaveTypes: LeaveType[]; @@ -60,8 +54,7 @@ const UserWorkspacePage: React.FC = ({ }) => { const [anchorEl, setAnchorEl] = useState(null); - const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false); - const [isLeaveModalVisible, setLeaveModalVisible] = useState(false); + const [isTimeLeaveModalVisible, setTimeLeaveModalVisible] = useState(false); const [isPastEventModalVisible, setPastEventModalVisible] = useState(false); const [isTimesheetAmendmentVisible, setisTimesheetAmendmentVisible] = useState(false); @@ -79,22 +72,13 @@ const UserWorkspacePage: React.FC = ({ setAnchorEl(null); }, []); - const handleAddTimesheetButtonClick = useCallback(() => { + const handleAddTimeLeaveButton = useCallback(() => { setAnchorEl(null); - setTimeheetModalVisible(true); - }, []); - - const handleCloseTimesheetModal = useCallback(() => { - setTimeheetModalVisible(false); + setTimeLeaveModalVisible(true); }, []); - const handleAddLeaveButtonClick = useCallback(() => { - setAnchorEl(null); - setLeaveModalVisible(true); - }, []); - - const handleCloseLeaveModal = useCallback(() => { - setLeaveModalVisible(false); + const handleCloseTimeLeaveModal = useCallback(() => { + setTimeLeaveModalVisible(false); }, []); const handlePastEventClick = useCallback(() => { @@ -148,13 +132,9 @@ const UserWorkspacePage: React.FC = ({ horizontal: "right", }} > - + - {t("Enter Time")} - - - - {t("Record Leave")} + {t("Enter Timesheet")} @@ -175,26 +155,27 @@ const UserWorkspacePage: React.FC = ({ allProjects={allProjects} leaveTypes={leaveTypes} /> - - {assignedProjects.length > 0 ? ( - + ) : ( {t("You have no assigned projects!")}