| @@ -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<RecordTimesheetInput>( | |||
| `${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<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: { | |||
| staffId: number; | |||
| entry: TimeEntry; | |||
| @@ -124,12 +148,12 @@ export const revalidateCacheAfterAmendment = () => { | |||
| }; | |||
| 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; | |||
| @@ -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[], | |||
| @@ -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 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<void>; | |||
| multiple: false; | |||
| } | |||
| @@ -31,6 +37,8 @@ interface MultiAutocompleteProps extends CommonProps { | |||
| value: (number | undefined)[]; | |||
| onProjectSelect: (projectIds: Array<number | string>) => 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<Props> = ({ | |||
| onProjectSelect, | |||
| error, | |||
| multiple, | |||
| leaveTypes, | |||
| includeLeaves, | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| const allFilteredProjects = useMemo(() => { | |||
| @@ -82,13 +94,20 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||
| 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<Props> = ({ | |||
| 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], | |||
| @@ -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<Props> = ({ | |||
| }) => { | |||
| 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 [isTimesheetAmendmentVisible, setisTimesheetAmendmentVisible] = | |||
| useState(false); | |||
| @@ -79,22 +72,13 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| 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<Props> = ({ | |||
| horizontal: "right", | |||
| }} | |||
| > | |||
| <MenuItem onClick={handleAddTimesheetButtonClick} sx={menuItemSx}> | |||
| <MenuItem onClick={handleAddTimeLeaveButton} sx={menuItemSx}> | |||
| <MoreTime /> | |||
| {t("Enter Time")} | |||
| </MenuItem> | |||
| <MenuItem onClick={handleAddLeaveButtonClick} sx={menuItemSx}> | |||
| <Luggage /> | |||
| {t("Record Leave")} | |||
| {t("Enter Timesheet")} | |||
| </MenuItem> | |||
| <MenuItem onClick={handlePastEventClick} sx={menuItemSx}> | |||
| <CalendarMonth /> | |||
| @@ -175,26 +155,27 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| allProjects={allProjects} | |||
| leaveTypes={leaveTypes} | |||
| /> | |||
| <TimesheetModal | |||
| <TimeLeaveModal | |||
| fastEntryEnabled={fastEntryEnabled} | |||
| companyHolidays={holidays} | |||
| isOpen={isTimeheetModalVisible} | |||
| onClose={handleCloseTimesheetModal} | |||
| isOpen={isTimeLeaveModalVisible} | |||
| onClose={handleCloseTimeLeaveModal} | |||
| leaveTypes={leaveTypes} | |||
| allProjects={allProjects} | |||
| assignedProjects={assignedProjects} | |||
| defaultTimesheets={defaultTimesheets} | |||
| leaveRecords={defaultLeaveRecords} | |||
| /> | |||
| <LeaveModal | |||
| companyHolidays={holidays} | |||
| leaveTypes={leaveTypes} | |||
| isOpen={isLeaveModalVisible} | |||
| onClose={handleCloseLeaveModal} | |||
| defaultLeaveRecords={defaultLeaveRecords} | |||
| timesheetRecords={defaultTimesheets} | |||
| leaveRecords={defaultLeaveRecords} | |||
| /> | |||
| {assignedProjects.length > 0 ? ( | |||
| <AssignedProjects assignedProjects={assignedProjects} maintainNormalStaffWorkspaceAbility={maintainNormalStaffWorkspaceAbility} maintainManagementStaffWorkspaceAbility={maintainManagementStaffWorkspaceAbility}/> | |||
| <AssignedProjects | |||
| assignedProjects={assignedProjects} | |||
| maintainNormalStaffWorkspaceAbility={ | |||
| maintainNormalStaffWorkspaceAbility | |||
| } | |||
| maintainManagementStaffWorkspaceAbility={ | |||
| maintainManagementStaffWorkspaceAbility | |||
| } | |||
| /> | |||
| ) : ( | |||
| <Typography variant="subtitle1"> | |||
| {t("You have no assigned projects!")} | |||