@@ -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!")} | |||