Преглед на файлове

Add mobile input for timesheet

tags/Baseline_30082024_FRONTEND_UAT
Wayne преди 1 година
родител
ревизия
a8d3a9099a
променени са 8 файла, в които са добавени 584 реда и са изтрити 84 реда
  1. +2
    -1
      src/components/LeaveTable/LeaveEditModal.tsx
  2. +82
    -75
      src/components/LeaveTable/MobileLeaveEntry.tsx
  3. +0
    -1
      src/components/TimesheetTable/EntryInputTable.tsx
  4. +194
    -2
      src/components/TimesheetTable/MobileTimesheetEntry.tsx
  5. +47
    -0
      src/components/TimesheetTable/ProjectSelect.tsx
  6. +8
    -1
      src/components/TimesheetTable/TaskGroupSelect.tsx
  7. +4
    -4
      src/components/TimesheetTable/TaskSelect.tsx
  8. +247
    -0
      src/components/TimesheetTable/TimesheetEditModal.tsx

+ 2
- 1
src/components/LeaveTable/LeaveEditModal.tsx Целия файл

@@ -57,8 +57,9 @@ const LeaveEditModal: React.FC<Props> = ({
const valid = await trigger(); const valid = await trigger();
if (valid) { if (valid) {
onSave(getValues()); onSave(getValues());
reset();
} }
}, [getValues, onSave, trigger]);
}, [getValues, onSave, reset, trigger]);


const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
(...args) => { (...args) => {


+ 82
- 75
src/components/LeaveTable/MobileLeaveEntry.tsx Целия файл

@@ -86,93 +86,100 @@ const MobileLeaveEntry: React.FC<Props> = ({ date, leaveTypes }) => {
); );


return ( return (
<Box
marginInline={2}
flex={1}
display="flex"
flexDirection="column"
gap={2}
>
<>
<Typography <Typography
paddingInline={2}
variant="overline" variant="overline"
color={dayJsObj.day() === 0 ? "error.main" : undefined} color={dayJsObj.day() === 0 ? "error.main" : undefined}
> >
{shortDateFormatter(language).format(dayJsObj.toDate())} {shortDateFormatter(language).format(dayJsObj.toDate())}
</Typography> </Typography>
{currentEntries.length ? (
currentEntries.map((entry, index) => {
return (
<Card key={`${entry.id}-${index}`} sx={{ marginInline: 1 }}>
<CardContent
sx={{
padding: 2,
display: "flex",
flexDirection: "column",
gap: 2,
"&:last-child": {
paddingBottom: 2,
},
}}
<Box
paddingInline={2}
flex={1}
display="flex"
flexDirection="column"
gap={2}
overflow="scroll"
>
{currentEntries.length ? (
currentEntries.map((entry, index) => {
return (
<Card
key={`${entry.id}-${index}`}
sx={{ marginInline: 1, overflow: "visible" }}
> >
<Box
display="flex"
justifyContent="space-between"
alignItems="flex-start"
<CardContent
sx={{
padding: 2,
display: "flex",
flexDirection: "column",
gap: 2,
"&:last-child": {
paddingBottom: 2,
},
}}
> >
<Box>
<Typography
variant="body2"
component="div"
fontWeight="bold"
>
{leaveTypeMap[entry.leaveTypeId].name}
</Typography>
<Typography component="p">
{manhourFormatter.format(entry.inputHours)}
</Typography>
</Box>
<IconButton
size="small"
color="primary"
onClick={openEditModal(entry)}
<Box
display="flex"
justifyContent="space-between"
alignItems="flex-start"
> >
<Edit />
</IconButton>
</Box>
{entry.remark && (
<Box>
<Typography
variant="body2"
component="div"
fontWeight="bold"
<Box>
<Typography
variant="body2"
component="div"
fontWeight="bold"
>
{leaveTypeMap[entry.leaveTypeId].name}
</Typography>
<Typography component="p">
{manhourFormatter.format(entry.inputHours)}
</Typography>
</Box>
<IconButton
size="small"
color="primary"
onClick={openEditModal(entry)}
> >
{t("Remark")}
</Typography>
<Typography component="p">{entry.remark}</Typography>
<Edit />
</IconButton>
</Box> </Box>
)}
</CardContent>
</Card>
);
})
) : (
<Typography variant="body2" display="block">
{t("Add some leave entries!")}
</Typography>
)}
<Box>
<Button startIcon={<Add />} onClick={openEditModal()}>
{t("Record leave")}
</Button>
{entry.remark && (
<Box>
<Typography
variant="body2"
component="div"
fontWeight="bold"
>
{t("Remark")}
</Typography>
<Typography component="p">{entry.remark}</Typography>
</Box>
)}
</CardContent>
</Card>
);
})
) : (
<Typography variant="body2" display="block">
{t("Add some leave entries!")}
</Typography>
)}
<Box>
<Button startIcon={<Add />} onClick={openEditModal()}>
{t("Record leave")}
</Button>
</Box>
<LeaveEditModal
leaveTypes={leaveTypes}
open={editModalOpen}
onClose={closeEditModal}
onSave={onSaveEntry}
{...editModalProps}
/>
</Box> </Box>
<LeaveEditModal
leaveTypes={leaveTypes}
open={editModalOpen}
onClose={closeEditModal}
onSave={onSaveEntry}
{...editModalProps}
/>
</Box>
</>
); );
}; };




