Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 

353 řádky
10 KiB

  1. import { TimeEntry } from "@/app/api/timesheets/actions";
  2. import { Check, Delete, Close } from "@mui/icons-material";
  3. import {
  4. Box,
  5. Button,
  6. FormControl,
  7. InputLabel,
  8. Modal,
  9. ModalProps,
  10. Paper,
  11. SxProps,
  12. TextField,
  13. Typography,
  14. } from "@mui/material";
  15. import React, { useCallback, useEffect, useMemo } from "react";
  16. import { Controller, useForm } from "react-hook-form";
  17. import { useTranslation } from "react-i18next";
  18. import ProjectSelect from "./ProjectSelect";
  19. import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
  20. import TaskGroupSelect, {
  21. TaskGroupSelectWithoutProject,
  22. } from "./TaskGroupSelect";
  23. import TaskSelect from "./TaskSelect";
  24. import { Task, TaskGroup } from "@/app/api/tasks";
  25. import uniqBy from "lodash/uniqBy";
  26. import { roundToNearestQuarter } from "@/app/utils/manhourUtils";
  27. import { shortDateFormatter } from "@/app/utils/formatUtil";
  28. import {
  29. DAILY_NORMAL_MAX_HOURS,
  30. TIMESHEET_DAILY_MAX_HOURS,
  31. } from "@/app/api/timesheets/utils";
  32. import dayjs from "dayjs";
  33. export interface Props extends Omit<ModalProps, "children"> {
  34. onSave: (timeEntry: TimeEntry, recordDate?: string) => Promise<void>;
  35. onDelete?: () => Promise<void>;
  36. defaultValues?: Partial<TimeEntry>;
  37. allProjects: ProjectWithTasks[];
  38. assignedProjects: AssignedProject[];
  39. modalSx?: SxProps;
  40. recordDate?: string;
  41. isHoliday?: boolean;
  42. miscTasks: Task[];
  43. }
  44. const modalSx: SxProps = {
  45. position: "absolute",
  46. top: "50%",
  47. left: "50%",
  48. transform: "translate(-50%, -50%)",
  49. width: "90%",
  50. maxHeight: "90%",
  51. padding: 3,
  52. display: "flex",
  53. flexDirection: "column",
  54. gap: 2,
  55. };
  56. const TimesheetEditModal: React.FC<Props> = ({
  57. onSave,
  58. onDelete,
  59. open,
  60. onClose,
  61. defaultValues,
  62. allProjects,
  63. assignedProjects,
  64. modalSx: mSx,
  65. recordDate,
  66. isHoliday,
  67. miscTasks,
  68. }) => {
  69. const {
  70. t,
  71. i18n: { language },
  72. } = useTranslation("home");
  73. const taskGroupsByProject = useMemo(() => {
  74. return allProjects.reduce<{
  75. [projectId: AssignedProject["id"]]: {
  76. value: TaskGroup["id"];
  77. label: string;
  78. }[];
  79. }>((acc, project) => {
  80. return {
  81. ...acc,
  82. [project.id]: uniqBy(
  83. project.tasks.map((t) => ({
  84. value: t.taskGroup.id,
  85. label: t.taskGroup.name,
  86. })),
  87. "value",
  88. ),
  89. };
  90. }, {});
  91. }, [allProjects]);
  92. const taskGroupsWithoutProject = useMemo(
  93. () =>
  94. uniqBy(
  95. miscTasks.map((t) => t.taskGroup),
  96. "id",
  97. ),
  98. [miscTasks],
  99. );
  100. const {
  101. register,
  102. control,
  103. reset,
  104. getValues,
  105. setValue,
  106. trigger,
  107. setError,
  108. formState,
  109. watch,
  110. } = useForm<TimeEntry>();
  111. useEffect(() => {
  112. reset(defaultValues ?? { id: Date.now() });
  113. }, [defaultValues, reset]);
  114. const saveHandler = useCallback(async () => {
  115. const valid = await trigger();
  116. if (valid) {
  117. try {
  118. await onSave(getValues(), recordDate);
  119. reset({ id: Date.now() });
  120. } catch (e) {
  121. setError("root", {
  122. message: e instanceof Error ? e.message : "Unknown error",
  123. });
  124. }
  125. }
  126. }, [getValues, onSave, recordDate, reset, setError, trigger]);
  127. const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
  128. (...args) => {
  129. onClose?.(...args);
  130. reset({ id: Date.now() });
  131. },
  132. [onClose, reset],
  133. );
  134. const projectId = watch("projectId");
  135. const taskGroupId = watch("taskGroupId");
  136. const otHours = watch("otHours");
  137. return (
  138. <Modal open={open} onClose={closeHandler}>
  139. <Paper sx={{ ...modalSx, ...mSx }}>
  140. {recordDate && (
  141. <Typography
  142. variant="h6"
  143. marginBlockEnd={2}
  144. color={isHoliday ? "error.main" : undefined}
  145. >
  146. {shortDateFormatter(language).format(new Date(recordDate))}
  147. </Typography>
  148. )}
  149. <FormControl fullWidth>
  150. <InputLabel shrink>{t("Project Code and Name")}</InputLabel>
  151. <Controller
  152. control={control}
  153. name="projectId"
  154. render={({ field }) => (
  155. <ProjectSelect
  156. referenceDay={dayjs(recordDate)}
  157. multiple={false}
  158. allProjects={allProjects}
  159. assignedProjects={assignedProjects}
  160. value={field.value}
  161. onProjectSelect={(newId) => {
  162. field.onChange(newId ?? null);
  163. const firstTaskGroup = (
  164. typeof newId === "number" ? taskGroupsByProject[newId] : []
  165. )[0];
  166. setValue("taskGroupId", firstTaskGroup?.value);
  167. setValue("taskId", undefined);
  168. }}
  169. isTimesheetAdmendment={true}
  170. />
  171. )}
  172. rules={{ deps: ["taskGroupId", "taskId"] }}
  173. />
  174. </FormControl>
  175. <FormControl fullWidth>
  176. <InputLabel shrink>{t("Stage")}</InputLabel>
  177. <Controller
  178. control={control}
  179. name="taskGroupId"
  180. render={({ field }) =>
  181. projectId ? (
  182. <TaskGroupSelect
  183. error={Boolean(formState.errors.taskGroupId)}
  184. projectId={projectId}
  185. taskGroupsByProject={taskGroupsByProject}
  186. value={field.value}
  187. onTaskGroupSelect={(newId) => {
  188. field.onChange(newId ?? null);
  189. }}
  190. />
  191. ) : (
  192. <TaskGroupSelectWithoutProject
  193. value={field.value}
  194. onTaskGroupSelect={(newId) => {
  195. field.onChange(newId ?? null);
  196. if (!newId) {
  197. setValue("taskId", undefined);
  198. }
  199. }}
  200. taskGroups={taskGroupsWithoutProject}
  201. />
  202. )
  203. }
  204. rules={{
  205. validate: (id) => {
  206. if (!projectId) {
  207. return true;
  208. }
  209. const taskGroups = taskGroupsByProject[projectId];
  210. return taskGroups.some((tg) => tg.value === id);
  211. },
  212. deps: ["taskId"],
  213. }}
  214. />
  215. </FormControl>
  216. <FormControl fullWidth>
  217. <InputLabel shrink>{t("Task")}</InputLabel>
  218. <Controller
  219. control={control}
  220. name="taskId"
  221. render={({ field }) => (
  222. <TaskSelect
  223. error={Boolean(formState.errors.taskId)}
  224. projectId={projectId}
  225. taskGroupId={taskGroupId}
  226. allProjects={allProjects}
  227. value={field.value}
  228. onTaskSelect={(newId) => {
  229. field.onChange(newId ?? null);
  230. }}
  231. miscTasks={miscTasks}
  232. />
  233. )}
  234. rules={{
  235. validate: (id) => {
  236. const tasks = projectId
  237. ? allProjects.find((p) => p.id === projectId)?.tasks
  238. : miscTasks;
  239. return taskGroupId
  240. ? Boolean(
  241. tasks?.some(
  242. (task) =>
  243. task.id === id && task.taskGroup.id === taskGroupId,
  244. ),
  245. )
  246. : !id;
  247. },
  248. }}
  249. />
  250. </FormControl>
  251. <TextField
  252. type="number"
  253. label={t("Hours")}
  254. fullWidth
  255. {...register("inputHours", {
  256. setValueAs: (value) => roundToNearestQuarter(parseFloat(value)),
  257. validate: (value) => {
  258. if (value) {
  259. if (isHoliday) {
  260. return t("Cannot input normal hours on holidays");
  261. }
  262. return (
  263. (0 < value && value <= DAILY_NORMAL_MAX_HOURS) ||
  264. t(
  265. "Input hours should be between 0 and {{DAILY_NORMAL_MAX_HOURS}}",
  266. { DAILY_NORMAL_MAX_HOURS },
  267. )
  268. );
  269. } else {
  270. return Boolean(value || otHours) || t("Required");
  271. }
  272. },
  273. })}
  274. error={Boolean(formState.errors.inputHours)}
  275. helperText={formState.errors.inputHours?.message}
  276. />
  277. <TextField
  278. type="number"
  279. label={t("Other Hours")}
  280. fullWidth
  281. {...register("otHours", {
  282. setValueAs: (value) => roundToNearestQuarter(parseFloat(value)),
  283. validate: (value) => (value ? value > 0 : true),
  284. })}
  285. error={Boolean(formState.errors.otHours)}
  286. />
  287. <TextField
  288. label={t("Remarks")}
  289. fullWidth
  290. multiline
  291. rows={2}
  292. error={Boolean(formState.errors.remark)}
  293. {...register("remark", {
  294. validate: (value) =>
  295. Boolean(projectId || taskGroupId || value) ||
  296. t("Required for non-billable tasks"),
  297. })}
  298. helperText={formState.errors.remark?.message}
  299. />
  300. {formState.errors.root?.message && (
  301. <Typography variant="caption" color="error">
  302. {t(formState.errors.root.message, {
  303. DAILY_NORMAL_MAX_HOURS,
  304. TIMESHEET_DAILY_MAX_HOURS,
  305. })}
  306. </Typography>
  307. )}
  308. <Box display="flex" justifyContent="flex-end" gap={1}>
  309. {onDelete && (
  310. <Button
  311. variant="outlined"
  312. startIcon={<Delete />}
  313. color="error"
  314. onClick={onDelete}
  315. >
  316. {t("Delete")}
  317. </Button>
  318. )}
  319. <Button
  320. variant="outlined"
  321. startIcon={<Close />}
  322. onClick={(event) => closeHandler(event, "backdropClick")}
  323. >
  324. {t("Close")}
  325. </Button>
  326. <Button
  327. variant="contained"
  328. startIcon={<Check />}
  329. onClick={saveHandler}
  330. >
  331. {t("Save")}
  332. </Button>
  333. </Box>
  334. </Paper>
  335. </Modal>
  336. );
  337. };
  338. export default TimesheetEditModal;