Browse Source

Update design for timesheet

tags/Baseline_30082024_FRONTEND_UAT
Wayne 1 year ago
parent
commit
665c19eb7e
11 changed files with 708 additions and 3 deletions
  1. +8
    -0
      src/app/api/projects/index.ts
  2. +15
    -0
      src/app/api/timesheets/actions.ts
  3. +26
    -0
      src/app/utils/formatUtil.ts
  4. +8
    -2
      src/components/CreateProject/MilestoneSection.tsx
  5. +4
    -0
      src/components/StyledDataGrid/StyledDataGrid.tsx
  6. +102
    -0
      src/components/TimesheetModal/TimesheetModal.tsx
  7. +1
    -0
      src/components/TimesheetModal/index.ts
  8. +430
    -0
      src/components/TimesheetTable/EntryInputTable.tsx
  9. +106
    -0
      src/components/TimesheetTable/TimesheetTable.tsx
  10. +1
    -0
      src/components/TimesheetTable/index.ts
  11. +7
    -1
      src/components/UserWorkspacePage/UserWorkspacePage.tsx

+ 8
- 0
src/app/api/projects/index.ts View File

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


+ 15
- 0
src/app/api/timesheets/actions.ts View File

@@ -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[];
}

+ 26
- 0
src/app/utils/formatUtil.ts View File

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

+ 8
- 2
src/components/CreateProject/MilestoneSection.tsx View File

@@ -45,7 +45,10 @@ declare module "@mui/x-data-grid" {
type PaymentRow = Partial<PaymentInputs & { _isNew: boolean; _error: string }>;

const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => {
const { t } = useTranslation();
const {
t,
i18n: { language },
} = useTranslation();
const { getValues, setValue } = useFormContext<CreateProjectInputs>();
const [payments, setPayments] = useState<PaymentRow[]>(
getValues("milestones")[taskGroupId]?.payments || [],
@@ -220,7 +223,10 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => {
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Stage Milestones")}
</Typography>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale={`${language}-hk`}
>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs>
<FormControl fullWidth>


+ 4
- 0
src/components/StyledDataGrid/StyledDataGrid.tsx View File

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

+ 102
- 0
src/components/TimesheetModal/TimesheetModal.tsx View File

@@ -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<Props> = ({
isOpen,
onClose,
timesheetType,
}) => {
const { t } = useTranslation("home");

const defaultValues = useMemo(() => {
const today = dayjs();
return Array(7)
.fill(undefined)
.reduce<RecordTimesheetInput>((acc, _, index) => {
return {
...acc,
[today.subtract(index, "day").format(INPUT_DATE_FORMAT)]: [],
};
}, {});
}, []);

const formProps = useForm<RecordTimesheetInput>({ defaultValues });

const onCancel = useCallback(() => {
formProps.reset(defaultValues);
onClose();
}, [defaultValues, formProps, onClose]);

return (
<Modal open={isOpen} onClose={onClose}>
<Card sx={modalSx}>
<FormProvider {...formProps}>
<CardContent>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t(timesheetType === "time" ? "Timesheet Input" : "Record Leave")}
</Typography>
<Box
sx={{
marginInline: -3,
marginBlock: 4,
}}
>
<TimesheetTable />
</Box>
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button
variant="outlined"
startIcon={<Close />}
onClick={onCancel}
>
{t("Cancel")}
</Button>
<Button
onClick={onClose}
variant="contained"
startIcon={<Check />}
type="submit"
>
{t("Confirm")}
</Button>
</CardActions>
</CardContent>
</FormProvider>
</Card>
</Modal>
);
};

export default TimesheetModal;

+ 1
- 0
src/components/TimesheetModal/index.ts View File

@@ -0,0 +1 @@
export { default } from "./TimesheetModal";

+ 430
- 0
src/components/TimesheetTable/EntryInputTable.tsx View File

@@ -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<RecordTimesheetInput>();
const currentEntries = getValues(day);

const [entries, setEntries] = useState<TimeEntryRow[]>(
currentEntries.map((e, index) => ({ ...e, id: `${day}-${index}` })) || [],
);

const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});

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<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: "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 (
<StyledDataGrid
apiRef={apiRef}
autoHeight
sx={{
"--DataGrid-overlayHeight": "100px",
".MuiDataGrid-row .MuiDataGrid-cell.hasError": {
border: "1px solid",
borderColor: "error.main",
},
}}
disableColumnMenu
editMode="row"
rows={entries}
rowModesModel={rowModesModel}
onRowModesModelChange={setRowModesModel}
onRowEditStop={handleEditStop}
processRowUpdate={processRowUpdate}
columns={columns}
getCellClassName={(params) => {
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 (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100%"
>
<Typography variant="caption">{t("Add some time entries!")}</Typography>
</Box>
);
};

const FooterToolbar: React.FC<FooterPropsOverrides> = ({ onAdd }) => {
const { t } = useTranslation();
return (
<GridToolbarContainer sx={{ p: 2 }}>
<Button
disableRipple
variant="outlined"
startIcon={<Add />}
onClick={onAdd}
size="small"
>
{t("Record time")}
</Button>
</GridToolbarContainer>
);
};

export default EntryInputTable;

+ 106
- 0
src/components/TimesheetTable/TimesheetTable.tsx View File

@@ -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<RecordTimesheetInput>();
const currentInput = watch();
const days = Object.keys(currentInput);

return (
<TableContainer sx={{ maxHeight: 400 }}>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell />
<TableCell>{t("Date")}</TableCell>
<TableCell>{t("Daily Total Hours")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{days.map((day, index) => {
const entries = currentInput[day];
return (
<DayRow key={`${day}${index}`} day={day} entries={entries} />
);
})}
</TableBody>
</Table>
</TableContainer>
);
};

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 (
<>
<TableRow>
<TableCell align="center" width={70}>
<IconButton
disableRipple
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUp /> : <KeyboardArrowDown />}
</IconButton>
</TableCell>
<TableCell
sx={{ color: dayJsObj.day() === 0 ? "error.main" : undefined }}
>
{shortDateFormatter(language).format(dayJsObj.toDate())}
</TableCell>
<TableCell sx={{ color: totalHours > 20 ? "error.main" : undefined }}>
{manhourFormatter.format(totalHours)}
</TableCell>
</TableRow>
<TableRow>
<TableCell
sx={{
p: 0,
border: "none",
outline: open ? "1px solid" : undefined,
outlineColor: "primary.main",
}}
colSpan={3}
>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box>
<EntryInputTable day={day} />
</Box>
</Collapse>
</TableCell>
</TableRow>
</>
);
};

export default TimesheetTable;

+ 1
- 0
src/components/TimesheetTable/index.ts View File

@@ -0,0 +1 @@
export { default } from "./TimesheetTable";

+ 7
- 1
src/components/UserWorkspacePage/UserWorkspacePage.tsx View File

@@ -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<Props> = ({ allProjects }) => {
isOpen={isTimeheetModalVisible}
onClose={handleCloseTimesheetModal}
/>
<EnterLeaveModal
{/* <EnterLeaveModal
isOpen={isLeaveModalVisible}
onClose={handleCloseLeaveModal}
/> */}
<TimesheetModal
timesheetType="leave"
isOpen={isLeaveModalVisible}
onClose={handleCloseLeaveModal}
/>


Loading…
Cancel
Save