@@ -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(); | |||
@@ -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[]; | |||
} |
@@ -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; | |||
} | |||
}; |
@@ -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> | |||
@@ -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; |
@@ -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; |
@@ -0,0 +1 @@ | |||
export { default } from "./TimesheetModal"; |
@@ -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; |
@@ -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; |
@@ -0,0 +1 @@ | |||
export { default } from "./TimesheetTable"; |
@@ -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} | |||
/> | |||