From 665c19eb7ea54f01a3cab5858a7dd1c748a68a6c Mon Sep 17 00:00:00 2001 From: Wayne Date: Thu, 11 Apr 2024 18:25:44 +0900 Subject: [PATCH] Update design for timesheet --- src/app/api/projects/index.ts | 8 + src/app/api/timesheets/actions.ts | 15 + src/app/utils/formatUtil.ts | 26 ++ .../CreateProject/MilestoneSection.tsx | 10 +- .../StyledDataGrid/StyledDataGrid.tsx | 4 + .../TimesheetModal/TimesheetModal.tsx | 102 +++++ src/components/TimesheetModal/index.ts | 1 + .../TimesheetTable/EntryInputTable.tsx | 430 ++++++++++++++++++ .../TimesheetTable/TimesheetTable.tsx | 106 +++++ src/components/TimesheetTable/index.ts | 1 + .../UserWorkspacePage/UserWorkspacePage.tsx | 8 +- 11 files changed, 708 insertions(+), 3 deletions(-) create mode 100644 src/app/api/timesheets/actions.ts create mode 100644 src/components/TimesheetModal/TimesheetModal.tsx create mode 100644 src/components/TimesheetModal/index.ts create mode 100644 src/components/TimesheetTable/EntryInputTable.tsx create mode 100644 src/components/TimesheetTable/TimesheetTable.tsx create mode 100644 src/components/TimesheetTable/index.ts diff --git a/src/app/api/projects/index.ts b/src/app/api/projects/index.ts index 8374721..d55f1e9 100644 --- a/src/app/api/projects/index.ts +++ b/src/app/api/projects/index.ts @@ -2,6 +2,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; import { cache } from "react"; import "server-only"; +import { Task } from "../tasks"; export interface ProjectResult { id: number; @@ -17,6 +18,13 @@ export interface ProjectCategory { name: string; } +export interface AssignedProject { + id: number; + code: string; + name: string; + tasks: Task[]; +} + export const preloadProjects = () => { fetchProjectCategories(); fetchProjects(); diff --git a/src/app/api/timesheets/actions.ts b/src/app/api/timesheets/actions.ts new file mode 100644 index 0000000..6b959ec --- /dev/null +++ b/src/app/api/timesheets/actions.ts @@ -0,0 +1,15 @@ +"use server"; + +import { ProjectResult } from "../projects"; +import { Task, TaskGroup } from "../tasks"; + +export interface TimeEntry { + projectId: ProjectResult["id"]; + taskGroupId: TaskGroup["id"]; + taskId: Task["id"]; + inputHours: number; +} + +export interface RecordTimesheetInput { + [date: string]: TimeEntry[]; +} diff --git a/src/app/utils/formatUtil.ts b/src/app/utils/formatUtil.ts index f097e43..616d205 100644 --- a/src/app/utils/formatUtil.ts +++ b/src/app/utils/formatUtil.ts @@ -12,3 +12,29 @@ export const percentFormatter = new Intl.NumberFormat("en-HK", { style: "percent", maximumFractionDigits: 2, }); + +export const INPUT_DATE_FORMAT = "YYYY-MM-DD"; + +const shortDateFormatter_en = new Intl.DateTimeFormat("en-HK", { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", +}); + +const shortDateFormatter_zh = new Intl.DateTimeFormat("zh-HK", { + weekday: "long", + year: "numeric", + month: "numeric", + day: "numeric", +}); + +export const shortDateFormatter = (locale?: string) => { + switch (locale) { + case "zh": + return shortDateFormatter_zh; + case "en": + default: + return shortDateFormatter_en; + } +}; diff --git a/src/components/CreateProject/MilestoneSection.tsx b/src/components/CreateProject/MilestoneSection.tsx index cd1b1e7..e0089a7 100644 --- a/src/components/CreateProject/MilestoneSection.tsx +++ b/src/components/CreateProject/MilestoneSection.tsx @@ -45,7 +45,10 @@ declare module "@mui/x-data-grid" { type PaymentRow = Partial; const MilestoneSection: React.FC = ({ taskGroupId }) => { - const { t } = useTranslation(); + const { + t, + i18n: { language }, + } = useTranslation(); const { getValues, setValue } = useFormContext(); const [payments, setPayments] = useState( getValues("milestones")[taskGroupId]?.payments || [], @@ -220,7 +223,10 @@ const MilestoneSection: React.FC = ({ taskGroupId }) => { {t("Stage Milestones")} - + diff --git a/src/components/StyledDataGrid/StyledDataGrid.tsx b/src/components/StyledDataGrid/StyledDataGrid.tsx index a69460e..d8901c3 100644 --- a/src/components/StyledDataGrid/StyledDataGrid.tsx +++ b/src/components/StyledDataGrid/StyledDataGrid.tsx @@ -17,6 +17,10 @@ const StyledDataGrid = styled(DataGrid)(({ theme }) => ({ "& .MuiDataGrid-columnSeparator": { color: theme.palette.primary.main, }, + "& .MuiOutlinedInput-root": { + borderRadius: 0, + maxHeight: 50, + }, })); export default StyledDataGrid; diff --git a/src/components/TimesheetModal/TimesheetModal.tsx b/src/components/TimesheetModal/TimesheetModal.tsx new file mode 100644 index 0000000..055e0d9 --- /dev/null +++ b/src/components/TimesheetModal/TimesheetModal.tsx @@ -0,0 +1,102 @@ +import React, { useCallback, useMemo } from "react"; +import { + Box, + Button, + Card, + CardActions, + CardContent, + Modal, + SxProps, + Typography, +} from "@mui/material"; +import TimesheetTable from "../TimesheetTable"; +import { useTranslation } from "react-i18next"; +import { Check, Close } from "@mui/icons-material"; +import { FormProvider, useForm } from "react-hook-form"; +import { RecordTimesheetInput } from "@/app/api/timesheets/actions"; +import dayjs from "dayjs"; +import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; + +interface Props { + isOpen: boolean; + onClose: () => void; + timesheetType: "time" | "leave"; +} + +const modalSx: SxProps = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: { xs: "calc(100% - 2rem)", sm: "90%" }, + maxHeight: "90%", + maxWidth: 1200, +}; + +const TimesheetModal: React.FC = ({ + isOpen, + onClose, + timesheetType, +}) => { + const { t } = useTranslation("home"); + + const defaultValues = useMemo(() => { + const today = dayjs(); + return Array(7) + .fill(undefined) + .reduce((acc, _, index) => { + return { + ...acc, + [today.subtract(index, "day").format(INPUT_DATE_FORMAT)]: [], + }; + }, {}); + }, []); + + const formProps = useForm({ defaultValues }); + + const onCancel = useCallback(() => { + formProps.reset(defaultValues); + onClose(); + }, [defaultValues, formProps, onClose]); + + return ( + + + + + + {t(timesheetType === "time" ? "Timesheet Input" : "Record Leave")} + + + + + + + + + + + + + ); +}; + +export default TimesheetModal; diff --git a/src/components/TimesheetModal/index.ts b/src/components/TimesheetModal/index.ts new file mode 100644 index 0000000..c5197a7 --- /dev/null +++ b/src/components/TimesheetModal/index.ts @@ -0,0 +1 @@ +export { default } from "./TimesheetModal"; diff --git a/src/components/TimesheetTable/EntryInputTable.tsx b/src/components/TimesheetTable/EntryInputTable.tsx new file mode 100644 index 0000000..5aa89bd --- /dev/null +++ b/src/components/TimesheetTable/EntryInputTable.tsx @@ -0,0 +1,430 @@ +import { Add, Check, Close, Delete } from "@mui/icons-material"; +import { Box, Button, Typography } from "@mui/material"; +import { + FooterPropsOverrides, + GridActionsCellItem, + GridColDef, + GridEventListener, + 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 { RecordTimesheetInput, TimeEntry } from "@/app/api/timesheets/actions"; +import { manhourFormatter } from "@/app/utils/formatUtil"; +import { AssignedProject } from "@/app/api/projects"; +import uniqBy from "lodash/uniqBy"; +import { TaskGroup } from "@/app/api/tasks"; + +const mockProjects: AssignedProject[] = [ + { + id: 1, + name: "Consultancy Project A", + code: "M1001 (C)", + tasks: [ + { + id: 1, + name: "1.1 Preparation of preliminary Cost Estimate / Cost Plan including Revised & Refined", + description: null, + taskGroup: { + id: 1, + name: "1. Design & Cost Planning / Estimating", + }, + }, + { + id: 6, + name: "2.1 Advise on tendering & contractual arrangement", + description: null, + taskGroup: { + id: 2, + name: "2. Tender Documentation", + }, + }, + ], + }, + { + id: 2, + name: "Consultancy Project B", + code: "M1354 (C)", + tasks: [ + { + id: 1, + name: "1.1 Preparation of preliminary Cost Estimate / Cost Plan including Revised & Refined", + description: null, + taskGroup: { + id: 1, + name: "1. Design & Cost Planning / Estimating", + }, + }, + { + id: 10, + name: "3.5 Attend tender interviews", + description: null, + taskGroup: { + id: 3, + name: "3. Tender Analysis & Report & Contract Documentation", + }, + }, + ], + }, + { + id: 3, + name: "Consultancy Project C", + code: "M1973 (C)", + tasks: [ + { + id: 1, + name: "1.1 Preparation of preliminary Cost Estimate / Cost Plan including Revised & Refined", + description: null, + taskGroup: { + id: 1, + name: "1. Design & Cost Planning / Estimating", + }, + }, + { + id: 20, + name: "4.10 Preparation of Statement of Final Account", + description: null, + taskGroup: { + id: 4, + name: "4. Construction / Post Construction", + }, + }, + ], + }, +]; + +type TimeEntryRow = Partial< + TimeEntry & { + _isNew: boolean; + _error: string; + id: string; + taskGroupId: number; + } +>; + +const EntryInputTable: React.FC<{ day: string }> = ({ day }) => { + const { t } = useTranslation("home"); + const taskGroupsByProject = useMemo(() => { + return mockProjects.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", + ), + }; + }, {}); + }, []); + + const { getValues, setValue } = useFormContext(); + const currentEntries = getValues(day); + + const [entries, setEntries] = useState( + currentEntries.map((e, index) => ({ ...e, id: `${day}-${index}` })) || [], + ); + + const [rowModesModel, setRowModesModel] = useState({}); + + const apiRef = useGridApiRef(); + const addRow = useCallback(() => { + const id = `${day}-${Date.now()}`; + setEntries((e) => [...e, { id, _isNew: true }]); + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.Edit, fieldToFocus: "projectId" }, + })); + }, [day]); + + const validateRow = useCallback( + (id: GridRowId) => { + const row = apiRef.current.getRowWithUpdatedValues( + id, + "", + ) as TimeEntryRow; + let error: keyof TimeEntry | "taskGroupId" | "" = ""; + if (!row.projectId) { + error = "projectId"; + } else if (!row.taskGroupId) { + error = "taskGroupId"; + } else if (!row.taskId) { + error = "taskId"; + } else if (!row.inputHours || !(row.inputHours >= 0)) { + error = "inputHours"; + } + + apiRef.current.updateRows([{ id, _error: error }]); + return !error; + }, + [apiRef], + ); + + 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>( + (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( + () => [ + { + type: "actions", + field: "actions", + headerName: t("Actions"), + getActions: ({ id }) => { + if (rowModesModel[id]?.mode === GridRowModes.Edit) { + return [ + } + label={t("Save")} + onClick={handleSave(id)} + />, + } + label={t("Cancel")} + onClick={handleCancel(id)} + />, + ]; + } + + return [ + } + label={t("Remove")} + onClick={handleDelete(id)} + />, + ]; + }, + }, + { + field: "projectId", + headerName: t("Project Code and Name"), + width: 200, + editable: true, + type: "singleSelect", + valueOptions() { + return mockProjects.map((p) => ({ value: p.id, label: p.name })); + }, + }, + { + field: "taskGroupId", + headerName: t("Stage"), + width: 200, + editable: true, + type: "singleSelect", + valueOptions(params) { + const updatedRow = params.id + ? apiRef.current.getRowWithUpdatedValues(params.id, "") + : null; + if (!updatedRow) { + return []; + } + + const projectInfo = mockProjects.find( + (p) => p.id === updatedRow.projectId, + ); + + if (!projectInfo) { + return []; + } + + return taskGroupsByProject[projectInfo.id]; + }, + }, + { + field: "taskId", + headerName: t("Task"), + width: 200, + editable: true, + type: "singleSelect", + valueOptions(params) { + const updatedRow = params.id + ? apiRef.current.getRowWithUpdatedValues(params.id, "") + : null; + if (!updatedRow) { + return []; + } + + const projectInfo = mockProjects.find( + (p) => p.id === updatedRow.projectId, + ); + + if (!projectInfo) { + return []; + } + + return projectInfo.tasks + .filter((t) => t.taskGroup.id === updatedRow.taskGroupId) + .map((t) => ({ + value: t.id, + label: t.name, + })); + }, + }, + { + field: "inputHours", + headerName: t("Hours"), + width: 100, + editable: true, + type: "number", + valueFormatter(params) { + return manhourFormatter.format(params.value); + }, + }, + ], + [ + t, + rowModesModel, + handleDelete, + handleSave, + handleCancel, + apiRef, + taskGroupsByProject, + ], + ); + + useEffect(() => { + setValue(day, [ + ...entries + .filter( + (e) => + !e._isNew && + !e._error && + e.inputHours && + e.projectId && + e.taskId && + e.taskGroupId, + ) + .map((e) => ({ + inputHours: e.inputHours!, + projectId: e.projectId!, + taskId: e.taskId!, + taskGroupId: e.taskGroupId!, + })), + ]); + }, [getValues, entries, setValue, day]); + + return ( + { + return params.row._error === params.field ? "hasError" : ""; + }} + slots={{ + footer: FooterToolbar, + noRowsOverlay: NoRowsOverlay, + }} + slotProps={{ + footer: { onAdd: addRow }, + }} + /> + ); +}; + +const NoRowsOverlay: React.FC = () => { + const { t } = useTranslation("home"); + return ( + + {t("Add some time entries!")} + + ); +}; + +const FooterToolbar: React.FC = ({ onAdd }) => { + const { t } = useTranslation(); + return ( + + + + ); +}; + +export default EntryInputTable; diff --git a/src/components/TimesheetTable/TimesheetTable.tsx b/src/components/TimesheetTable/TimesheetTable.tsx new file mode 100644 index 0000000..f133cc2 --- /dev/null +++ b/src/components/TimesheetTable/TimesheetTable.tsx @@ -0,0 +1,106 @@ +import { RecordTimesheetInput, TimeEntry } from "@/app/api/timesheets/actions"; +import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; +import { KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; +import { + Box, + Collapse, + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from "@mui/material"; +import dayjs from "dayjs"; +import React, { useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import EntryInputTable from "./EntryInputTable"; + +const TimesheetTable: React.FC = () => { + const { t } = useTranslation("home"); + + const { watch } = useFormContext(); + const currentInput = watch(); + const days = Object.keys(currentInput); + + return ( + + + + + + {t("Date")} + {t("Daily Total Hours")} + + + + {days.map((day, index) => { + const entries = currentInput[day]; + return ( + + ); + })} + +
+
+ ); +}; + +const DayRow: React.FC<{ day: string; entries: TimeEntry[] }> = ({ + day, + entries, +}) => { + const { + i18n: { language }, + } = useTranslation("home"); + const dayJsObj = dayjs(day); + const [open, setOpen] = useState(false); + + const totalHours = entries.reduce((acc, entry) => acc + entry.inputHours, 0); + + return ( + <> + + + setOpen(!open)} + > + {open ? : } + + + + {shortDateFormatter(language).format(dayJsObj.toDate())} + + 20 ? "error.main" : undefined }}> + {manhourFormatter.format(totalHours)} + + + + + + + + + + + + + ); +}; + +export default TimesheetTable; diff --git a/src/components/TimesheetTable/index.ts b/src/components/TimesheetTable/index.ts new file mode 100644 index 0000000..5d7b0ce --- /dev/null +++ b/src/components/TimesheetTable/index.ts @@ -0,0 +1 @@ +export { default } from "./TimesheetTable"; diff --git a/src/components/UserWorkspacePage/UserWorkspacePage.tsx b/src/components/UserWorkspacePage/UserWorkspacePage.tsx index a329410..ebadfd6 100644 --- a/src/components/UserWorkspacePage/UserWorkspacePage.tsx +++ b/src/components/UserWorkspacePage/UserWorkspacePage.tsx @@ -11,6 +11,7 @@ import EnterLeaveModal from "../EnterLeave/EnterLeaveModal"; import ButtonGroup from "@mui/material/ButtonGroup"; import AssignedProjects from "./AssignedProjects"; import { ProjectHours } from "./UserWorkspaceWrapper"; +import TimesheetModal from "../TimesheetModal"; export interface Props { allProjects: ProjectHours[]; @@ -68,7 +69,12 @@ const UserWorkspacePage: React.FC = ({ allProjects }) => { isOpen={isTimeheetModalVisible} onClose={handleCloseTimesheetModal} /> - */} +