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