Pārlūkot izejas kodu

Add mobile input for timesheet

tags/Baseline_30082024_FRONTEND_UAT
Wayne pirms 1 gada
vecāks
revīzija
a8d3a9099a
8 mainītis faili ar 584 papildinājumiem un 84 dzēšanām
  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 Parādīt failu

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

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


+ 82
- 75
src/components/LeaveTable/MobileLeaveEntry.tsx Parādīt failu

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

return (
<Box
marginInline={2}
flex={1}
display="flex"
flexDirection="column"
gap={2}
>
<>
<Typography
paddingInline={2}
variant="overline"
color={dayJsObj.day() === 0 ? "error.main" : undefined}
>
{shortDateFormatter(language).format(dayJsObj.toDate())}
</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>
)}
</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>
<LeaveEditModal
leaveTypes={leaveTypes}
open={editModalOpen}
onClose={closeEditModal}
onSave={onSaveEntry}
{...editModalProps}
/>
</Box>
</>
);
};



+ 0
- 1
src/components/TimesheetTable/EntryInputTable.tsx Parādīt failu

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


+ 194
- 2
src/components/TimesheetTable/MobileTimesheetEntry.tsx Parādīt failu

@@ -10,10 +10,13 @@ import {
Typography,
} from "@mui/material";
import dayjs from "dayjs";
import React from "react";
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 "./TimesheetEditModal";

interface Props {
date: string;
@@ -30,11 +33,200 @@ const MobileTimesheetEntry: React.FC<Props> = ({
t,
i18n: { language },
} = 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 { watch, setValue } = useFormContext<RecordTimesheetInput>();
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;

+ 47
- 0
src/components/TimesheetTable/ProjectSelect.tsx Parādīt failu

@@ -1,9 +1,11 @@
import React, { useCallback, useMemo } from "react";
import {
Autocomplete,
ListSubheader,
MenuItem,
Select,
SelectChangeEvent,
TextField,
} from "@mui/material";
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
import { useTranslation } from "react-i18next";
@@ -16,6 +18,49 @@ interface Props {
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> = ({
allProjects,
assignedProjects,
@@ -68,6 +113,7 @@ const ProjectSelect: React.FC<Props> = ({
<MenuItem
key={project.id}
value={project.id}
sx={{ whiteSpace: "wrap" }}
>{`${project.code} - ${project.name}`}</MenuItem>
)),
]}
@@ -79,6 +125,7 @@ const ProjectSelect: React.FC<Props> = ({
<MenuItem
key={project.id}
value={project.id}
sx={{ whiteSpace: "wrap" }}
>{`${project.code} - ${project.name}`}</MenuItem>
)),
]}


+ 8
- 1
src/components/TimesheetTable/TaskGroupSelect.tsx Parādīt failu

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

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

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

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


+ 4
- 4
src/components/TimesheetTable/TaskSelect.tsx Parādīt failu

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

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

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

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

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


+ 247
- 0
src/components/TimesheetTable/TimesheetEditModal.tsx Parādīt failu

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

Notiek ielāde…
Atcelt
Saglabāt