Pārlūkot izejas kodu

Consolidate time and leave entry

tags/Baseline_30082024_FRONTEND_UAT
Wayne pirms 1 gada
vecāks
revīzija
cd499b434a
8 mainītis faili ar 1342 papildinājumiem un 54 dzēšanām
  1. +30
    -6
      src/app/api/timesheets/actions.ts
  2. +45
    -0
      src/app/api/timesheets/utils.ts
  3. +11
    -0
      src/components/TimeLeaveModal/DisabledEdit.tsx
  4. +626
    -0
      src/components/TimeLeaveModal/TimeLeaveInputTable.tsx
  5. +298
    -0
      src/components/TimeLeaveModal/TimeLeaveMobileEntry.tsx
  6. +272
    -0
      src/components/TimeLeaveModal/TimeLeaveModal.tsx
  7. +37
    -6
      src/components/TimesheetTable/ProjectSelect.tsx
  8. +23
    -42
      src/components/UserWorkspacePage/UserWorkspacePage.tsx

+ 30
- 6
src/app/api/timesheets/actions.ts Parādīt failu

@@ -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;


+ 45
- 0
src/app/api/timesheets/utils.ts Parādīt failu

@@ -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[],


+ 11
- 0
src/components/TimeLeaveModal/DisabledEdit.tsx Parādīt failu

@@ -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;

+ 626
- 0
src/components/TimeLeaveModal/TimeLeaveInputTable.tsx Parādīt failu

@@ -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;

+ 298
- 0
src/components/TimeLeaveModal/TimeLeaveMobileEntry.tsx Parādīt failu

@@ -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;

+ 272
- 0
src/components/TimeLeaveModal/TimeLeaveModal.tsx Parādīt failu

@@ -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;

+ 37
- 6
src/components/TimesheetTable/ProjectSelect.tsx Parādīt failu

@@ -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],


+ 23
- 42
src/components/UserWorkspacePage/UserWorkspacePage.tsx Parādīt failu

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


Notiek ielāde…
Atcelt
Saglabāt