| @@ -2,6 +2,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| import "server-only"; | import "server-only"; | ||||
| import { Task } from "../tasks"; | |||||
| export interface ProjectResult { | export interface ProjectResult { | ||||
| id: number; | id: number; | ||||
| @@ -17,6 +18,13 @@ export interface ProjectCategory { | |||||
| name: string; | name: string; | ||||
| } | } | ||||
| export interface AssignedProject { | |||||
| id: number; | |||||
| code: string; | |||||
| name: string; | |||||
| tasks: Task[]; | |||||
| } | |||||
| export const preloadProjects = () => { | export const preloadProjects = () => { | ||||
| fetchProjectCategories(); | fetchProjectCategories(); | ||||
| fetchProjects(); | 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", | style: "percent", | ||||
| maximumFractionDigits: 2, | 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 }>; | type PaymentRow = Partial<PaymentInputs & { _isNew: boolean; _error: string }>; | ||||
| const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | ||||
| const { t } = useTranslation(); | |||||
| const { | |||||
| t, | |||||
| i18n: { language }, | |||||
| } = useTranslation(); | |||||
| const { getValues, setValue } = useFormContext<CreateProjectInputs>(); | const { getValues, setValue } = useFormContext<CreateProjectInputs>(); | ||||
| const [payments, setPayments] = useState<PaymentRow[]>( | const [payments, setPayments] = useState<PaymentRow[]>( | ||||
| getValues("milestones")[taskGroupId]?.payments || [], | getValues("milestones")[taskGroupId]?.payments || [], | ||||
| @@ -220,7 +223,10 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | <Typography variant="overline" display="block" marginBlockEnd={1}> | ||||
| {t("Stage Milestones")} | {t("Stage Milestones")} | ||||
| </Typography> | </Typography> | ||||
| <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk"> | |||||
| <LocalizationProvider | |||||
| dateAdapter={AdapterDayjs} | |||||
| adapterLocale={`${language}-hk`} | |||||
| > | |||||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | ||||
| <Grid item xs> | <Grid item xs> | ||||
| <FormControl fullWidth> | <FormControl fullWidth> | ||||
| @@ -17,6 +17,10 @@ const StyledDataGrid = styled(DataGrid)(({ theme }) => ({ | |||||
| "& .MuiDataGrid-columnSeparator": { | "& .MuiDataGrid-columnSeparator": { | ||||
| color: theme.palette.primary.main, | color: theme.palette.primary.main, | ||||
| }, | }, | ||||
| "& .MuiOutlinedInput-root": { | |||||
| borderRadius: 0, | |||||
| maxHeight: 50, | |||||
| }, | |||||
| })); | })); | ||||
| export default StyledDataGrid; | 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 ButtonGroup from "@mui/material/ButtonGroup"; | ||||
| import AssignedProjects from "./AssignedProjects"; | import AssignedProjects from "./AssignedProjects"; | ||||
| import { ProjectHours } from "./UserWorkspaceWrapper"; | import { ProjectHours } from "./UserWorkspaceWrapper"; | ||||
| import TimesheetModal from "../TimesheetModal"; | |||||
| export interface Props { | export interface Props { | ||||
| allProjects: ProjectHours[]; | allProjects: ProjectHours[]; | ||||
| @@ -68,7 +69,12 @@ const UserWorkspacePage: React.FC<Props> = ({ allProjects }) => { | |||||
| isOpen={isTimeheetModalVisible} | isOpen={isTimeheetModalVisible} | ||||
| onClose={handleCloseTimesheetModal} | onClose={handleCloseTimesheetModal} | ||||
| /> | /> | ||||
| <EnterLeaveModal | |||||
| {/* <EnterLeaveModal | |||||
| isOpen={isLeaveModalVisible} | |||||
| onClose={handleCloseLeaveModal} | |||||
| /> */} | |||||
| <TimesheetModal | |||||
| timesheetType="leave" | |||||
| isOpen={isLeaveModalVisible} | isOpen={isLeaveModalVisible} | ||||
| onClose={handleCloseLeaveModal} | onClose={handleCloseLeaveModal} | ||||
| /> | /> | ||||