| @@ -31,6 +31,14 @@ export interface RecordLeaveInput { | |||||
| [date: string]: LeaveEntry[]; | [date: string]: LeaveEntry[]; | ||||
| } | } | ||||
| export type TimeLeaveEntry = | |||||
| | (TimeEntry & { type: "timeEntry" }) | |||||
| | (LeaveEntry & { type: "leaveEntry" }); | |||||
| export interface RecordTimeLeaveInput { | |||||
| [date: string]: TimeLeaveEntry[]; | |||||
| } | |||||
| export const saveTimesheet = async (data: RecordTimesheetInput) => { | export const saveTimesheet = async (data: RecordTimesheetInput) => { | ||||
| const savedRecords = await serverFetchJson<RecordTimesheetInput>( | const savedRecords = await serverFetchJson<RecordTimesheetInput>( | ||||
| `${BASE_API_URL}/timesheets/save`, | `${BASE_API_URL}/timesheets/save`, | ||||
| @@ -61,6 +69,22 @@ export const saveLeave = async (data: RecordLeaveInput) => { | |||||
| return savedRecords; | return savedRecords; | ||||
| }; | }; | ||||
| export const saveTimeLeave = async (data: RecordTimeLeaveInput) => { | |||||
| const savedRecords = await serverFetchJson<RecordTimeLeaveInput>( | |||||
| `${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: { | export const saveMemberEntry = async (data: { | ||||
| staffId: number; | staffId: number; | ||||
| entry: TimeEntry; | entry: TimeEntry; | ||||
| @@ -124,12 +148,12 @@ export const revalidateCacheAfterAmendment = () => { | |||||
| }; | }; | ||||
| export const importTimesheets = async (data: FormData) => { | export const importTimesheets = async (data: FormData) => { | ||||
| const importTimesheets = await serverFetchString<String>( | |||||
| `${BASE_API_URL}/timesheets/import`, | |||||
| { | |||||
| method: "POST", | |||||
| body: data, | |||||
| }, | |||||
| const importTimesheets = await serverFetchString<string>( | |||||
| `${BASE_API_URL}/timesheets/import`, | |||||
| { | |||||
| method: "POST", | |||||
| body: data, | |||||
| }, | |||||
| ); | ); | ||||
| return importTimesheets; | return importTimesheets; | ||||
| @@ -3,6 +3,7 @@ import { HolidaysResult } from "../holidays"; | |||||
| import { | import { | ||||
| LeaveEntry, | LeaveEntry, | ||||
| RecordLeaveInput, | RecordLeaveInput, | ||||
| RecordTimeLeaveInput, | |||||
| RecordTimesheetInput, | RecordTimesheetInput, | ||||
| TimeEntry, | TimeEntry, | ||||
| } from "./actions"; | } from "./actions"; | ||||
| @@ -158,6 +159,50 @@ export const validateLeaveRecord = ( | |||||
| return Object.keys(errors).length > 0 ? errors : undefined; | 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 = ( | export const checkTotalHours = ( | ||||
| timeEntries: TimeEntry[], | timeEntries: TimeEntry[], | ||||
| leaves: LeaveEntry[], | leaves: LeaveEntry[], | ||||
| @@ -0,0 +1,11 @@ | |||||
| import { Box } from "@mui/material"; | |||||
| const DisabledEdit: React.FC = () => { | |||||
| return ( | |||||
| <Box | |||||
| sx={{ backgroundColor: "neutral.200", width: "100%", height: "100%" }} | |||||
| /> | |||||
| ); | |||||
| }; | |||||
| export default DisabledEdit; | |||||
| @@ -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<Props> = ({ | |||||
| 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<RecordTimeLeaveInput>(); | |||||
| const currentEntries = getValues(day); | |||||
| const [entries, setEntries] = useState<TimeLeaveRow[]>(currentEntries || []); | |||||
| const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | |||||
| 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<GridEventListener<"rowEditStop">>( | |||||
| (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<GridColDef[]>( | |||||
| () => [ | |||||
| { | |||||
| type: "actions", | |||||
| field: "actions", | |||||
| headerName: t("Actions"), | |||||
| getActions: ({ id }) => { | |||||
| if (rowModesModel[id]?.mode === GridRowModes.Edit) { | |||||
| return [ | |||||
| <GridActionsCellItem | |||||
| key="accpet-action" | |||||
| icon={<Check />} | |||||
| label={t("Save")} | |||||
| onClick={handleSave(id)} | |||||
| />, | |||||
| <GridActionsCellItem | |||||
| key="cancel-action" | |||||
| icon={<Close />} | |||||
| label={t("Cancel")} | |||||
| onClick={handleCancel(id)} | |||||
| />, | |||||
| ]; | |||||
| } | |||||
| return [ | |||||
| <GridActionsCellItem | |||||
| key="delete-action" | |||||
| icon={<Delete />} | |||||
| 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<TimeLeaveRow>(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<TimeLeaveRow, number>) { | |||||
| return ( | |||||
| <ProjectSelect | |||||
| includeLeaves | |||||
| leaveTypes={leaveTypes} | |||||
| multiple={false} | |||||
| allProjects={allProjects} | |||||
| assignedProjects={assignedProjects} | |||||
| value={ | |||||
| (params.row.type === "leaveEntry" | |||||
| ? `leave-${params.row.leaveTypeId}` | |||||
| : undefined) || | |||||
| (params.row.type === "timeEntry" | |||||
| ? params.row.projectId | |||||
| : undefined) | |||||
| } | |||||
| onProjectSelect={async (projectOrLeaveId, isLeave) => { | |||||
| 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<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"); | |||||
| }} | |||||
| /> | |||||
| ); | |||||
| } 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"); | |||||
| }, | |||||
| }, | |||||
| { | |||||
| field: "taskId", | |||||
| headerName: t("Task"), | |||||
| width: 200, | |||||
| editable: true, | |||||
| renderEditCell(params: GridRenderEditCellParams<TimeLeaveRow, number>) { | |||||
| if (params.row.type === "timeEntry") { | |||||
| return ( | |||||
| <TaskSelect | |||||
| value={params.value} | |||||
| projectId={params.row.projectId} | |||||
| taskGroupId={params.row.taskGroupId} | |||||
| allProjects={allProjects} | |||||
| onTaskSelect={(taskId) => { | |||||
| params.api.setEditCellValue({ | |||||
| id: params.id, | |||||
| field: params.field, | |||||
| value: taskId, | |||||
| }); | |||||
| params.api.setCellFocus(params.id, "inputHours"); | |||||
| }} | |||||
| /> | |||||
| ); | |||||
| } else { | |||||
| return <DisabledEdit />; | |||||
| } | |||||
| }, | |||||
| 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<TimeLeaveRow>) { | |||||
| const errorMessage = | |||||
| params.row._error?.[ | |||||
| params.field as keyof Omit<TimeLeaveEntry, "type"> | |||||
| ]; | |||||
| const content = <GridEditInputCell {...params} />; | |||||
| return errorMessage ? ( | |||||
| <Tooltip title={t(errorMessage, { DAILY_NORMAL_MAX_HOURS })}> | |||||
| <Box width="100%">{content}</Box> | |||||
| </Tooltip> | |||||
| ) : ( | |||||
| 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<TimeLeaveRow>) { | |||||
| if (params.row.type === "leaveEntry") { | |||||
| return <DisabledEdit />; | |||||
| } | |||||
| const errorMessage = | |||||
| params.row._error?.[ | |||||
| params.field as keyof Omit<TimeLeaveEntry, "type"> | |||||
| ]; | |||||
| const content = <GridEditInputCell {...params} />; | |||||
| return errorMessage ? ( | |||||
| <Tooltip title={t(errorMessage)}> | |||||
| <Box width="100%">{content}</Box> | |||||
| </Tooltip> | |||||
| ) : ( | |||||
| 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<TimeLeaveRow>) { | |||||
| const errorMessage = | |||||
| params.row._error?.[ | |||||
| params.field as keyof Omit<TimeLeaveEntry, "type"> | |||||
| ]; | |||||
| const content = <GridEditInputCell {...params} />; | |||||
| return errorMessage ? ( | |||||
| <Tooltip title={t(errorMessage)}> | |||||
| <Box width="100%">{content}</Box> | |||||
| </Tooltip> | |||||
| ) : ( | |||||
| 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 = ( | |||||
| <Box display="flex" gap={2} alignItems="center"> | |||||
| <Button | |||||
| disableRipple | |||||
| variant="outlined" | |||||
| startIcon={<Add />} | |||||
| onClick={addRow} | |||||
| size="small" | |||||
| > | |||||
| {t("Record time or leave")} | |||||
| </Button> | |||||
| {fastEntryEnabled && ( | |||||
| <Button | |||||
| disableRipple | |||||
| variant="outlined" | |||||
| startIcon={<Add />} | |||||
| onClick={openFastEntryModal} | |||||
| size="small" | |||||
| > | |||||
| {t("Fast time entry")} | |||||
| </Button> | |||||
| )} | |||||
| {hasOutOfPlannedStages && ( | |||||
| <Typography color="warning.main" variant="body2"> | |||||
| {t("There are entries for stages out of planned dates!")} | |||||
| </Typography> | |||||
| )} | |||||
| </Box> | |||||
| ); | |||||
| return ( | |||||
| <> | |||||
| <StyledDataGrid | |||||
| apiRef={apiRef} | |||||
| autoHeight | |||||
| sx={{ | |||||
| "--DataGrid-overlayHeight": "100px", | |||||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { | |||||
| border: "1px solid", | |||||
| borderColor: "error.main", | |||||
| }, | |||||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": { | |||||
| border: "1px solid", | |||||
| borderColor: "warning.main", | |||||
| }, | |||||
| }} | |||||
| disableColumnMenu | |||||
| editMode="row" | |||||
| rows={entries} | |||||
| rowModesModel={rowModesModel} | |||||
| onRowModesModelChange={setRowModesModel} | |||||
| onRowEditStop={handleEditStop} | |||||
| processRowUpdate={processRowUpdate} | |||||
| columns={columns} | |||||
| getCellClassName={(params: GridCellParams<TimeLeaveRow>) => { | |||||
| let classname = ""; | |||||
| if ( | |||||
| params.row._error?.[ | |||||
| params.field as keyof Omit<TimeLeaveEntry, "type"> | |||||
| ] | |||||
| ) { | |||||
| 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 && ( | |||||
| <FastTimeEntryModal | |||||
| allProjects={allProjects} | |||||
| assignedProjects={assignedProjects} | |||||
| open={fastEntryModalOpen} | |||||
| isHoliday={Boolean(isHoliday)} | |||||
| onClose={closeFastEntryModal} | |||||
| onSave={onSaveFastEntry} | |||||
| /> | |||||
| )} | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| const NoRowsOverlay: React.FC = () => { | |||||
| const { t } = useTranslation("home"); | |||||
| return ( | |||||
| <Box | |||||
| display="flex" | |||||
| justifyContent="center" | |||||
| alignItems="center" | |||||
| height="100%" | |||||
| > | |||||
| <Typography variant="caption">{t("Add some time entries!")}</Typography> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => { | |||||
| return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>; | |||||
| }; | |||||
| export default TimeLeaveInputTable; | |||||
| @@ -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<Props> = ({ | |||||
| 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<RecordTimeLeaveInput>(); | |||||
| const currentEntries = watch(date); | |||||
| // Time entry edit modal | |||||
| const [editTimeModalProps, setEditTimeModalProps] = useState< | |||||
| Partial<TimesheetEditModalProps> | |||||
| >({}); | |||||
| 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<LeaveEditModalProps> | |||||
| >({}); | |||||
| 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 ( | |||||
| <> | |||||
| <Typography | |||||
| paddingInline={2} | |||||
| variant="overline" | |||||
| color={isHoliday ? "error.main" : undefined} | |||||
| > | |||||
| {shortDateFormatter(language).format(dayJsObj.toDate())} | |||||
| {holiday && ( | |||||
| <Typography | |||||
| marginInlineStart={1} | |||||
| variant="caption" | |||||
| >{`(${holiday.title})`}</Typography> | |||||
| )} | |||||
| </Typography> | |||||
| <Box | |||||
| paddingInline={2} | |||||
| flex={1} | |||||
| display="flex" | |||||
| flexDirection="column" | |||||
| gap={2} | |||||
| overflow="scroll" | |||||
| > | |||||
| {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 ( | |||||
| <TimeEntryCard | |||||
| key={`${entry.id}-${index}`} | |||||
| project={project} | |||||
| task={task} | |||||
| entry={entry} | |||||
| onEdit={openEditTimeModal(entry)} | |||||
| /> | |||||
| ); | |||||
| } else { | |||||
| return ( | |||||
| <LeaveEntryCard | |||||
| key={`${entry.id}-${index}`} | |||||
| entry={entry} | |||||
| onEdit={openEditLeaveModal(entry)} | |||||
| leaveTypeMap={leaveTypeMap} | |||||
| /> | |||||
| ); | |||||
| } | |||||
| }) | |||||
| ) : ( | |||||
| <Typography variant="body2" display="block"> | |||||
| {t("Add some time entries!")} | |||||
| </Typography> | |||||
| )} | |||||
| <Stack alignItems={"flex-start"} spacing={1}> | |||||
| <Button startIcon={<Add />} onClick={openEditTimeModal()}> | |||||
| {t("Record time")} | |||||
| </Button> | |||||
| <Button startIcon={<Add />} onClick={openEditLeaveModal()}> | |||||
| {t("Record leave")} | |||||
| </Button> | |||||
| {fastEntryEnabled && ( | |||||
| <Button startIcon={<Add />} onClick={openFastEntryModal}> | |||||
| {t("Fast time entry")} | |||||
| </Button> | |||||
| )} | |||||
| </Stack> | |||||
| <TimesheetEditModal | |||||
| allProjects={allProjects} | |||||
| assignedProjects={assignedProjects} | |||||
| open={editTimeModalOpen} | |||||
| onClose={closeEditTimeModal} | |||||
| onSave={onSaveTimeEntry} | |||||
| isHoliday={Boolean(isHoliday)} | |||||
| fastEntryEnabled={fastEntryEnabled} | |||||
| {...editTimeModalProps} | |||||
| /> | |||||
| <LeaveEditModal | |||||
| leaveTypes={leaveTypes} | |||||
| open={editLeaveModalOpen} | |||||
| onClose={closeEditLeaveModal} | |||||
| onSave={onSaveLeaveEntry} | |||||
| isHoliday={Boolean(isHoliday)} | |||||
| {...editLeaveModalProps} | |||||
| /> | |||||
| {fastEntryEnabled && ( | |||||
| <FastTimeEntryModal | |||||
| allProjects={allProjects} | |||||
| assignedProjects={assignedProjects} | |||||
| open={fastEntryModalOpen} | |||||
| isHoliday={Boolean(isHoliday)} | |||||
| onClose={closeFastEntryModal} | |||||
| onSave={onSaveFastEntry} | |||||
| /> | |||||
| )} | |||||
| </Box> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default TimeLeaveMobileEntry; | |||||
| @@ -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<Props> = ({ | |||||
| 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<RecordTimeLeaveInput>((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<RecordTimeLeaveInput>({ defaultValues }); | |||||
| useEffect(() => { | |||||
| formProps.reset(defaultValues); | |||||
| }, [defaultValues, formProps]); | |||||
| const onSubmit = useCallback<SubmitHandler<RecordTimeLeaveInput>>( | |||||
| 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<RecordTimeLeaveInput>((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<NonNullable<ModalProps["onClose"]>>( | |||||
| (_, reason) => { | |||||
| if (reason !== "backdropClick") { | |||||
| onClose(); | |||||
| } | |||||
| }, | |||||
| [onClose], | |||||
| ); | |||||
| const errorComponent = ( | |||||
| <ErrorAlert | |||||
| errors={Object.keys(formProps.formState.errors).map((date) => { | |||||
| 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 ( | |||||
| <FormProvider {...formProps}> | |||||
| {!matches ? ( | |||||
| // Desktop version | |||||
| <Modal open={isOpen} onClose={onModalClose}> | |||||
| <Card sx={modalSx}> | |||||
| <CardContent | |||||
| component="form" | |||||
| onSubmit={formProps.handleSubmit(onSubmit)} | |||||
| > | |||||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
| {t("Timesheet Input")} | |||||
| </Typography> | |||||
| <Box | |||||
| sx={{ | |||||
| marginInline: -3, | |||||
| marginBlock: 4, | |||||
| }} | |||||
| > | |||||
| <DateHoursTable | |||||
| companyHolidays={companyHolidays} | |||||
| days={currentDays} | |||||
| leaveEntries={currentLeaveEntries} | |||||
| timesheetEntries={currentTimeEntries} | |||||
| EntryTableComponent={TimeLeaveInputTable} | |||||
| entryTableProps={{ | |||||
| assignedProjects, | |||||
| allProjects, | |||||
| fastEntryEnabled, | |||||
| leaveTypes, | |||||
| }} | |||||
| /> | |||||
| </Box> | |||||
| {errorComponent} | |||||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Close />} | |||||
| onClick={onCancel} | |||||
| > | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| <Button variant="contained" startIcon={<Check />} type="submit"> | |||||
| {t("Save")} | |||||
| </Button> | |||||
| </CardActions> | |||||
| </CardContent> | |||||
| </Card> | |||||
| </Modal> | |||||
| ) : ( | |||||
| // Mobile version | |||||
| <FullscreenModal | |||||
| open={isOpen} | |||||
| onClose={onModalClose} | |||||
| closeModal={onCancel} | |||||
| > | |||||
| <Box | |||||
| display="flex" | |||||
| flexDirection="column" | |||||
| gap={2} | |||||
| height="100%" | |||||
| component="form" | |||||
| onSubmit={formProps.handleSubmit(onSubmit)} | |||||
| > | |||||
| <Typography variant="h6" padding={2} flex="none"> | |||||
| {t("Timesheet Input")} | |||||
| </Typography> | |||||
| <DateHoursList | |||||
| days={currentDays} | |||||
| companyHolidays={companyHolidays} | |||||
| leaveEntries={currentLeaveEntries} | |||||
| timesheetEntries={currentTimeEntries} | |||||
| EntryComponent={TimeLeaveMobileEntry} | |||||
| entryComponentProps={{ | |||||
| allProjects, | |||||
| assignedProjects, | |||||
| companyHolidays, | |||||
| fastEntryEnabled, | |||||
| leaveTypes, | |||||
| }} | |||||
| errorComponent={errorComponent} | |||||
| /> | |||||
| </Box> | |||||
| </FullscreenModal> | |||||
| )} | |||||
| </FormProvider> | |||||
| ); | |||||
| }; | |||||
| export default TimeLeaveModal; | |||||
| @@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next"; | |||||
| import differenceBy from "lodash/differenceBy"; | import differenceBy from "lodash/differenceBy"; | ||||
| import intersectionWith from "lodash/intersectionWith"; | import intersectionWith from "lodash/intersectionWith"; | ||||
| import { TFunction } from "i18next"; | import { TFunction } from "i18next"; | ||||
| import { LeaveType } from "@/app/api/timesheets"; | |||||
| interface CommonProps { | interface CommonProps { | ||||
| allProjects: ProjectWithTasks[]; | allProjects: ProjectWithTasks[]; | ||||
| @@ -19,11 +20,16 @@ interface CommonProps { | |||||
| error?: boolean; | error?: boolean; | ||||
| multiple?: boolean; | multiple?: boolean; | ||||
| showOnlyOngoing?: boolean; | showOnlyOngoing?: boolean; | ||||
| includeLeaves?: boolean; | |||||
| leaveTypes?: LeaveType[]; | |||||
| } | } | ||||
| interface SingleAutocompleteProps extends CommonProps { | interface SingleAutocompleteProps extends CommonProps { | ||||
| value: number | undefined; | |||||
| onProjectSelect: (projectId: number | string) => void; | |||||
| value: number | string | undefined; | |||||
| onProjectSelect: ( | |||||
| projectId: number | string, | |||||
| isLeave: boolean, | |||||
| ) => void | Promise<void>; | |||||
| multiple: false; | multiple: false; | ||||
| } | } | ||||
| @@ -31,6 +37,8 @@ interface MultiAutocompleteProps extends CommonProps { | |||||
| value: (number | undefined)[]; | value: (number | undefined)[]; | ||||
| onProjectSelect: (projectIds: Array<number | string>) => void; | onProjectSelect: (projectIds: Array<number | string>) => void; | ||||
| multiple: true; | multiple: true; | ||||
| // No leave types for multi select (fast entry) | |||||
| includeLeaves: false; | |||||
| } | } | ||||
| type Props = SingleAutocompleteProps | MultiAutocompleteProps; | type Props = SingleAutocompleteProps | MultiAutocompleteProps; | ||||
| @@ -43,6 +51,8 @@ const getGroupName = (t: TFunction, groupName: string): string => { | |||||
| return t("Assigned Projects"); | return t("Assigned Projects"); | ||||
| case "non-assigned": | case "non-assigned": | ||||
| return t("Non-assigned Projects"); | return t("Non-assigned Projects"); | ||||
| case "leaves": | |||||
| return t("Leave Types"); | |||||
| case "all-projects": | case "all-projects": | ||||
| return t("All projects"); | return t("All projects"); | ||||
| default: | default: | ||||
| @@ -58,6 +68,8 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||||
| onProjectSelect, | onProjectSelect, | ||||
| error, | error, | ||||
| multiple, | multiple, | ||||
| leaveTypes, | |||||
| includeLeaves, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| const allFilteredProjects = useMemo(() => { | const allFilteredProjects = useMemo(() => { | ||||
| @@ -82,13 +94,20 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||||
| label: `${p.code} - ${p.name}`, | label: `${p.code} - ${p.name}`, | ||||
| group: "assigned", | group: "assigned", | ||||
| })), | })), | ||||
| ...(includeLeaves && leaveTypes | |||||
| ? leaveTypes.map((l) => ({ | |||||
| value: `leave-${l.id}`, | |||||
| label: l.name, | |||||
| group: "leaves", | |||||
| })) | |||||
| : []), | |||||
| ...nonAssignedProjects.map((p) => ({ | ...nonAssignedProjects.map((p) => ({ | ||||
| value: p.id, | value: p.id, | ||||
| label: `${p.code} - ${p.name}`, | label: `${p.code} - ${p.name}`, | ||||
| group: assignedProjects.length === 0 ? "all-projects" : "non-assigned", | group: assignedProjects.length === 0 ? "all-projects" : "non-assigned", | ||||
| })), | })), | ||||
| ]; | ]; | ||||
| }, [assignedProjects, nonAssignedProjects, t]); | |||||
| }, [assignedProjects, includeLeaves, leaveTypes, nonAssignedProjects, t]); | |||||
| const currentValue = multiple | const currentValue = multiple | ||||
| ? intersectionWith(options, value, (option, v) => { | ? intersectionWith(options, value, (option, v) => { | ||||
| @@ -99,14 +118,26 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||||
| const onChange = useCallback( | const onChange = useCallback( | ||||
| ( | ( | ||||
| event: React.SyntheticEvent, | event: React.SyntheticEvent, | ||||
| newValue: { value: number | string } | { value: number | string }[], | |||||
| newValue: | |||||
| | { value: number | string; group: string } | |||||
| | { value: number | string }[], | |||||
| ) => { | ) => { | ||||
| if (multiple) { | if (multiple) { | ||||
| const multiNewValue = newValue as { value: number | string }[]; | const multiNewValue = newValue as { value: number | string }[]; | ||||
| onProjectSelect(multiNewValue.map(({ value }) => value)); | onProjectSelect(multiNewValue.map(({ value }) => value)); | ||||
| } else { | } 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], | [onProjectSelect, multiple], | ||||
| @@ -4,27 +4,21 @@ import React, { useCallback, useState } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| import Stack from "@mui/material/Stack"; | 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 { Menu, MenuItem, SxProps, Typography } from "@mui/material"; | ||||
| import AssignedProjects from "./AssignedProjects"; | import AssignedProjects from "./AssignedProjects"; | ||||
| import TimesheetModal from "../TimesheetModal"; | |||||
| import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | ||||
| import { | import { | ||||
| RecordLeaveInput, | RecordLeaveInput, | ||||
| RecordTimesheetInput, | RecordTimesheetInput, | ||||
| revalidateCacheAfterAmendment, | revalidateCacheAfterAmendment, | ||||
| } from "@/app/api/timesheets/actions"; | } from "@/app/api/timesheets/actions"; | ||||
| import LeaveModal from "../LeaveModal"; | |||||
| import { LeaveType, TeamLeaves, TeamTimeSheets } from "@/app/api/timesheets"; | import { LeaveType, TeamLeaves, TeamTimeSheets } from "@/app/api/timesheets"; | ||||
| import { CalendarIcon } from "@mui/x-date-pickers"; | import { CalendarIcon } from "@mui/x-date-pickers"; | ||||
| import PastEntryCalendarModal from "../PastEntryCalendar/PastEntryCalendarModal"; | import PastEntryCalendarModal from "../PastEntryCalendar/PastEntryCalendarModal"; | ||||
| import { HolidaysResult } from "@/app/api/holidays"; | import { HolidaysResult } from "@/app/api/holidays"; | ||||
| import { TimesheetAmendmentModal } from "../TimesheetAmendment/TimesheetAmendmentModal"; | import { TimesheetAmendmentModal } from "../TimesheetAmendment/TimesheetAmendmentModal"; | ||||
| import TimeLeaveModal from "../TimeLeaveModal/TimeLeaveModal"; | |||||
| export interface Props { | export interface Props { | ||||
| leaveTypes: LeaveType[]; | leaveTypes: LeaveType[]; | ||||
| @@ -60,8 +54,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| }) => { | }) => { | ||||
| const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||
| const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false); | |||||
| const [isLeaveModalVisible, setLeaveModalVisible] = useState(false); | |||||
| const [isTimeLeaveModalVisible, setTimeLeaveModalVisible] = useState(false); | |||||
| const [isPastEventModalVisible, setPastEventModalVisible] = useState(false); | const [isPastEventModalVisible, setPastEventModalVisible] = useState(false); | ||||
| const [isTimesheetAmendmentVisible, setisTimesheetAmendmentVisible] = | const [isTimesheetAmendmentVisible, setisTimesheetAmendmentVisible] = | ||||
| useState(false); | useState(false); | ||||
| @@ -79,22 +72,13 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| setAnchorEl(null); | setAnchorEl(null); | ||||
| }, []); | }, []); | ||||
| const handleAddTimesheetButtonClick = useCallback(() => { | |||||
| const handleAddTimeLeaveButton = useCallback(() => { | |||||
| setAnchorEl(null); | 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(() => { | const handlePastEventClick = useCallback(() => { | ||||
| @@ -148,13 +132,9 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| horizontal: "right", | horizontal: "right", | ||||
| }} | }} | ||||
| > | > | ||||
| <MenuItem onClick={handleAddTimesheetButtonClick} sx={menuItemSx}> | |||||
| <MenuItem onClick={handleAddTimeLeaveButton} sx={menuItemSx}> | |||||
| <MoreTime /> | <MoreTime /> | ||||
| {t("Enter Time")} | |||||
| </MenuItem> | |||||
| <MenuItem onClick={handleAddLeaveButtonClick} sx={menuItemSx}> | |||||
| <Luggage /> | |||||
| {t("Record Leave")} | |||||
| {t("Enter Timesheet")} | |||||
| </MenuItem> | </MenuItem> | ||||
| <MenuItem onClick={handlePastEventClick} sx={menuItemSx}> | <MenuItem onClick={handlePastEventClick} sx={menuItemSx}> | ||||
| <CalendarMonth /> | <CalendarMonth /> | ||||
| @@ -175,26 +155,27 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| leaveTypes={leaveTypes} | leaveTypes={leaveTypes} | ||||
| /> | /> | ||||
| <TimesheetModal | |||||
| <TimeLeaveModal | |||||
| fastEntryEnabled={fastEntryEnabled} | fastEntryEnabled={fastEntryEnabled} | ||||
| companyHolidays={holidays} | companyHolidays={holidays} | ||||
| isOpen={isTimeheetModalVisible} | |||||
| onClose={handleCloseTimesheetModal} | |||||
| isOpen={isTimeLeaveModalVisible} | |||||
| onClose={handleCloseTimeLeaveModal} | |||||
| leaveTypes={leaveTypes} | |||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
| defaultTimesheets={defaultTimesheets} | |||||
| leaveRecords={defaultLeaveRecords} | |||||
| /> | |||||
| <LeaveModal | |||||
| companyHolidays={holidays} | |||||
| leaveTypes={leaveTypes} | |||||
| isOpen={isLeaveModalVisible} | |||||
| onClose={handleCloseLeaveModal} | |||||
| defaultLeaveRecords={defaultLeaveRecords} | |||||
| timesheetRecords={defaultTimesheets} | timesheetRecords={defaultTimesheets} | ||||
| leaveRecords={defaultLeaveRecords} | |||||
| /> | /> | ||||
| {assignedProjects.length > 0 ? ( | {assignedProjects.length > 0 ? ( | ||||
| <AssignedProjects assignedProjects={assignedProjects} maintainNormalStaffWorkspaceAbility={maintainNormalStaffWorkspaceAbility} maintainManagementStaffWorkspaceAbility={maintainManagementStaffWorkspaceAbility}/> | |||||
| <AssignedProjects | |||||
| assignedProjects={assignedProjects} | |||||
| maintainNormalStaffWorkspaceAbility={ | |||||
| maintainNormalStaffWorkspaceAbility | |||||
| } | |||||
| maintainManagementStaffWorkspaceAbility={ | |||||
| maintainManagementStaffWorkspaceAbility | |||||
| } | |||||
| /> | |||||
| ) : ( | ) : ( | ||||
| <Typography variant="subtitle1"> | <Typography variant="subtitle1"> | ||||
| {t("You have no assigned projects!")} | {t("You have no assigned projects!")} | ||||