+ 0
- 1
src/components/TimesheetTable/EntryInputTable.tsx Целия файл

@@ -277,7 +277,6 @@ const EntryInputTable: React.FC<Props> = ({
projectId={params.row.projectId} projectId={params.row.projectId}
taskGroupId={params.row.taskGroupId} taskGroupId={params.row.taskGroupId}
allProjects={allProjects} allProjects={allProjects}
editCellProps={params}
onTaskSelect={(taskId) => { onTaskSelect={(taskId) => {
params.api.setEditCellValue({ params.api.setEditCellValue({
id: params.id, id: params.id,


+ 194
- 2
src/components/TimesheetTable/MobileTimesheetEntry.tsx Целия файл

@@ -10,10 +10,13 @@ import {
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import dayjs from "dayjs"; import dayjs from "dayjs";
import React from "react";
import React, { useCallback, useMemo, useState } from "react";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
import TimesheetEditModal, {
Props as TimesheetEditModalProps,
} from "./TimesheetEditModal";


interface Props { interface Props {
date: string; date: string;
@@ -30,11 +33,200 @@ const MobileTimesheetEntry: React.FC<Props> = ({
t, t,
i18n: { language }, i18n: { language },
} = useTranslation("home"); } = useTranslation("home");

const projectMap = useMemo(() => {
return allProjects.reduce<{
[id: ProjectWithTasks["id"]]: ProjectWithTasks;
}>((acc, project) => {
return { ...acc, [project.id]: project };
}, {});
}, [allProjects]);

const dayJsObj = dayjs(date); const dayJsObj = dayjs(date);
const { watch, setValue } = useFormContext<RecordTimesheetInput>(); const { watch, setValue } = useFormContext<RecordTimesheetInput>();
const currentEntries = watch(date); const currentEntries = watch(date);


return null;
// Edit modal
const [editModalProps, setEditModalProps] = useState<
Partial<TimesheetEditModalProps>
>({});
const [editModalOpen, setEditModalOpen] = useState(false);

const openEditModal = useCallback(
(defaultValues?: TimeEntry) => () => {
setEditModalProps({
defaultValues,
onDelete: defaultValues
? () => {
setValue(
date,
currentEntries.filter((entry) => entry.id !== defaultValues.id),
);
setEditModalOpen(false);
}
: undefined,
});
setEditModalOpen(true);
},
[currentEntries, date, setValue],
);

const closeEditModal = useCallback(() => {
setEditModalOpen(false);
}, []);

const onSaveEntry = useCallback(
(entry: TimeEntry) => {
const existingEntry = currentEntries.find((e) => e.id === entry.id);
if (existingEntry) {
setValue(
date,
currentEntries.map((e) => ({
...(e.id === existingEntry.id ? entry : e),
})),
);
} else {
setValue(date, [...currentEntries, entry]);
}
setEditModalOpen(false);
},
[currentEntries, date, setValue],
);

return (
<>
<Typography
paddingInline={2}
variant="overline"
color={dayJsObj.day() === 0 ? "error.main" : undefined}
>
{shortDateFormatter(language).format(dayJsObj.toDate())}
</Typography>
<Box
paddingInline={2}
flex={1}
display="flex"
flexDirection="column"
gap={2}
overflow="scroll"
>
{currentEntries.length ? (
currentEntries.map((entry, index) => {
const project = entry.projectId
? projectMap[entry.projectId]
: undefined;

const task = project?.tasks.find((t) => t.id === entry.taskId);

return (
<Card
key={`${entry.id}-${index}`}
sx={{ marginInline: 1, overflow: "visible" }}
>
<CardContent
sx={{
padding: 2,
display: "flex",
flexDirection: "column",
gap: 2,
"&:last-child": {
paddingBottom: 2,
},
}}
>
<Box
display="flex"
justifyContent="space-between"
alignItems="flex-start"
gap={2}
>
<Box>
<Typography
variant="body2"
component="div"
fontWeight="bold"
>
{project
? `${project.code} - ${project.name}`
: t("Non-billable Task")}
</Typography>
{task && (
<Typography variant="body2" component="div">
{task.name}
</Typography>
)}
</Box>
<IconButton
size="small"
color="primary"
onClick={openEditModal(entry)}
>
<Edit />
</IconButton>
</Box>
<Box display="flex" gap={2}>
<Box>
<Typography
variant="body2"
component="div"
fontWeight="bold"
>
{t("Hours")}
</Typography>
<Typography component="p">
{manhourFormatter.format(entry.inputHours || 0)}
</Typography>
</Box>
<Box>
<Typography
variant="body2"
component="div"
fontWeight="bold"
>
{t("Other Hours")}
</Typography>
<Typography component="p">
{manhourFormatter.format(entry.otHours || 0)}
</Typography>
</Box>
</Box>
{entry.remark && (
<Box>
<Typography
variant="body2"
component="div"
fontWeight="bold"
>
{t("Remark")}
</Typography>
<Typography component="p">{entry.remark}</Typography>
</Box>
)}
</CardContent>
</Card>
);
})
) : (
<Typography variant="body2" display="block">
{t("Add some time entries!")}
</Typography>
)}
<Box>
<Button startIcon={<Add />} onClick={openEditModal()}>
{t("Record leave")}
</Button>
</Box>
<TimesheetEditModal
allProjects={allProjects}
assignedProjects={assignedProjects}
open={editModalOpen}
onClose={closeEditModal}
onSave={onSaveEntry}
{...editModalProps}
/>
</Box>
</>
);
}; };


export default MobileTimesheetEntry; export default MobileTimesheetEntry;

+ 47
- 0
src/components/TimesheetTable/ProjectSelect.tsx Целия файл

@@ -1,9 +1,11 @@
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { import {
Autocomplete,
ListSubheader, ListSubheader,
MenuItem, MenuItem,
Select, Select,
SelectChangeEvent, SelectChangeEvent,
TextField,
} from "@mui/material"; } from "@mui/material";
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -16,6 +18,49 @@ interface Props {
onProjectSelect: (projectId: number | string) => void; onProjectSelect: (projectId: number | string) => void;
} }


// const AutocompleteProjectSelect: React.FC<Props> = ({
// allProjects,
// assignedProjects,
// value,
// onProjectSelect,
// }) => {
// const { t } = useTranslation("home");
// const nonAssignedProjects = useMemo(() => {
// return differenceBy(allProjects, assignedProjects, "id");
// }, [allProjects, assignedProjects]);

// const options = useMemo(() => {
// return [
// {
// value: "",
// label: t("None"),
// group: "non-billable",
// },
// ...assignedProjects.map((p) => ({
// value: p.id,
// label: `${p.code} - ${p.name}`,
// group: "assigned",
// })),
// ...nonAssignedProjects.map((p) => ({
// value: p.id,
// label: `${p.code} - ${p.name}`,
// group: "non-assigned",
// })),
// ];
// }, [assignedProjects, nonAssignedProjects, t]);

// return (
// <Autocomplete
// disableClearable
// fullWidth
// groupBy={(option) => option.group}
// getOptionLabel={(option) => option.label}
// options={options}
// renderInput={(params) => <TextField {...params} />}
// />
// );
// };

const ProjectSelect: React.FC<Props> = ({ const ProjectSelect: React.FC<Props> = ({
allProjects, allProjects,
assignedProjects, assignedProjects,
@@ -68,6 +113,7 @@ const ProjectSelect: React.FC<Props> = ({
<MenuItem <MenuItem
key={project.id} key={project.id}
value={project.id} value={project.id}
sx={{ whiteSpace: "wrap" }}
>{`${project.code} - ${project.name}`}</MenuItem> >{`${project.code} - ${project.name}`}</MenuItem>
)), )),
]} ]}
@@ -79,6 +125,7 @@ const ProjectSelect: React.FC<Props> = ({
<MenuItem <MenuItem
key={project.id} key={project.id}
value={project.id} value={project.id}
sx={{ whiteSpace: "wrap" }}
>{`${project.code} - ${project.name}`}</MenuItem> >{`${project.code} - ${project.name}`}</MenuItem>
)), )),
]} ]}


+ 8
- 1
src/components/TimesheetTable/TaskGroupSelect.tsx Целия файл

@@ -13,6 +13,7 @@ interface Props {
projectId: number | undefined; projectId: number | undefined;
value: number | undefined; value: number | undefined;
onTaskGroupSelect: (taskGroupId: number | string) => void; onTaskGroupSelect: (taskGroupId: number | string) => void;
error?: boolean;
} }


const TaskGroupSelect: React.FC<Props> = ({ const TaskGroupSelect: React.FC<Props> = ({
@@ -20,6 +21,7 @@ const TaskGroupSelect: React.FC<Props> = ({
projectId, projectId,
onTaskGroupSelect, onTaskGroupSelect,
taskGroupsByProject, taskGroupsByProject,
error,
}) => { }) => {
const { t } = useTranslation("home"); const { t } = useTranslation("home");


@@ -35,6 +37,7 @@ const TaskGroupSelect: React.FC<Props> = ({


return ( return (
<Select <Select
error={error}
displayEmpty displayEmpty
disabled={taskGroups.length === 0} disabled={taskGroups.length === 0}
value={value || ""} value={value || ""}
@@ -58,7 +61,11 @@ const TaskGroupSelect: React.FC<Props> = ({
> >
{taskGroups.length === 0 && <MenuItem value={""}>{t("None")}</MenuItem>} {taskGroups.length === 0 && <MenuItem value={""}>{t("None")}</MenuItem>}
{taskGroups.map((taskGroup) => ( {taskGroups.map((taskGroup) => (
<MenuItem key={taskGroup.value} value={taskGroup.value}>
<MenuItem
key={taskGroup.value}
value={taskGroup.value}
sx={{ whiteSpace: "wrap" }}
>
{taskGroup.label} {taskGroup.label}
</MenuItem> </MenuItem>
))} ))}


+ 4
- 4
src/components/TimesheetTable/TaskSelect.tsx Целия файл

@@ -1,7 +1,5 @@
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { MenuItem, Select, SelectChangeEvent } from "@mui/material"; import { MenuItem, Select, SelectChangeEvent } from "@mui/material";
import { GridRenderEditCellParams } from "@mui/x-data-grid";
import { TimeEntryRow } from "./EntryInputTable";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ProjectWithTasks } from "@/app/api/projects"; import { ProjectWithTasks } from "@/app/api/projects";


@@ -10,8 +8,8 @@ interface Props {
value: number | undefined; value: number | undefined;
projectId: number | undefined; projectId: number | undefined;
taskGroupId: number | undefined; taskGroupId: number | undefined;
editCellProps: GridRenderEditCellParams<TimeEntryRow, number>;
onTaskSelect: (taskId: number | string) => void; onTaskSelect: (taskId: number | string) => void;
error?: boolean;
} }


const TaskSelect: React.FC<Props> = ({ const TaskSelect: React.FC<Props> = ({
@@ -20,6 +18,7 @@ const TaskSelect: React.FC<Props> = ({
projectId, projectId,
taskGroupId, taskGroupId,
onTaskSelect, onTaskSelect,
error
}) => { }) => {
const { t } = useTranslation("home"); const { t } = useTranslation("home");


@@ -38,6 +37,7 @@ const TaskSelect: React.FC<Props> = ({


return ( return (
<Select <Select
error={error}
displayEmpty displayEmpty
disabled={tasks.length === 0} disabled={tasks.length === 0}
value={value || ""} value={value || ""}
@@ -61,7 +61,7 @@ const TaskSelect: React.FC<Props> = ({
> >
{tasks.length === 0 && <MenuItem value={""}>{t("None")}</MenuItem>} {tasks.length === 0 && <MenuItem value={""}>{t("None")}</MenuItem>}
{tasks.map((task) => ( {tasks.map((task) => (
<MenuItem key={task.id} value={task.id}>
<MenuItem key={task.id} value={task.id} sx={{ whiteSpace: "wrap" }}>
{task.name} {task.name}
</MenuItem> </MenuItem>
))} ))}


+ 247
- 0
src/components/TimesheetTable/TimesheetEditModal.tsx Целия файл

@@ -0,0 +1,247 @@
import { TimeEntry } from "@/app/api/timesheets/actions";
import { Check, Delete } from "@mui/icons-material";
import {
Box,
Button,
FormControl,
InputLabel,
Modal,
ModalProps,
Paper,
SxProps,
TextField,
} from "@mui/material";
import React, { useCallback, useEffect, useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import ProjectSelect from "./ProjectSelect";
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
import TaskGroupSelect from "./TaskGroupSelect";
import TaskSelect from "./TaskSelect";
import { TaskGroup } from "@/app/api/tasks";
import uniqBy from "lodash/uniqBy";

export interface Props extends Omit<ModalProps, "children"> {
onSave: (leaveEntry: TimeEntry) => void;
onDelete?: () => void;
defaultValues?: Partial<TimeEntry>;
allProjects: ProjectWithTasks[];
assignedProjects: AssignedProject[];
}

const modalSx: SxProps = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "90%",
maxHeight: "90%",
padding: 3,
display: "flex",
flexDirection: "column",
gap: 2,
};
const TimesheetEditModal: React.FC<Props> = ({
onSave,
onDelete,
open,
onClose,
defaultValues,
allProjects,
assignedProjects,
}) => {
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]);

const {
register,
control,
reset,
getValues,
setValue,
trigger,
formState,
watch,
} = useForm<TimeEntry>();

useEffect(() => {
reset(defaultValues ?? { id: Date.now() });
}, [defaultValues, reset]);

const saveHandler = useCallback(async () => {
const valid = await trigger();
if (valid) {
onSave(getValues());
reset();
}
}, [getValues, onSave, reset, trigger]);

const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
(...args) => {
onClose?.(...args);
reset();
},
[onClose, reset],
);

const projectId = watch("projectId");
const taskGroupId = watch("taskGroupId");
const otHours = watch("otHours");

return (
<Modal open={open} onClose={closeHandler}>
<Paper sx={modalSx}>
<FormControl fullWidth>
<InputLabel shrink>{t("Project Code and Name")}</InputLabel>
<Controller
control={control}
name="projectId"
render={({ field }) => (
<ProjectSelect
allProjects={allProjects}
assignedProjects={assignedProjects}
value={field.value}
onProjectSelect={(newId) => {
field.onChange(newId ?? null);
const firstTaskGroup = (
typeof newId === "number" ? taskGroupsByProject[newId] : []
)[0];

setValue("taskGroupId", firstTaskGroup?.value);
setValue("taskId", undefined);
}}
/>
)}
rules={{ deps: ["taskGroupId", "taskId"] }}
/>
</FormControl>
<FormControl fullWidth>
<InputLabel shrink>{t("Stage")}</InputLabel>
<Controller
control={control}
name="taskGroupId"
render={({ field }) => (
<TaskGroupSelect
error={Boolean(formState.errors.taskGroupId)}
projectId={projectId}
taskGroupsByProject={taskGroupsByProject}
value={field.value}
onTaskGroupSelect={(newId) => {
field.onChange(newId ?? null);
}}
/>
)}
rules={{
validate: (id) => {
if (!projectId) {
return !id;
}
const taskGroups = taskGroupsByProject[projectId];
return taskGroups.some((tg) => tg.value === id);
},
deps: ["taskId"],
}}
/>
</FormControl>
<FormControl fullWidth>
<InputLabel shrink>{t("Task")}</InputLabel>
<Controller
control={control}
name="taskId"
render={({ field }) => (
<TaskSelect
error={Boolean(formState.errors.taskId)}
projectId={projectId}
taskGroupId={taskGroupId}
allProjects={allProjects}
value={field.value}
onTaskSelect={(newId) => {
field.onChange(newId ?? null);
}}
/>
)}
rules={{
validate: (id) => {
if (!projectId) {
return !id;
}
const projectTasks = allProjects.find((p) => p.id === projectId)
?.tasks;
return Boolean(projectTasks?.some((task) => task.id === id));
},
}}
/>
</FormControl>
<TextField
type="number"
label={t("Hours")}
fullWidth
{...register("inputHours", {
valueAsNumber: true,
validate: (value) => Boolean(value || otHours),
})}
error={Boolean(formState.errors.inputHours)}
/>
<TextField
type="number"
label={t("Other Hours")}
fullWidth
{...register("otHours", {
valueAsNumber: true,
})}
error={Boolean(formState.errors.otHours)}
/>
<TextField
label={t("Remark")}
fullWidth
multiline
rows={2}
error={Boolean(formState.errors.remark)}
{...register("remark", {
validate: (value) => Boolean(projectId || value),
})}
/>
<Box display="flex" justifyContent="flex-end" gap={1}>
{onDelete && (
<Button
variant="outlined"
startIcon={<Delete />}
color="error"
onClick={onDelete}
>
{t("Delete")}
</Button>
)}
<Button
variant="contained"
startIcon={<Check />}
onClick={saveHandler}
>
{t("Save")}
</Button>
</Box>
</Paper>
</Modal>
);
};

export default TimesheetEditModal;

Зареждане…
Отказ
Запис