# Conflicts: # src/app/utils/formatUtil.ts # src/components/InvoiceSearch/InvoiceSearch.tsx # src/components/InvoiceSearch/InvoiceSearchWrapper.tsxtags/Baseline_30082024_FRONTEND_UAT
| @@ -13,6 +13,7 @@ export type TeamTimeSheets = { | |||
| timeEntries: RecordTimesheetInput; | |||
| staffId: string; | |||
| name: string; | |||
| employType: string | null; | |||
| }; | |||
| }; | |||
| @@ -21,6 +22,7 @@ export type TeamLeaves = { | |||
| leaveEntries: RecordLeaveInput; | |||
| staffId: string; | |||
| name: string; | |||
| employType: string | null; | |||
| }; | |||
| }; | |||
| @@ -1,14 +1,9 @@ | |||
| import { getPublicHolidaysForNYears } from "@/app/utils/holidayUtils"; | |||
| import { HolidaysResult } from "../holidays"; | |||
| import { | |||
| LeaveEntry, | |||
| RecordLeaveInput, | |||
| RecordTimeLeaveInput, | |||
| RecordTimesheetInput, | |||
| TimeEntry, | |||
| } from "./actions"; | |||
| import { LeaveEntry, RecordTimeLeaveInput, TimeEntry } from "./actions"; | |||
| import { convertDateArrayToString } from "@/app/utils/formatUtil"; | |||
| import compact from "lodash/compact"; | |||
| import dayjs from "dayjs"; | |||
| export type TimeEntryError = { | |||
| [field in keyof TimeEntry]?: string; | |||
| @@ -83,85 +78,10 @@ export const validateLeaveEntry = ( | |||
| return Object.keys(error).length > 0 ? error : undefined; | |||
| }; | |||
| export const validateTimesheet = ( | |||
| timesheet: RecordTimesheetInput, | |||
| leaveRecords: RecordLeaveInput, | |||
| companyHolidays: HolidaysResult[], | |||
| ): { [date: string]: string } | undefined => { | |||
| const errors: { [date: string]: string } = {}; | |||
| const holidays = new Set( | |||
| compact([ | |||
| ...getPublicHolidaysForNYears(2).map((h) => h.date), | |||
| ...companyHolidays.map((h) => convertDateArrayToString(h.date)), | |||
| ]), | |||
| ); | |||
| Object.keys(timesheet).forEach((date) => { | |||
| const timeEntries = timesheet[date]; | |||
| // Check each entry | |||
| for (const entry of timeEntries) { | |||
| const entryErrors = validateTimeEntry(entry, holidays.has(date)); | |||
| if (entryErrors) { | |||
| errors[date] = "There are errors in the entries"; | |||
| return; | |||
| } | |||
| } | |||
| // Check total hours | |||
| const leaves = leaveRecords[date] || []; | |||
| const totalHourError = checkTotalHours(timeEntries, leaves); | |||
| if (totalHourError) { | |||
| errors[date] = totalHourError; | |||
| } | |||
| }); | |||
| return Object.keys(errors).length > 0 ? errors : undefined; | |||
| }; | |||
| export const validateLeaveRecord = ( | |||
| leaveRecords: RecordLeaveInput, | |||
| timesheet: RecordTimesheetInput, | |||
| companyHolidays: HolidaysResult[], | |||
| ): { [date: string]: string } | undefined => { | |||
| const errors: { [date: string]: string } = {}; | |||
| const holidays = new Set( | |||
| compact([ | |||
| ...getPublicHolidaysForNYears(2).map((h) => h.date), | |||
| ...companyHolidays.map((h) => convertDateArrayToString(h.date)), | |||
| ]), | |||
| ); | |||
| Object.keys(leaveRecords).forEach((date) => { | |||
| const leaves = leaveRecords[date]; | |||
| // Check each leave entry | |||
| for (const entry of leaves) { | |||
| const entryError = validateLeaveEntry(entry, holidays.has(date)); | |||
| if (entryError) { | |||
| errors[date] = "There are errors in the entries"; | |||
| return; | |||
| } | |||
| } | |||
| // Check total hours | |||
| const timeEntries = timesheet[date] || []; | |||
| const totalHourError = checkTotalHours(timeEntries, leaves); | |||
| if (totalHourError) { | |||
| errors[date] = totalHourError; | |||
| } | |||
| }); | |||
| return Object.keys(errors).length > 0 ? errors : undefined; | |||
| }; | |||
| export const validateTimeLeaveRecord = ( | |||
| records: RecordTimeLeaveInput, | |||
| companyHolidays: HolidaysResult[], | |||
| isFullTime?: boolean, | |||
| ): { [date: string]: string } | undefined => { | |||
| const errors: { [date: string]: string } = {}; | |||
| @@ -173,14 +93,18 @@ export const validateTimeLeaveRecord = ( | |||
| ); | |||
| Object.keys(records).forEach((date) => { | |||
| const dayJsObj = dayjs(date); | |||
| const isHoliday = | |||
| holidays.has(date) || dayJsObj.day() === 0 || dayJsObj.day() === 6; | |||
| const entries = records[date]; | |||
| // Check each entry | |||
| for (const entry of entries) { | |||
| let entryError; | |||
| if (entry.type === "leaveEntry") { | |||
| entryError = validateLeaveEntry(entry, holidays.has(date)); | |||
| entryError = validateLeaveEntry(entry, isHoliday); | |||
| } else { | |||
| entryError = validateTimeEntry(entry, holidays.has(date)); | |||
| entryError = validateTimeEntry(entry, isHoliday); | |||
| } | |||
| if (entryError) { | |||
| @@ -191,8 +115,10 @@ export const validateTimeLeaveRecord = ( | |||
| // Check total hours | |||
| const totalHourError = checkTotalHours( | |||
| entries.filter((e) => e.type === "timeEntry"), | |||
| entries.filter((e) => e.type === "leaveEntry"), | |||
| entries.filter((e) => e.type === "timeEntry") as TimeEntry[], | |||
| entries.filter((e) => e.type === "leaveEntry") as LeaveEntry[], | |||
| isHoliday, | |||
| isFullTime, | |||
| ); | |||
| if (totalHourError) { | |||
| @@ -206,6 +132,8 @@ export const validateTimeLeaveRecord = ( | |||
| export const checkTotalHours = ( | |||
| timeEntries: TimeEntry[], | |||
| leaves: LeaveEntry[], | |||
| isHoliday?: boolean, | |||
| isFullTime?: boolean, | |||
| ): string | undefined => { | |||
| const leaveHours = leaves.reduce((acc, entry) => acc + entry.inputHours, 0); | |||
| @@ -219,6 +147,12 @@ export const checkTotalHours = ( | |||
| if (totalInputHours + leaveHours > DAILY_NORMAL_MAX_HOURS) { | |||
| return "The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours or decrease the leave hours."; | |||
| } else if ( | |||
| isFullTime && | |||
| !isHoliday && | |||
| totalInputHours + leaveHours !== DAILY_NORMAL_MAX_HOURS | |||
| ) { | |||
| return "The daily normal hours (timesheet hours + leave hours) for full-time staffs should be {{DAILY_NORMAL_MAX_HOURS}}."; | |||
| } else if ( | |||
| totalInputHours + totalOtHours + leaveHours > | |||
| TIMESHEET_DAILY_MAX_HOURS | |||
| @@ -134,8 +134,8 @@ export function convertLocaleStringToNumber(numberString: string): number { | |||
| } | |||
| export function timestampToDateString(timestamp: string): string { | |||
| if (timestamp === "0" || timestamp === null) { | |||
| return "-"; | |||
| if (timestamp === null){ | |||
| return "-" | |||
| } | |||
| const date = new Date(timestamp); | |||
| const year = date.getFullYear(); | |||
| @@ -344,7 +344,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| {/* <Grid item xs={6}> | |||
| <Checkbox | |||
| {...register("isClpProject")} | |||
| checked={Boolean(watch("isClpProject"))} | |||
| @@ -353,7 +353,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| <Typography variant="overline" display="inline"> | |||
| {t("CLP Project")} | |||
| </Typography> | |||
| </Grid> | |||
| </Grid> */} | |||
| </Grid> | |||
| </Box> | |||
| @@ -1,109 +0,0 @@ | |||
| "use client"; | |||
| import { useState } from "react"; | |||
| import Button from "@mui/material/Button"; | |||
| import { Card, Modal } from "@mui/material"; | |||
| import TimesheetInputGrid from "./LeaveInputGrid"; | |||
| // import { fetchLeaves } from "@/app/api/leave"; | |||
| interface EnterTimesheetModalProps { | |||
| isOpen: boolean; | |||
| onClose: () => void; | |||
| modalStyle?: any; | |||
| } | |||
| const EnterTimesheetModal: React.FC<EnterTimesheetModalProps> = ({ | |||
| ...props | |||
| }) => { | |||
| const [lockConfirm, setLockConfirm] = useState(false); | |||
| const columns = [ | |||
| { | |||
| id: "projectCode", | |||
| field: "projectCode", | |||
| headerName: "Project Code and Name", | |||
| flex: 1, | |||
| }, | |||
| { | |||
| id: "task", | |||
| field: "task", | |||
| headerName: "Task", | |||
| flex: 1, | |||
| }, | |||
| ]; | |||
| const rows = [ | |||
| { | |||
| id: 1, | |||
| projectCode: "M1001", | |||
| task: "1.2", | |||
| }, | |||
| { | |||
| id: 2, | |||
| projectCode: "M1301", | |||
| task: "1.1", | |||
| }, | |||
| ]; | |||
| const fetchTimesheet = async () => { | |||
| // fetchLeaves(); | |||
| // const res = await fetch(`http://localhost:8090/api/timesheets`, { | |||
| // // const res = await fetch(`${BASE_API_URL}/timesheets`, { | |||
| // method: "GET", | |||
| // mode: 'no-cors', | |||
| // }); | |||
| // console.log(res.json); | |||
| }; | |||
| return ( | |||
| <Modal open={props.isOpen} onClose={props.onClose}> | |||
| <div> | |||
| {/* <Typography variant="h5" id="modal-title" sx={{flex:1}}> | |||
| <div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}> | |||
| Record Leave | |||
| </div> | |||
| </Typography> */} | |||
| <Card style={{ | |||
| flex: 10, | |||
| marginBottom: "20px", | |||
| width: "90%", | |||
| // height: "80%", | |||
| position: "fixed", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| }}> | |||
| <TimesheetInputGrid setLockConfirm={setLockConfirm}/> | |||
| <div | |||
| style={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| width: "100%", | |||
| flex: 1, | |||
| padding: "20px", | |||
| }} | |||
| > | |||
| <Button | |||
| disabled={lockConfirm} | |||
| variant="contained" | |||
| onClick={props.onClose} | |||
| > | |||
| Confirm | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={props.onClose} | |||
| sx={{ "background-color": "#F890A5" }} | |||
| > | |||
| Cancel | |||
| </Button> | |||
| </div> | |||
| </Card> | |||
| </div> | |||
| </Modal> | |||
| ); | |||
| }; | |||
| export default EnterTimesheetModal; | |||
| @@ -1,548 +0,0 @@ | |||
| "use client"; | |||
| import Grid from "@mui/material/Grid"; | |||
| import Paper from "@mui/material/Paper"; | |||
| import { useState, useEffect } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import PageTitle from "../PageTitle/PageTitle"; | |||
| import { Suspense } from "react"; | |||
| import Button from "@mui/material/Button"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import Link from "next/link"; | |||
| import { t } from "i18next"; | |||
| import { | |||
| Box, | |||
| Container, | |||
| Modal, | |||
| Select, | |||
| SelectChangeEvent, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { Close } from "@mui/icons-material"; | |||
| import AddIcon from "@mui/icons-material/Add"; | |||
| import EditIcon from "@mui/icons-material/Edit"; | |||
| import DeleteIcon from "@mui/icons-material/DeleteOutlined"; | |||
| import SaveIcon from "@mui/icons-material/Save"; | |||
| import CancelIcon from "@mui/icons-material/Close"; | |||
| import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; | |||
| import ArrowBackIcon from "@mui/icons-material/ArrowBack"; | |||
| import Swal from "sweetalert2"; | |||
| import { msg } from "../Swal/CustomAlerts"; | |||
| import React from "react"; | |||
| import { DatePicker } from "@mui/x-date-pickers/DatePicker"; | |||
| import { | |||
| GridRowsProp, | |||
| GridRowModesModel, | |||
| GridRowModes, | |||
| DataGrid, | |||
| GridColDef, | |||
| GridToolbarContainer, | |||
| GridFooterContainer, | |||
| GridActionsCellItem, | |||
| GridEventListener, | |||
| GridRowId, | |||
| GridRowModel, | |||
| GridRowEditStopReasons, | |||
| GridEditInputCell, | |||
| GridValueSetterParams, | |||
| } from "@mui/x-data-grid"; | |||
| import { LocalizationProvider } from "@mui/x-date-pickers"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import dayjs from "dayjs"; | |||
| import { Props } from "react-intl/src/components/relative"; | |||
| const weekdays = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; | |||
| interface BottomBarProps { | |||
| getHoursTotal: (column: string) => number; | |||
| setLockConfirm: (newLock: (oldLock: boolean) => boolean) => void; | |||
| setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void; | |||
| setRowModesModel: ( | |||
| newModel: (oldModel: GridRowModesModel) => GridRowModesModel, | |||
| ) => void; | |||
| } | |||
| interface EditToolbarProps { | |||
| // setDay: (newDay : dayjs.Dayjs) => void; | |||
| setDay: (newDay: (oldDay: dayjs.Dayjs) => dayjs.Dayjs) => void; | |||
| setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void; | |||
| setRowModesModel: ( | |||
| newModel: (oldModel: GridRowModesModel) => GridRowModesModel, | |||
| ) => void; | |||
| } | |||
| interface EditFooterProps { | |||
| setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void; | |||
| setRowModesModel: ( | |||
| newModel: (oldModel: GridRowModesModel) => GridRowModesModel, | |||
| ) => void; | |||
| } | |||
| const EditToolbar = (props: EditToolbarProps) => { | |||
| const { setDay } = props; | |||
| const [selectedDate, setSelectedDate] = useState<dayjs.Dayjs>(dayjs()); | |||
| const handleClickLeft = () => { | |||
| if (selectedDate) { | |||
| const newDate = selectedDate.add(-7, "day"); | |||
| setSelectedDate(newDate); | |||
| } | |||
| }; | |||
| const handleClickRight = () => { | |||
| if (selectedDate) { | |||
| const newDate = | |||
| selectedDate.add(7, "day") > dayjs() | |||
| ? dayjs() | |||
| : selectedDate.add(7, "day"); | |||
| setSelectedDate(newDate); | |||
| } | |||
| }; | |||
| const handleDateChange = (date: dayjs.Dayjs | Date | null) => { | |||
| const newDate = dayjs(date); | |||
| setSelectedDate(newDate); | |||
| }; | |||
| useEffect(() => { | |||
| setDay((oldDay) => selectedDate); | |||
| }, [selectedDate]); | |||
| return ( | |||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| <div | |||
| style={{ | |||
| display: "flex", | |||
| justifyContent: "flex-end", | |||
| width: "100%", | |||
| paddingBottom: "20px", | |||
| }} | |||
| > | |||
| <Typography variant="h5" id="modal-title" sx={{ flex: 1 }}> | |||
| Record Leave | |||
| </Typography> | |||
| <Button | |||
| sx={{ "border-radius": "30%", marginRight: "20px" }} | |||
| variant="contained" | |||
| onClick={handleClickLeft} | |||
| > | |||
| <ArrowBackIcon /> | |||
| </Button> | |||
| <DatePicker | |||
| value={selectedDate} | |||
| onChange={handleDateChange} | |||
| disableFuture={true} | |||
| /> | |||
| <Button | |||
| sx={{ "border-radius": "30%", margin: "0px 20px 0px 20px" }} | |||
| variant="contained" | |||
| onClick={handleClickRight} | |||
| > | |||
| <ArrowForwardIcon /> | |||
| </Button> | |||
| </div> | |||
| </LocalizationProvider> | |||
| ); | |||
| }; | |||
| const BottomBar = (props: BottomBarProps) => { | |||
| const { setRows, setRowModesModel, getHoursTotal, setLockConfirm } = props; | |||
| // const getHoursTotal = props.getHoursTotal; | |||
| const [newId, setNewId] = useState(-1); | |||
| const [invalidDays, setInvalidDays] = useState(0); | |||
| const handleAddClick = () => { | |||
| const id = newId; | |||
| setNewId(newId - 1); | |||
| setRows((oldRows) => [ | |||
| ...oldRows, | |||
| { id, projectCode: "", task: "", isNew: true }, | |||
| ]); | |||
| setRowModesModel((oldModel) => ({ | |||
| ...oldModel, | |||
| [id]: { mode: GridRowModes.Edit, fieldToFocus: "projectCode" }, | |||
| })); | |||
| }; | |||
| const totalColDef = { | |||
| flex: 1, | |||
| // style: {color:getHoursTotal('mon')>24?"red":"black"} | |||
| }; | |||
| const TotalCell = ({ value }: Props) => { | |||
| const [invalid, setInvalid] = useState(false); | |||
| useEffect(() => { | |||
| const newInvalid = (value ?? 0) > 24; | |||
| setInvalid(newInvalid); | |||
| }, [value]); | |||
| return ( | |||
| <Box flex={1} style={{ color: invalid ? "red" : "black" }}> | |||
| {value} | |||
| </Box> | |||
| ); | |||
| }; | |||
| const checkUnlockConfirmBtn = () => { | |||
| // setLockConfirm((oldLock)=> valid); | |||
| setLockConfirm((oldLock) => | |||
| weekdays.every((weekday) => { | |||
| getHoursTotal(weekday) <= 24; | |||
| }), | |||
| ); | |||
| }; | |||
| return ( | |||
| <div> | |||
| <div style={{ display: "flex", justifyContent: "flex", width: "100%" }}> | |||
| <Box flex={5.7} textAlign={"right"} marginRight="4rem"> | |||
| <b>Total:</b> | |||
| </Box> | |||
| <TotalCell value={getHoursTotal("mon")} /> | |||
| <TotalCell value={getHoursTotal("tue")} /> | |||
| <TotalCell value={getHoursTotal("wed")} /> | |||
| <TotalCell value={getHoursTotal("thu")} /> | |||
| <TotalCell value={getHoursTotal("fri")} /> | |||
| <TotalCell value={getHoursTotal("sat")} /> | |||
| <TotalCell value={getHoursTotal("sun")} /> | |||
| </div> | |||
| <Button | |||
| variant="outlined" | |||
| color="primary" | |||
| startIcon={<AddIcon />} | |||
| onClick={handleAddClick} | |||
| > | |||
| Add record | |||
| </Button> | |||
| </div> | |||
| ); | |||
| }; | |||
| const EditFooter = (props: EditFooterProps) => { | |||
| return ( | |||
| <div style={{ display: "flex", justifyContent: "flex", width: "100%" }}> | |||
| <Box flex={1}> | |||
| <b>Total: </b> | |||
| </Box> | |||
| <Box flex={2}>ssss</Box> | |||
| </div> | |||
| ); | |||
| }; | |||
| interface TimesheetInputGridProps { | |||
| setLockConfirm: (newLock: (oldLock: boolean) => boolean) => void; | |||
| onClose?: () => void; | |||
| } | |||
| const initialRows: GridRowsProp = [ | |||
| { | |||
| id: 1, | |||
| projectCode: "M1001", | |||
| task: "1.2", | |||
| mon: 2.5, | |||
| }, | |||
| { | |||
| id: 2, | |||
| projectCode: "M1002", | |||
| task: "1.3", | |||
| mon: 3.25, | |||
| }, | |||
| ]; | |||
| const options = ["M1001", "M1301", "M1354", "M1973"]; | |||
| const options2 = [ | |||
| "1.1 - Preparation of preliminary Cost Estimate / Cost Plan", | |||
| "1.2 - Cash flow forecast", | |||
| "1.3 - Cost studies fo alterative design solutions", | |||
| "1.4 = Attend design co-ordination / project review meetings", | |||
| "1.5 - Prepare / Review RIC", | |||
| ]; | |||
| const getDateForHeader = (date: dayjs.Dayjs, weekday: number) => { | |||
| if (date.day() == 0) { | |||
| return date.add(weekday - date.day() - 7, "day").format("DD MMM"); | |||
| } else { | |||
| return date.add(weekday - date.day(), "day").format("DD MMM"); | |||
| } | |||
| }; | |||
| const TimesheetInputGrid: React.FC<TimesheetInputGridProps> = ({ | |||
| ...props | |||
| }) => { | |||
| const [rows, setRows] = useState(initialRows); | |||
| const [day, setDay] = useState(dayjs()); | |||
| const [rowModesModel, setRowModesModel] = React.useState<GridRowModesModel>( | |||
| {}, | |||
| ); | |||
| const { setLockConfirm } = props; | |||
| const handleRowEditStop: GridEventListener<"rowEditStop"> = ( | |||
| params, | |||
| event, | |||
| ) => { | |||
| if (params.reason === GridRowEditStopReasons.rowFocusOut) { | |||
| event.defaultMuiPrevented = true; | |||
| } | |||
| }; | |||
| const handleEditClick = (id: GridRowId) => () => { | |||
| setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } }); | |||
| }; | |||
| const handleSaveClick = (id: GridRowId) => () => { | |||
| setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } }); | |||
| }; | |||
| const handleDeleteClick = (id: GridRowId) => () => { | |||
| setRows(rows.filter((row) => row.id !== id)); | |||
| }; | |||
| const handleCancelClick = (id: GridRowId) => () => { | |||
| setRowModesModel({ | |||
| ...rowModesModel, | |||
| [id]: { mode: GridRowModes.View, ignoreModifications: true }, | |||
| }); | |||
| const editedRow = rows.find((row) => row.id === id); | |||
| if (editedRow!.isNew) { | |||
| setRows(rows.filter((row) => row.id !== id)); | |||
| } | |||
| }; | |||
| const processRowUpdate = (newRow: GridRowModel) => { | |||
| const updatedRow = { ...newRow, isNew: false }; | |||
| setRows(rows.map((row) => (row.id === newRow.id ? updatedRow : row))); | |||
| return updatedRow; | |||
| }; | |||
| const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => { | |||
| setRowModesModel(newRowModesModel); | |||
| }; | |||
| const getHoursTotal = (column: any) => { | |||
| let sum = 0; | |||
| rows.forEach((row) => { | |||
| sum += row[column] ?? 0; | |||
| }); | |||
| return sum; | |||
| }; | |||
| const weekdayColConfig: any = { | |||
| type: "number", | |||
| // sortable: false, | |||
| //width: 100, | |||
| flex: 1, | |||
| align: "left", | |||
| headerAlign: "left", | |||
| editable: true, | |||
| renderEditCell: (value: any) => ( | |||
| <GridEditInputCell | |||
| {...value} | |||
| inputProps={{ | |||
| max: 24, | |||
| min: 0, | |||
| step: 0.25, | |||
| }} | |||
| /> | |||
| ), | |||
| }; | |||
| const columns: GridColDef[] = [ | |||
| { | |||
| field: "actions", | |||
| type: "actions", | |||
| headerName: "Actions", | |||
| width: 100, | |||
| cellClassName: "actions", | |||
| getActions: ({ id }) => { | |||
| const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; | |||
| if (isInEditMode) { | |||
| return [ | |||
| <GridActionsCellItem | |||
| key={`actions-${id}-save`} | |||
| icon={<SaveIcon />} | |||
| title="Save" | |||
| label="Save" | |||
| sx={{ | |||
| color: "primary.main", | |||
| }} | |||
| onClick={handleSaveClick(id)} | |||
| />, | |||
| <GridActionsCellItem | |||
| key={`actions-${id}-cancel`} | |||
| icon={<CancelIcon />} | |||
| title="Cancel" | |||
| label="Cancel" | |||
| className="textPrimary" | |||
| onClick={handleCancelClick(id)} | |||
| color="inherit" | |||
| />, | |||
| ]; | |||
| } | |||
| return [ | |||
| <GridActionsCellItem | |||
| key={`actions-${id}-edit`} | |||
| icon={<EditIcon />} | |||
| title="Edit" | |||
| label="Edit" | |||
| className="textPrimary" | |||
| onClick={handleEditClick(id)} | |||
| color="inherit" | |||
| />, | |||
| <GridActionsCellItem | |||
| key={`actions-${id}-delete`} | |||
| title="Delete" | |||
| label="Delete" | |||
| icon={<DeleteIcon />} | |||
| onClick={handleDeleteClick(id)} | |||
| sx={{ color: "red" }} | |||
| />, | |||
| ]; | |||
| }, | |||
| }, | |||
| { | |||
| field: "projectCode", | |||
| headerName: "Project Code", | |||
| // width: 220, | |||
| flex: 2, | |||
| editable: true, | |||
| type: "singleSelect", | |||
| valueOptions: options, | |||
| }, | |||
| { | |||
| field: "task", | |||
| headerName: "Task", | |||
| // width: 220, | |||
| flex: 3, | |||
| editable: true, | |||
| type: "singleSelect", | |||
| valueOptions: options2, | |||
| }, | |||
| { | |||
| // Mon | |||
| field: "mon", | |||
| ...weekdayColConfig, | |||
| renderHeader: () => { | |||
| return <div>Mon - {getDateForHeader(day, 1)}</div>; | |||
| }, | |||
| }, | |||
| { | |||
| // Tue | |||
| field: "tue", | |||
| ...weekdayColConfig, | |||
| renderHeader: () => { | |||
| return <div>Tue - {getDateForHeader(day, 2)}</div>; | |||
| }, | |||
| }, | |||
| { | |||
| // Wed | |||
| field: "wed", | |||
| ...weekdayColConfig, | |||
| renderHeader: () => { | |||
| return <div>Wed - {getDateForHeader(day, 3)}</div>; | |||
| }, | |||
| }, | |||
| { | |||
| // Thu | |||
| field: "thu", | |||
| ...weekdayColConfig, | |||
| renderHeader: () => { | |||
| return <div>Thu - {getDateForHeader(day, 4)}</div>; | |||
| }, | |||
| }, | |||
| { | |||
| // Fri | |||
| field: "fri", | |||
| ...weekdayColConfig, | |||
| renderHeader: () => { | |||
| return <div>Fri - {getDateForHeader(day, 5)}</div>; | |||
| }, | |||
| }, | |||
| { | |||
| // Sat | |||
| field: "sat", | |||
| ...weekdayColConfig, | |||
| renderHeader: () => { | |||
| return <div>Sat - {getDateForHeader(day, 6)}</div>; | |||
| }, | |||
| }, | |||
| { | |||
| // Sun | |||
| field: "sun", | |||
| ...weekdayColConfig, | |||
| renderHeader: () => { | |||
| return ( | |||
| <div style={{ color: "red" }}>Sun - {getDateForHeader(day, 7)}</div> | |||
| ); | |||
| }, | |||
| }, | |||
| // { | |||
| // field: 'joinDate', | |||
| // headerName: 'Join date', | |||
| // type: 'date', | |||
| // width: 180, | |||
| // editable: true, | |||
| // }, | |||
| ]; | |||
| return ( | |||
| <Box | |||
| sx={{ | |||
| // marginBottom: '-5px', | |||
| display: "flex", | |||
| "flex-direction": "column", | |||
| // 'justify-content': 'flex-end', | |||
| padding: "20px", | |||
| height: "100%", //'30rem', | |||
| width: "100%", | |||
| "& .actions": { | |||
| color: "text.secondary", | |||
| }, | |||
| "& .header": { | |||
| // border: 1, | |||
| // 'border-width': '1px', | |||
| // 'border-color': 'grey', | |||
| }, | |||
| "& .textPrimary": { | |||
| color: "text.primary", | |||
| }, | |||
| }} | |||
| > | |||
| <DataGrid | |||
| rows={rows} | |||
| columns={columns} | |||
| editMode="row" | |||
| rowModesModel={rowModesModel} | |||
| onRowModesModelChange={handleRowModesModelChange} | |||
| onRowEditStop={handleRowEditStop} | |||
| processRowUpdate={processRowUpdate} | |||
| disableRowSelectionOnClick={true} | |||
| disableColumnMenu={true} | |||
| hideFooterPagination={true} | |||
| slots={{ | |||
| toolbar: EditToolbar, | |||
| // footer: EditFooter, | |||
| }} | |||
| slotProps={{ | |||
| toolbar: { setDay, setRows, setRowModesModel }, | |||
| // footer: { setDay, setRows, setRowModesModel }, | |||
| }} | |||
| initialState={{ | |||
| pagination: { paginationModel: { pageSize: 100 } }, | |||
| }} | |||
| sx={{ flex: 1 }} | |||
| /> | |||
| <BottomBar | |||
| getHoursTotal={getHoursTotal} | |||
| setRows={setRows} | |||
| setRowModesModel={setRowModesModel} | |||
| setLockConfirm={setLockConfirm} | |||
| // sx={{flex:3}} | |||
| /> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default TimesheetInputGrid; | |||
| @@ -1 +0,0 @@ | |||
| export { default } from "./EnterLeaveModal"; | |||
| @@ -1,109 +0,0 @@ | |||
| "use client"; | |||
| import { useState } from "react"; | |||
| import Button from "@mui/material/Button"; | |||
| import { Card, Modal } from "@mui/material"; | |||
| import TimesheetInputGrid from "./TimesheetInputGrid"; | |||
| // import { fetchTimesheets } from "@/app/api/timesheets"; | |||
| interface EnterTimesheetModalProps { | |||
| isOpen: boolean; | |||
| onClose: () => void; | |||
| modalStyle?: any; | |||
| } | |||
| const EnterTimesheetModal: React.FC<EnterTimesheetModalProps> = ({ | |||
| ...props | |||
| }) => { | |||
| const [lockConfirm, setLockConfirm] = useState(false); | |||
| const columns = [ | |||
| { | |||
| id: "projectCode", | |||
| field: "projectCode", | |||
| headerName: "Project Code and Name", | |||
| flex: 1, | |||
| }, | |||
| { | |||
| id: "task", | |||
| field: "task", | |||
| headerName: "Task", | |||
| flex: 1, | |||
| }, | |||
| ]; | |||
| const rows = [ | |||
| { | |||
| id: 1, | |||
| projectCode: "M1001", | |||
| task: "1.2", | |||
| }, | |||
| { | |||
| id: 2, | |||
| projectCode: "M1301", | |||
| task: "1.1", | |||
| }, | |||
| ]; | |||
| const fetchTimesheet = async () => { | |||
| // fetchTimesheets(); | |||
| // const res = await fetch(`http://localhost:8090/api/timesheets`, { | |||
| // // const res = await fetch(`${BASE_API_URL}/timesheets`, { | |||
| // method: "GET", | |||
| // mode: 'no-cors', | |||
| // }); | |||
| // console.log(res.json); | |||
| }; | |||
| return ( | |||
| <Modal open={props.isOpen} onClose={props.onClose}> | |||
| <div> | |||
| {/* <Typography variant="h5" id="modal-title" sx={{flex:1}}> | |||
| <div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}> | |||
| Timesheet Input | |||
| </div> | |||
| </Typography> */} | |||
| <Card style={{ | |||
| flex: 10, | |||
| marginBottom: "20px", | |||
| width: "90%", | |||
| // height: "80%", | |||
| position: "fixed", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| }}> | |||
| <TimesheetInputGrid setLockConfirm={setLockConfirm}/> | |||
| <div | |||
| style={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| width: "100%", | |||
| flex: 1, | |||
| padding: "20px", | |||
| }} | |||
| > | |||
| <Button | |||
| disabled={lockConfirm} | |||
| variant="contained" | |||
| onClick={props.onClose} | |||
| > | |||
| Confirm | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={props.onClose} | |||
| sx={{ "background-color": "#F890A5" }} | |||
| > | |||
| Cancel | |||
| </Button> | |||
| </div> | |||
| </Card> | |||
| </div> | |||
| </Modal> | |||
| ); | |||
| }; | |||
| export default EnterTimesheetModal; | |||
| @@ -1,548 +0,0 @@ | |||
| "use client"; | |||
| import Grid from "@mui/material/Grid"; | |||
| import Paper from "@mui/material/Paper"; | |||
| import { useState, useEffect } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import PageTitle from "../PageTitle/PageTitle"; | |||
| import { Suspense } from "react"; | |||
| import Button from "@mui/material/Button"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import Link from "next/link"; | |||
| import { t } from "i18next"; | |||
| import { | |||
| Box, | |||
| Container, | |||
| Modal, | |||
| Select, | |||
| SelectChangeEvent, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { Close } from "@mui/icons-material"; | |||
| import AddIcon from "@mui/icons-material/Add"; | |||
| import EditIcon from "@mui/icons-material/Edit"; | |||
| import DeleteIcon from "@mui/icons-material/DeleteOutlined"; | |||
| import SaveIcon from "@mui/icons-material/Save"; | |||
| import CancelIcon from "@mui/icons-material/Close"; | |||
| import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; | |||
| import ArrowBackIcon from "@mui/icons-material/ArrowBack"; | |||
| import Swal from "sweetalert2"; | |||
| import { msg } from "../Swal/CustomAlerts"; | |||
| import React from "react"; | |||
| import { DatePicker } from "@mui/x-date-pickers/DatePicker"; | |||
| import { | |||
| GridRowsProp, | |||
| GridRowModesModel, | |||
| GridRowModes, | |||
| DataGrid, | |||
| GridColDef, | |||
| GridToolbarContainer, | |||
| GridFooterContainer, | |||
| GridActionsCellItem, | |||
| GridEventListener, | |||
| GridRowId, | |||
| GridRowModel, | |||
| GridRowEditStopReasons, | |||
| GridEditInputCell, | |||
| GridValueSetterParams, | |||
| } from "@mui/x-data-grid"; | |||
| import { LocalizationProvider } from "@mui/x-date-pickers"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import dayjs from "dayjs"; | |||
| import { Props } from "react-intl/src/components/relative"; | |||
| const weekdays = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; | |||
| interface BottomBarProps { | |||
| getHoursTotal: (column: string) => number; | |||
| setLockConfirm: (newLock: (oldLock: boolean) => boolean) => void; | |||
| setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void; | |||
| setRowModesModel: ( | |||
| newModel: (oldModel: GridRowModesModel) => GridRowModesModel, | |||
| ) => void; | |||
| } | |||
| interface EditToolbarProps { | |||
| // setDay: (newDay : dayjs.Dayjs) => void; | |||
| setDay: (newDay: (oldDay: dayjs.Dayjs) => dayjs.Dayjs) => void; | |||
| setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void; | |||
| setRowModesModel: ( | |||
| newModel: (oldModel: GridRowModesModel) => GridRowModesModel, | |||
| ) => void; | |||
| } | |||
| interface EditFooterProps { | |||
| setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void; | |||
| setRowModesModel: ( | |||
| newModel: (oldModel: GridRowModesModel) => GridRowModesModel, | |||
| ) => void; | |||
| } | |||
| const EditToolbar = (props: EditToolbarProps) => { | |||
| const { setDay } = props; | |||
| const [selectedDate, setSelectedDate] = useState<dayjs.Dayjs>(dayjs()); | |||
| const handleClickLeft = () => { | |||
| if (selectedDate) { | |||
| const newDate = selectedDate.add(-7, "day"); | |||
| setSelectedDate(newDate); | |||
| } | |||
| }; | |||
| const handleClickRight = () => { | |||
| if (selectedDate) { | |||
| const newDate = | |||
| selectedDate.add(7, "day") > dayjs() | |||
| ? dayjs() | |||
| : selectedDate.add(7, "day"); | |||
| setSelectedDate(newDate); | |||
| } | |||
| }; | |||
| const handleDateChange = (date: dayjs.Dayjs | Date | null) => { | |||
| const newDate = dayjs(date); | |||
| setSelectedDate(newDate); | |||
| }; | |||
| useEffect(() => { | |||
| setDay((oldDay) => selectedDate); | |||
| }, [selectedDate]); | |||
| return ( | |||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| <div | |||
| style={{ | |||
| display: "flex", | |||
| justifyContent: "flex-end", | |||
| width: "100%", | |||
| paddingBottom: "20px", | |||
| }} | |||
| > | |||
| <Typography variant="h5" id="modal-title" sx={{ flex: 1 }}> | |||
| Timesheet Input | |||
| </Typography> | |||
| <Button | |||
| sx={{ "border-radius": "30%", marginRight: "20px" }} | |||
| variant="contained" | |||
| onClick={handleClickLeft} | |||
| > | |||
| <ArrowBackIcon /> | |||
| </Button> | |||
| <DatePicker | |||
| value={selectedDate} | |||
| onChange={handleDateChange} | |||
| disableFuture={true} | |||
| /> | |||
| <Button | |||
| sx={{ "border-radius": "30%", margin: "0px 20px 0px 20px" }} | |||
| variant="contained" | |||
| onClick={handleClickRight} | |||
| > | |||
| <ArrowForwardIcon /> | |||
| </Button> | |||
| </div> | |||
| </LocalizationProvider> | |||
| ); | |||
| }; | |||
| const BottomBar = (props: BottomBarProps) => { | |||
| const { setRows, setRowModesModel, getHoursTotal, setLockConfirm } = props; | |||
| // const getHoursTotal = props.getHoursTotal; | |||
| const [newId, setNewId] = useState(-1); | |||
| const [invalidDays, setInvalidDays] = useState(0); | |||
| const handleAddClick = () => { | |||
| const id = newId; | |||
| setNewId(newId - 1); | |||
| setRows((oldRows) => [ | |||
| ...oldRows, | |||
| { id, projectCode: "", task: "", isNew: true }, | |||
| ]); | |||
| setRowModesModel((oldModel) => ({ | |||
| ...oldModel, | |||
| [id]: { mode: GridRowModes.Edit, fieldToFocus: "projectCode" }, | |||
| })); | |||
| }; | |||
| const totalColDef = { | |||
| flex: 1, | |||
| // style: {color:getHoursTotal('mon')>24?"red":"black"} | |||
| }; | |||
| const TotalCell = ({ value }: Props) => { | |||
| const [invalid, setInvalid] = useState(false); | |||
| useEffect(() => { | |||
| const newInvalid = (value ?? 0) > 24; | |||
| setInvalid(newInvalid); | |||
| }, [value]); | |||
| return ( | |||
| <Box flex={1} style={{ color: invalid ? "red" : "black" }}> | |||
| {value} | |||
| </Box> | |||
| ); | |||
| }; | |||
| const checkUnlockConfirmBtn = () => { | |||
| // setLockConfirm((oldLock)=> valid); | |||
| setLockConfirm((oldLock) => | |||
| weekdays.every((weekday) => { | |||
| getHoursTotal(weekday) <= 24; | |||
| }), | |||
| ); | |||
| }; | |||
| return ( | |||
| <div> | |||
| <div style={{ display: "flex", justifyContent: "flex", width: "100%" }}> | |||
| <Box flex={5.7} textAlign={"right"} marginRight="4rem"> | |||
| <b>Total:</b> | |||
| </Box> | |||
| <TotalCell value={getHoursTotal("mon")} /> | |||
| <TotalCell value={getHoursTotal("tue")} /> | |||
| <TotalCell value={getHoursTotal("wed")} /> | |||
| <TotalCell value={getHoursTotal("thu")} /> | |||
| <TotalCell value={getHoursTotal("fri")} /> | |||
| <TotalCell value={getHoursTotal("sat")} /> | |||
| <TotalCell value={getHoursTotal("sun")} /> | |||
| </div> | |||
| <Button | |||
| variant="outlined" | |||
| color="primary" | |||
| startIcon={<AddIcon />} | |||
| onClick={handleAddClick} | |||
| > | |||
| Add record | |||
| </Button> | |||
| </div> | |||
| ); | |||
| }; | |||
| const EditFooter = (props: EditFooterProps) => { | |||
| return ( | |||
| <div style={{ display: "flex", justifyContent: "flex", width: "100%" }}> | |||
| <Box flex={1}> | |||
| <b>Total: </b> | |||
| </Box> | |||
| <Box flex={2}>ssss</Box> | |||
| </div> | |||
| ); | |||
| }; | |||
| interface TimesheetInputGridProps { | |||
| setLockConfirm: (newLock: (oldLock: boolean) => boolean) => void; | |||
| onClose?: () => void; | |||
| } | |||
| const initialRows: GridRowsProp = [ | |||
| { | |||
| id: 1, | |||
| projectCode: "M1001", | |||
| task: "1.2", | |||
| mon: 2.5, | |||
| }, | |||
| { | |||
| id: 2, | |||
| projectCode: "M1002", | |||
| task: "1.3", | |||
| mon: 3.25, | |||
| }, | |||
| ]; | |||
| const options = ["M1001", "M1301", "M1354", "M1973"]; | |||
| const options2 = [ | |||
| "1.1 - Preparation of preliminary Cost Estimate / Cost Plan", | |||
| "1.2 - Cash flow forecast", | |||
| "1.3 - Cost studies fo alterative design solutions", | |||
| "1.4 = Attend design co-ordination / project review meetings", | |||
| "1.5 - Prepare / Review RIC", | |||
| ]; | |||
| const getDateForHeader = (date: dayjs.Dayjs, weekday: number) => { | |||
| if (date.day() == 0) { | |||
| return date.add(weekday - date.day() - 7, "day").format("DD MMM"); | |||
| } else { | |||
| return date.add(weekday - date.day(), "day").format("DD MMM"); | |||
| } | |||
| }; | |||
| const TimesheetInputGrid: React.FC<TimesheetInputGridProps> = ({ | |||
| ...props | |||
| }) => { | |||
| const [rows, setRows] = useState(initialRows); | |||
| const [day, setDay] = useState(dayjs()); | |||
| const [rowModesModel, setRowModesModel] = React.useState<GridRowModesModel>( | |||
| {}, | |||
| ); | |||
| const { setLockConfirm } = props; | |||
| const handleRowEditStop: GridEventListener<"rowEditStop"> = ( | |||
| params, | |||
| event, | |||
| ) => { | |||
| if (params.reason === GridRowEditStopReasons.rowFocusOut) { | |||
| event.defaultMuiPrevented = true; | |||
| } | |||
| }; | |||
| const handleEditClick = (id: GridRowId) => () => { | |||
| setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } }); | |||
| }; | |||
| const handleSaveClick = (id: GridRowId) => () => { | |||
| setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } }); | |||
| }; | |||
| const handleDeleteClick = (id: GridRowId) => () => { | |||
| setRows(rows.filter((row) => row.id !== id)); | |||
| }; | |||
| const handleCancelClick = (id: GridRowId) => () => { | |||
| setRowModesModel({ | |||
| ...rowModesModel, | |||
| [id]: { mode: GridRowModes.View, ignoreModifications: true }, | |||
| }); | |||
| const editedRow = rows.find((row) => row.id === id); | |||
| if (editedRow!.isNew) { | |||
| setRows(rows.filter((row) => row.id !== id)); | |||
| } | |||
| }; | |||
| const processRowUpdate = (newRow: GridRowModel) => { | |||
| const updatedRow = { ...newRow, isNew: false }; | |||
| setRows(rows.map((row) => (row.id === newRow.id ? updatedRow : row))); | |||
| return updatedRow; | |||
| }; | |||
| const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => { | |||
| setRowModesModel(newRowModesModel); | |||
| }; | |||
| const getHoursTotal = (column: any) => { | |||
| let sum = 0; | |||
| rows.forEach((row) => { | |||
| sum += row[column] ?? 0; | |||
| }); | |||
| return sum; | |||
| }; | |||
| const weekdayColConfig: any = { | |||
| type: "number", | |||
| // sortable: false, | |||
| //width: 100, | |||
| flex: 1, | |||
| align: "left", | |||
| headerAlign: "left", | |||
| editable: true, | |||
| renderEditCell: (value: any) => ( | |||
| <GridEditInputCell | |||
| {...value} | |||
| inputProps={{ | |||
| max: 24, | |||
| min: 0, | |||
| step: 0.25, | |||
| }} | |||
| /> | |||
| ), | |||
| }; | |||
| const columns: GridColDef[] = [ | |||
| { | |||
| field: "actions", | |||
| type: "actions", | |||
| headerName: "Actions", | |||
| width: 100, | |||
| cellClassName: "actions", | |||
| getActions: ({ id }) => { | |||
| const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; | |||
| if (isInEditMode) { | |||
| return [ | |||
| <GridActionsCellItem | |||
| key={`actions-${id}-save`} | |||
| icon={<SaveIcon />} | |||
| title="Save" | |||
| label="Save" | |||
| sx={{ | |||
| color: "primary.main", | |||
| }} | |||
| onClick={handleSaveClick(id)} | |||
| />, | |||
| <GridActionsCellItem | |||
| key={`actions-${id}-cancel`} | |||
| icon={<CancelIcon />} | |||
| title="Cancel" | |||
| label="Cancel" | |||
| className="textPrimary" | |||
| onClick={handleCancelClick(id)} | |||
| color="inherit" | |||
| />, | |||
| ]; | |||
| } | |||
| return [ | |||
| <GridActionsCellItem | |||
| key={`actions-${id}-edit`} | |||
| icon={<EditIcon />} | |||
| title="Edit" | |||
| label="Edit" | |||
| className="textPrimary" | |||
| onClick={handleEditClick(id)} | |||
| color="inherit" | |||
| />, | |||
| <GridActionsCellItem | |||
| key={`actions-${id}-delete`} | |||
| title="Delete" | |||
| label="Delete" | |||
| icon={<DeleteIcon />} | |||
| onClick={handleDeleteClick(id)} | |||
| sx={{ color: "red" }} | |||
| />, | |||
| ]; | |||
| }, | |||
| }, | |||
| { | |||
| field: "projectCode", | |||
| headerName: "Project Code", | |||
| // width: 220, | |||
| flex: 2, | |||
| editable: true, | |||
| type: "singleSelect", | |||
| valueOptions: options, | |||
| }, | |||
| { | |||
| field: "task", | |||
| headerName: "Task", | |||
| // width: 220, | |||
| flex: 3, | |||
| editable: true, | |||
| type: "singleSelect", | |||
| valueOptions: options2, | |||
| }, | |||
| { | |||
| // Mon | |||
| field: "mon", | |||
| ...weekdayColConfig, | |||
| renderHeader: () => { | |||
| return <div>Mon - {getDateForHeader(day, 1)}</div>; | |||
| }, | |||
| }, | |||
| { | |||
| // Tue | |||
| field: "tue", | |||
| ...weekdayColConfig, | |||
| renderHeader: () => { | |||
| return <div>Tue - {getDateForHeader(day, 2)}</div>; | |||
| }, | |||
| }, | |||
| { | |||
| // Wed | |||
| field: "wed", | |||
| ...weekdayColConfig, | |||
| renderHeader: () => { | |||
| return <div>Wed - {getDateForHeader(day, 3)}</div>; | |||
| }, | |||
| }, | |||
| { | |||
| // Thu | |||
| field: "thu", | |||
| ...weekdayColConfig, | |||
| renderHeader: () => { | |||
| return <div>Thu - {getDateForHeader(day, 4)}</div>; | |||
| }, | |||
| }, | |||
| { | |||
| // Fri | |||
| field: "fri", | |||
| ...weekdayColConfig, | |||
| renderHeader: () => { | |||
| return <div>Fri - {getDateForHeader(day, 5)}</div>; | |||
| }, | |||
| }, | |||
| { | |||
| // Sat | |||
| field: "sat", | |||
| ...weekdayColConfig, | |||
| renderHeader: () => { | |||
| return <div>Sat - {getDateForHeader(day, 6)}</div>; | |||
| }, | |||
| }, | |||
| { | |||
| // Sun | |||
| field: "sun", | |||
| ...weekdayColConfig, | |||
| renderHeader: () => { | |||
| return ( | |||
| <div style={{ color: "red" }}>Sun - {getDateForHeader(day, 7)}</div> | |||
| ); | |||
| }, | |||
| }, | |||
| // { | |||
| // field: 'joinDate', | |||
| // headerName: 'Join date', | |||
| // type: 'date', | |||
| // width: 180, | |||
| // editable: true, | |||
| // }, | |||
| ]; | |||
| return ( | |||
| <Box | |||
| sx={{ | |||
| // marginBottom: '-5px', | |||
| display: "flex", | |||
| "flex-direction": "column", | |||
| // 'justify-content': 'flex-end', | |||
| padding: "20px", | |||
| height: "100%", //'30rem', | |||
| width: "100%", | |||
| "& .actions": { | |||
| color: "text.secondary", | |||
| }, | |||
| "& .header": { | |||
| // border: 1, | |||
| // 'border-width': '1px', | |||
| // 'border-color': 'grey', | |||
| }, | |||
| "& .textPrimary": { | |||
| color: "text.primary", | |||
| }, | |||
| }} | |||
| > | |||
| <DataGrid | |||
| rows={rows} | |||
| columns={columns} | |||
| editMode="row" | |||
| rowModesModel={rowModesModel} | |||
| onRowModesModelChange={handleRowModesModelChange} | |||
| onRowEditStop={handleRowEditStop} | |||
| processRowUpdate={processRowUpdate} | |||
| disableRowSelectionOnClick={true} | |||
| disableColumnMenu={true} | |||
| hideFooterPagination={true} | |||
| slots={{ | |||
| toolbar: EditToolbar, | |||
| // footer: EditFooter, | |||
| }} | |||
| slotProps={{ | |||
| toolbar: { setDay, setRows, setRowModesModel }, | |||
| // footer: { setDay, setRows, setRowModesModel }, | |||
| }} | |||
| initialState={{ | |||
| pagination: { paginationModel: { pageSize: 100 } }, | |||
| }} | |||
| sx={{ flex: 1 }} | |||
| /> | |||
| <BottomBar | |||
| getHoursTotal={getHoursTotal} | |||
| setRows={setRows} | |||
| setRowModesModel={setRowModesModel} | |||
| setLockConfirm={setLockConfirm} | |||
| // sx={{flex:3}} | |||
| /> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default TimesheetInputGrid; | |||
| @@ -1 +0,0 @@ | |||
| export { default } from "./EnterTimesheetModal"; | |||
| @@ -100,7 +100,7 @@ const InvoiceSearch: React.FC<Props> = ({ issuedInvoice, receivedInvoice, invoic | |||
| formData.append('multipartFileList', file); | |||
| const response = await importIssuedInovice(formData); | |||
| console.log(response); | |||
| // response: status, message, projectList, emptyRowList, invoiceList | |||
| if (response.status) { | |||
| successDialog(t("Import Success"), t).then(() => { | |||
| @@ -0,0 +1,285 @@ | |||
| import React, { useCallback, useMemo, useState } from "react"; | |||
| import { HolidaysResult } from "@/app/api/holidays"; | |||
| import { LeaveType } from "@/app/api/timesheets"; | |||
| import dayGridPlugin from "@fullcalendar/daygrid"; | |||
| import interactionPlugin from "@fullcalendar/interaction"; | |||
| import { Box, useTheme } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| getHolidayForDate, | |||
| getPublicHolidaysForNYears, | |||
| } from "@/app/utils/holidayUtils"; | |||
| import { | |||
| INPUT_DATE_FORMAT, | |||
| convertDateArrayToString, | |||
| } from "@/app/utils/formatUtil"; | |||
| import StyledFullCalendar from "../StyledFullCalendar"; | |||
| import { ProjectWithTasks } from "@/app/api/projects"; | |||
| import { | |||
| LeaveEntry, | |||
| RecordLeaveInput, | |||
| RecordTimesheetInput, | |||
| saveLeave, | |||
| } from "@/app/api/timesheets/actions"; | |||
| import { Props as LeaveEditModalProps } from "../LeaveTable/LeaveEditModal"; | |||
| import LeaveEditModal from "../LeaveTable/LeaveEditModal"; | |||
| import dayjs from "dayjs"; | |||
| import { checkTotalHours } from "@/app/api/timesheets/utils"; | |||
| import unionBy from "lodash/unionBy"; | |||
| export interface Props { | |||
| leaveTypes: LeaveType[]; | |||
| companyHolidays: HolidaysResult[]; | |||
| allProjects: ProjectWithTasks[]; | |||
| leaveRecords: RecordLeaveInput; | |||
| timesheetRecords: RecordTimesheetInput; | |||
| isFullTime: boolean; | |||
| } | |||
| interface EventClickArg { | |||
| event: { | |||
| start: Date | null; | |||
| startStr: string; | |||
| extendedProps: { | |||
| calendar?: string; | |||
| entry?: LeaveEntry; | |||
| }; | |||
| }; | |||
| } | |||
| const LeaveCalendar: React.FC<Props> = ({ | |||
| companyHolidays, | |||
| allProjects, | |||
| leaveTypes, | |||
| timesheetRecords, | |||
| leaveRecords, | |||
| isFullTime, | |||
| }) => { | |||
| const { t } = useTranslation(["home", "common"]); | |||
| const theme = useTheme(); | |||
| const projectMap = useMemo(() => { | |||
| return allProjects.reduce<{ | |||
| [id: ProjectWithTasks["id"]]: ProjectWithTasks; | |||
| }>((acc, project) => { | |||
| return { ...acc, [project.id]: project }; | |||
| }, {}); | |||
| }, [allProjects]); | |||
| const leaveMap = useMemo(() => { | |||
| return leaveTypes.reduce<{ [id: LeaveType["id"]]: string }>( | |||
| (acc, leaveType) => ({ ...acc, [leaveType.id]: leaveType.name }), | |||
| {}, | |||
| ); | |||
| }, [leaveTypes]); | |||
| const [localLeaveRecords, setLocalLeaveEntries] = useState(leaveRecords); | |||
| // leave edit modal related | |||
| const [leaveEditModalProps, setLeaveEditModalProps] = useState< | |||
| Partial<LeaveEditModalProps> | |||
| >({}); | |||
| const [leaveEditModalOpen, setLeaveEditModalOpen] = useState(false); | |||
| const openLeaveEditModal = useCallback( | |||
| (defaultValues?: LeaveEntry, recordDate?: string, isHoliday?: boolean) => { | |||
| setLeaveEditModalProps({ | |||
| defaultValues: defaultValues ? { ...defaultValues } : undefined, | |||
| recordDate, | |||
| isHoliday, | |||
| onDelete: defaultValues | |||
| ? async () => { | |||
| if (!recordDate || !leaveRecords[recordDate]) { | |||
| return; | |||
| } | |||
| const leaveEntriesAtDate = leaveRecords[recordDate]; | |||
| const newLeaveRecords = { | |||
| ...leaveRecords, | |||
| [recordDate!]: leaveEntriesAtDate.filter( | |||
| (e) => e.id !== defaultValues.id, | |||
| ), | |||
| }; | |||
| const savedLeaveRecords = await saveLeave(newLeaveRecords); | |||
| setLocalLeaveEntries(savedLeaveRecords); | |||
| setLeaveEditModalOpen(false); | |||
| } | |||
| : undefined, | |||
| }); | |||
| setLeaveEditModalOpen(true); | |||
| }, | |||
| [leaveRecords], | |||
| ); | |||
| const closeLeaveEditModal = useCallback(() => { | |||
| setLeaveEditModalOpen(false); | |||
| }, []); | |||
| // calendar related | |||
| const holidays = useMemo(() => { | |||
| return [ | |||
| ...getPublicHolidaysForNYears(2), | |||
| ...companyHolidays.map((h) => ({ | |||
| title: h.name, | |||
| date: convertDateArrayToString(h.date, INPUT_DATE_FORMAT), | |||
| extendedProps: { | |||
| calender: "holiday", | |||
| }, | |||
| })), | |||
| ].map((e) => ({ | |||
| ...e, | |||
| backgroundColor: theme.palette.error.main, | |||
| borderColor: theme.palette.error.main, | |||
| })); | |||
| }, [companyHolidays, theme.palette.error.main]); | |||
| const leaveEntries = useMemo( | |||
| () => | |||
| Object.keys(localLeaveRecords).flatMap((date, index) => { | |||
| return localLeaveRecords[date].map((entry) => ({ | |||
| id: `${date}-${index}-leave-${entry.id}`, | |||
| date, | |||
| title: `${t("{{count}} hour", { | |||
| ns: "common", | |||
| count: entry.inputHours || 0, | |||
| })} (${leaveMap[entry.leaveTypeId]})`, | |||
| backgroundColor: theme.palette.warning.light, | |||
| borderColor: theme.palette.warning.light, | |||
| textColor: theme.palette.text.primary, | |||
| extendedProps: { | |||
| calendar: "leaveEntry", | |||
| entry, | |||
| }, | |||
| })); | |||
| }), | |||
| [leaveMap, localLeaveRecords, t, theme], | |||
| ); | |||
| const timeEntries = useMemo( | |||
| () => | |||
| Object.keys(timesheetRecords).flatMap((date, index) => { | |||
| return timesheetRecords[date].map((entry) => ({ | |||
| id: `${date}-${index}-time-${entry.id}`, | |||
| date, | |||
| title: `${t("{{count}} hour", { | |||
| ns: "common", | |||
| count: (entry.inputHours || 0) + (entry.otHours || 0), | |||
| })} (${ | |||
| entry.projectId | |||
| ? projectMap[entry.projectId].code | |||
| : t("Non-billable task") | |||
| })`, | |||
| backgroundColor: theme.palette.info.main, | |||
| borderColor: theme.palette.info.main, | |||
| extendedProps: { | |||
| calendar: "timeEntry", | |||
| entry, | |||
| }, | |||
| })); | |||
| }), | |||
| [projectMap, timesheetRecords, t, theme], | |||
| ); | |||
| const handleEventClick = useCallback( | |||
| ({ event }: EventClickArg) => { | |||
| const dayJsObj = dayjs(event.startStr); | |||
| const holiday = getHolidayForDate(event.startStr, companyHolidays); | |||
| const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; | |||
| if ( | |||
| event.extendedProps.calendar === "leaveEntry" && | |||
| event.extendedProps.entry | |||
| ) { | |||
| openLeaveEditModal( | |||
| event.extendedProps.entry as LeaveEntry, | |||
| event.startStr, | |||
| Boolean(isHoliday), | |||
| ); | |||
| } | |||
| }, | |||
| [companyHolidays, openLeaveEditModal], | |||
| ); | |||
| const handleDateClick = useCallback( | |||
| (e: { dateStr: string; dayEl: HTMLElement }) => { | |||
| const dayJsObj = dayjs(e.dateStr); | |||
| const holiday = getHolidayForDate(e.dateStr, companyHolidays); | |||
| const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; | |||
| openLeaveEditModal(undefined, e.dateStr, Boolean(isHoliday)); | |||
| }, | |||
| [companyHolidays, openLeaveEditModal], | |||
| ); | |||
| const checkTotalHoursForDate = useCallback( | |||
| (newEntry: LeaveEntry, date?: string) => { | |||
| if (!date) { | |||
| throw Error("Invalid date"); | |||
| } | |||
| const dayJsObj = dayjs(date); | |||
| const holiday = getHolidayForDate(date, companyHolidays); | |||
| const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; | |||
| const leaves = localLeaveRecords[date] || []; | |||
| const timesheets = timesheetRecords[date] || []; | |||
| const leavesWithNewEntry = unionBy( | |||
| [newEntry as LeaveEntry], | |||
| leaves, | |||
| "id", | |||
| ); | |||
| const totalHourError = checkTotalHours( | |||
| timesheets, | |||
| leavesWithNewEntry, | |||
| Boolean(isHoliday), | |||
| isFullTime, | |||
| ); | |||
| if (totalHourError) throw Error(totalHourError); | |||
| }, | |||
| [companyHolidays, isFullTime, localLeaveRecords, timesheetRecords], | |||
| ); | |||
| const handleSaveLeave = useCallback( | |||
| async (leaveEntry: LeaveEntry, recordDate?: string) => { | |||
| checkTotalHoursForDate(leaveEntry, recordDate); | |||
| const leaveEntriesAtDate = leaveRecords[recordDate!] || []; | |||
| const newLeaveRecords = { | |||
| ...leaveRecords, | |||
| [recordDate!]: [ | |||
| ...leaveEntriesAtDate.filter((e) => e.id !== leaveEntry.id), | |||
| leaveEntry, | |||
| ], | |||
| }; | |||
| const savedLeaveRecords = await saveLeave(newLeaveRecords); | |||
| setLocalLeaveEntries(savedLeaveRecords); | |||
| setLeaveEditModalOpen(false); | |||
| }, | |||
| [checkTotalHoursForDate, leaveRecords], | |||
| ); | |||
| return ( | |||
| <Box> | |||
| <StyledFullCalendar | |||
| plugins={[dayGridPlugin, interactionPlugin]} | |||
| initialView="dayGridMonth" | |||
| buttonText={{ today: t("Today") }} | |||
| events={[...holidays, ...timeEntries, ...leaveEntries]} | |||
| eventClick={handleEventClick} | |||
| dateClick={handleDateClick} | |||
| /> | |||
| <LeaveEditModal | |||
| modalSx={{ maxWidth: 400 }} | |||
| leaveTypes={leaveTypes} | |||
| open={leaveEditModalOpen} | |||
| onClose={closeLeaveEditModal} | |||
| onSave={handleSaveLeave} | |||
| {...leaveEditModalProps} | |||
| /> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default LeaveCalendar; | |||
| @@ -1,46 +1,16 @@ | |||
| import React, { useCallback, useEffect, useMemo } from "react"; | |||
| import useIsMobile from "@/app/utils/useIsMobile"; | |||
| import React from "react"; | |||
| import FullscreenModal from "../FullscreenModal"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Card, | |||
| CardActions, | |||
| CardContent, | |||
| Modal, | |||
| ModalProps, | |||
| SxProps, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { Check, Close } from "@mui/icons-material"; | |||
| import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | |||
| import { | |||
| RecordLeaveInput, | |||
| RecordTimesheetInput, | |||
| saveLeave, | |||
| } from "@/app/api/timesheets/actions"; | |||
| import dayjs from "dayjs"; | |||
| import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import LeaveTable from "../LeaveTable"; | |||
| import { LeaveType } from "@/app/api/timesheets"; | |||
| import FullscreenModal from "../FullscreenModal"; | |||
| import MobileLeaveTable from "../LeaveTable/MobileLeaveTable"; | |||
| import useIsMobile from "@/app/utils/useIsMobile"; | |||
| import { HolidaysResult } from "@/app/api/holidays"; | |||
| import { | |||
| DAILY_NORMAL_MAX_HOURS, | |||
| TIMESHEET_DAILY_MAX_HOURS, | |||
| validateLeaveRecord, | |||
| } from "@/app/api/timesheets/utils"; | |||
| import ErrorAlert from "../ErrorAlert"; | |||
| interface Props { | |||
| isOpen: boolean; | |||
| onClose: () => void; | |||
| defaultLeaveRecords?: RecordLeaveInput; | |||
| leaveTypes: LeaveType[]; | |||
| timesheetRecords: RecordTimesheetInput; | |||
| companyHolidays: HolidaysResult[]; | |||
| } | |||
| import LeaveCalendar, { Props as LeaveCalendarProps } from "./LeaveCalendar"; | |||
| const modalSx: SxProps = { | |||
| position: "absolute", | |||
| @@ -52,167 +22,58 @@ const modalSx: SxProps = { | |||
| maxWidth: 1400, | |||
| }; | |||
| interface Props extends LeaveCalendarProps { | |||
| open: boolean; | |||
| onClose: () => void; | |||
| } | |||
| const LeaveModal: React.FC<Props> = ({ | |||
| isOpen, | |||
| open, | |||
| onClose, | |||
| defaultLeaveRecords, | |||
| timesheetRecords, | |||
| leaveTypes, | |||
| companyHolidays, | |||
| allProjects, | |||
| leaveRecords, | |||
| timesheetRecords, | |||
| isFullTime, | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| const isMobile = useIsMobile(); | |||
| const defaultValues = useMemo(() => { | |||
| const today = dayjs(); | |||
| return Array(7) | |||
| .fill(undefined) | |||
| .reduce<RecordLeaveInput>((acc, _, index) => { | |||
| const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); | |||
| return { | |||
| ...acc, | |||
| [date]: defaultLeaveRecords?.[date] ?? [], | |||
| }; | |||
| }, {}); | |||
| }, [defaultLeaveRecords]); | |||
| const formProps = useForm<RecordLeaveInput>({ defaultValues }); | |||
| useEffect(() => { | |||
| formProps.reset(defaultValues); | |||
| }, [defaultValues, formProps]); | |||
| const onSubmit = useCallback<SubmitHandler<RecordLeaveInput>>( | |||
| async (data) => { | |||
| const errors = validateLeaveRecord( | |||
| data, | |||
| timesheetRecords, | |||
| companyHolidays, | |||
| ); | |||
| if (errors) { | |||
| Object.keys(errors).forEach((date) => | |||
| formProps.setError(date, { | |||
| message: errors[date], | |||
| }), | |||
| ); | |||
| return; | |||
| } | |||
| const savedRecords = await saveLeave(data); | |||
| const today = dayjs(); | |||
| const newFormValues = Array(7) | |||
| .fill(undefined) | |||
| .reduce<RecordLeaveInput>((acc, _, index) => { | |||
| const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); | |||
| return { | |||
| ...acc, | |||
| [date]: savedRecords[date] ?? [], | |||
| }; | |||
| }, {}); | |||
| formProps.reset(newFormValues); | |||
| onClose(); | |||
| }, | |||
| [companyHolidays, formProps, onClose, timesheetRecords], | |||
| ); | |||
| const onCancel = useCallback(() => { | |||
| formProps.reset(defaultValues); | |||
| onClose(); | |||
| }, [defaultValues, formProps, onClose]); | |||
| const onModalClose = useCallback<NonNullable<ModalProps["onClose"]>>( | |||
| (_, reason) => { | |||
| if (reason !== "backdropClick") { | |||
| onCancel(); | |||
| } | |||
| }, | |||
| [onCancel], | |||
| ); | |||
| const errorComponent = ( | |||
| <ErrorAlert | |||
| errors={Object.keys(formProps.formState.errors).map((date) => { | |||
| const error = formProps.formState.errors[date]?.message; | |||
| return error | |||
| ? `${date}: ${t(error, { | |||
| TIMESHEET_DAILY_MAX_HOURS, | |||
| DAILY_NORMAL_MAX_HOURS, | |||
| })}` | |||
| : undefined; | |||
| })} | |||
| const title = t("Record leave"); | |||
| const content = ( | |||
| <LeaveCalendar | |||
| isFullTime={isFullTime} | |||
| leaveTypes={leaveTypes} | |||
| companyHolidays={companyHolidays} | |||
| allProjects={allProjects} | |||
| leaveRecords={leaveRecords} | |||
| timesheetRecords={timesheetRecords} | |||
| /> | |||
| ); | |||
| const matches = useIsMobile(); | |||
| return ( | |||
| <FormProvider {...formProps}> | |||
| {!matches ? ( | |||
| // Desktop version | |||
| <Modal open={isOpen} onClose={onModalClose}> | |||
| <Card sx={modalSx}> | |||
| <CardContent | |||
| component="form" | |||
| onSubmit={formProps.handleSubmit(onSubmit)} | |||
| > | |||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||
| {t("Record Leave")} | |||
| </Typography> | |||
| <Box | |||
| sx={{ | |||
| marginInline: -3, | |||
| marginBlock: 4, | |||
| }} | |||
| > | |||
| <LeaveTable | |||
| companyHolidays={companyHolidays} | |||
| leaveTypes={leaveTypes} | |||
| timesheetRecords={timesheetRecords} | |||
| /> | |||
| </Box> | |||
| {errorComponent} | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Close />} | |||
| onClick={onCancel} | |||
| > | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button variant="contained" startIcon={<Check />} type="submit"> | |||
| {t("Save")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| </Modal> | |||
| ) : ( | |||
| // Mobile version | |||
| <FullscreenModal | |||
| open={isOpen} | |||
| onClose={onModalClose} | |||
| closeModal={onCancel} | |||
| > | |||
| <Box | |||
| display="flex" | |||
| flexDirection="column" | |||
| gap={2} | |||
| height="100%" | |||
| component="form" | |||
| onSubmit={formProps.handleSubmit(onSubmit)} | |||
| > | |||
| <Typography variant="h6" padding={2} flex="none"> | |||
| {t("Record Leave")} | |||
| </Typography> | |||
| <MobileLeaveTable | |||
| companyHolidays={companyHolidays} | |||
| leaveTypes={leaveTypes} | |||
| timesheetRecords={timesheetRecords} | |||
| errorComponent={errorComponent} | |||
| /> | |||
| return isMobile ? ( | |||
| <FullscreenModal open={open} onClose={onClose} closeModal={onClose}> | |||
| <Box display="flex" flexDirection="column" gap={2} height="100%"> | |||
| <Typography variant="h6" flex="none" padding={2}> | |||
| {title} | |||
| </Typography> | |||
| <Box paddingInline={2}>{content}</Box> | |||
| </Box> | |||
| </FullscreenModal> | |||
| ) : ( | |||
| <Modal open={open} onClose={onClose}> | |||
| <Card sx={modalSx}> | |||
| <CardContent> | |||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||
| {title} | |||
| </Typography> | |||
| <Box maxHeight={900} overflow="scroll"> | |||
| {content} | |||
| </Box> | |||
| </FullscreenModal> | |||
| )} | |||
| </FormProvider> | |||
| </CardContent> | |||
| </Card> | |||
| </Modal> | |||
| ); | |||
| }; | |||
| @@ -1,5 +1,3 @@ | |||
| import { Grid } from "@mui/material"; | |||
| interface Props { | |||
| width?: number; | |||
| height?: number; | |||
| @@ -16,7 +14,7 @@ const Logo: React.FC<Props> = ({ width, height }) => { | |||
| <g clipPath="url(#a)"> | |||
| <path id="logo" | |||
| fill="#89ba17" stroke="#89ba17" stroke-width="1" | |||
| fill="#89ba17" stroke="#89ba17" strokeWidth="1" | |||
| d="M 98.00,125.00 | |||
| C 92.11,126.67 84.23,126.00 78.00,126.00 | |||
| 68.19,126.00 48.68,126.75 40.00,125.00 | |||
| @@ -66,7 +64,7 @@ const Logo: React.FC<Props> = ({ width, height }) => { | |||
| 41.00,156.00 39.00,156.00 39.00,156.00 Z" /> | |||
| <path id="word" | |||
| fill="#111927" stroke="#111927" stroke-width="1" | |||
| fill="#111927" stroke="#111927" strokeWidth="1" | |||
| d="M 273.00,64.00 | |||
| C 273.00,64.00 279.96,66.35 279.96,66.35 | |||
| 283.26,67.45 289.15,67.63 290.83,63.79 | |||
| @@ -67,16 +67,16 @@ import { | |||
| MAINTAIN_GROUP, | |||
| MAINTAIN_HOLIDAY, | |||
| VIEW_PROJECT_RESOURCE_CONSUMPTION_RANKING, | |||
| GENERATE_LATE_START_REPORTS, | |||
| GENERATE_LATE_START_REPORT, | |||
| GENERATE_PROJECT_POTENTIAL_DELAY_REPORT, | |||
| GENERATE_RESOURCE_OVERCONSUMPTION_REPORT, | |||
| GENERATE_COST_ANT_EXPENSE_REPORT, | |||
| GENERATE_COST_AND_EXPENSE_REPORT, | |||
| GENERATE_PROJECT_COMPLETION_REPORT, | |||
| GENERATE_PROJECT_PANDL_REPORT, | |||
| GENERATE_FINANCIAL_STATUS_REPORT, | |||
| GENERATE_PROJECT_CASH_FLOW_REPORT, | |||
| GENERATE_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT, | |||
| GENERATE_CROSS_TEAM_CHARGE_REPORT, | |||
| GENERATE_CROSS_TEAM_CHARGE_REPORT | |||
| } from "@/middleware"; | |||
| import { SessionWithAbilities } from "../AppBar/NavigationToggle"; | |||
| import { authOptions } from "@/config/authConfig"; | |||
| @@ -190,16 +190,16 @@ const NavigationContent: React.FC<Props> = ({ abilities, username }) => { | |||
| label: "Analysis Report", | |||
| path: "", | |||
| isHidden: ![ | |||
| GENERATE_LATE_START_REPORTS, | |||
| GENERATE_LATE_START_REPORT, | |||
| GENERATE_PROJECT_POTENTIAL_DELAY_REPORT, | |||
| GENERATE_RESOURCE_OVERCONSUMPTION_REPORT, | |||
| GENERATE_COST_ANT_EXPENSE_REPORT, | |||
| GENERATE_COST_AND_EXPENSE_REPORT, | |||
| GENERATE_PROJECT_COMPLETION_REPORT, | |||
| GENERATE_PROJECT_PANDL_REPORT, | |||
| GENERATE_FINANCIAL_STATUS_REPORT, | |||
| GENERATE_PROJECT_CASH_FLOW_REPORT, | |||
| GENERATE_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT, | |||
| GENERATE_CROSS_TEAM_CHARGE_REPORT, | |||
| GENERATE_CROSS_TEAM_CHARGE_REPORT | |||
| ].some((ability) => | |||
| abilities!.includes(ability), | |||
| ), | |||
| @@ -208,7 +208,7 @@ const NavigationContent: React.FC<Props> = ({ abilities, username }) => { | |||
| icon: <Analytics />, | |||
| label: "Late Start Report", | |||
| path: "/analytics/LateStartReport", | |||
| isHidden: ![GENERATE_LATE_START_REPORTS].some((ability) => | |||
| isHidden: ![GENERATE_LATE_START_REPORT].some((ability) => | |||
| abilities!.includes(ability), | |||
| ), | |||
| }, | |||
| @@ -232,7 +232,7 @@ const NavigationContent: React.FC<Props> = ({ abilities, username }) => { | |||
| icon: <Analytics />, | |||
| label: "Cost and Expense Report", | |||
| path: "/analytics/CostandExpenseReport", | |||
| isHidden: ![GENERATE_COST_ANT_EXPENSE_REPORT].some((ability) => | |||
| isHidden: ![GENERATE_COST_AND_EXPENSE_REPORT].some((ability) => | |||
| abilities!.includes(ability), | |||
| ), | |||
| }, | |||
| @@ -0,0 +1,196 @@ | |||
| import { | |||
| RecordLeaveInput, | |||
| RecordTimesheetInput, | |||
| } from "@/app/api/timesheets/actions"; | |||
| import { | |||
| Box, | |||
| Card, | |||
| CardActionArea, | |||
| CardContent, | |||
| Stack, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import union from "lodash/union"; | |||
| import { useCallback, useMemo } from "react"; | |||
| import dayjs, { Dayjs } from "dayjs"; | |||
| import { getHolidayForDate } from "@/app/utils/holidayUtils"; | |||
| import { HolidaysResult } from "@/app/api/holidays"; | |||
| import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import pickBy from "lodash/pickBy"; | |||
| interface Props { | |||
| currentMonth: Dayjs; | |||
| timesheet: RecordTimesheetInput; | |||
| leaves: RecordLeaveInput; | |||
| companyHolidays: HolidaysResult[]; | |||
| onDateSelect: (date: string) => void; | |||
| } | |||
| const MonthlySummary: React.FC<Props> = ({ | |||
| timesheet, | |||
| leaves, | |||
| currentMonth, | |||
| companyHolidays, | |||
| onDateSelect, | |||
| }) => { | |||
| const { | |||
| t, | |||
| i18n: { language }, | |||
| } = useTranslation("home"); | |||
| const timesheetForCurrentMonth = useMemo(() => { | |||
| return pickBy(timesheet, (_, date) => { | |||
| return currentMonth.isSame(dayjs(date), "month"); | |||
| }); | |||
| }, [currentMonth, timesheet]); | |||
| const leavesForCurrentMonth = useMemo(() => { | |||
| return pickBy(leaves, (_, date) => { | |||
| return currentMonth.isSame(dayjs(date), "month"); | |||
| }); | |||
| }, [currentMonth, leaves]); | |||
| const days = useMemo(() => { | |||
| return union( | |||
| Object.keys(timesheetForCurrentMonth), | |||
| Object.keys(leavesForCurrentMonth), | |||
| ); | |||
| }, [timesheetForCurrentMonth, leavesForCurrentMonth]).sort(); | |||
| const makeSelectDate = useCallback( | |||
| (date: string) => () => { | |||
| onDateSelect(date); | |||
| }, | |||
| [onDateSelect], | |||
| ); | |||
| return ( | |||
| <Stack | |||
| gap={2} | |||
| marginBlockEnd={2} | |||
| minWidth={{ sm: 375 }} | |||
| maxHeight={{ sm: 500 }} | |||
| > | |||
| <Typography variant="overline">{t("Monthly Summary")}</Typography> | |||
| <Box sx={{ overflowY: "scroll" }} flex={1}> | |||
| {days.map((day, index) => { | |||
| const dayJsObj = dayjs(day); | |||
| const holiday = getHolidayForDate(day, companyHolidays); | |||
| const isHoliday = | |||
| holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; | |||
| const ls = leavesForCurrentMonth[day]; | |||
| const leaveHours = | |||
| ls?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; | |||
| const ts = timesheetForCurrentMonth[day]; | |||
| const timesheetNormalHours = | |||
| ts?.reduce((acc, entry) => acc + (entry.inputHours || 0), 0) || 0; | |||
| const timesheetOtHours = | |||
| ts?.reduce((acc, entry) => acc + (entry.otHours || 0), 0) || 0; | |||
| const timesheetHours = timesheetNormalHours + timesheetOtHours; | |||
| return ( | |||
| <Card | |||
| key={`${day}-${index}`} | |||
| sx={{ marginBlockEnd: 2, marginInline: 2 }} | |||
| > | |||
| <CardActionArea onClick={makeSelectDate(day)}> | |||
| <CardContent sx={{ padding: 3 }}> | |||
| <Typography | |||
| variant="overline" | |||
| component="div" | |||
| sx={{ | |||
| color: isHoliday ? "error.main" : undefined, | |||
| }} | |||
| > | |||
| {shortDateFormatter(language).format(dayJsObj.toDate())} | |||
| {holiday && ( | |||
| <Typography | |||
| marginInlineStart={1} | |||
| variant="caption" | |||
| >{`(${holiday.title})`}</Typography> | |||
| )} | |||
| </Typography> | |||
| <Stack spacing={1}> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| flexWrap: "wrap", | |||
| alignItems: "baseline", | |||
| }} | |||
| > | |||
| <Typography variant="body2"> | |||
| {t("Timesheet Hours")} | |||
| </Typography> | |||
| <Typography> | |||
| {manhourFormatter.format(timesheetHours)} | |||
| </Typography> | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| flexWrap: "wrap", | |||
| alignItems: "baseline", | |||
| }} | |||
| > | |||
| <Typography variant="body2"> | |||
| {t("Leave Hours")} | |||
| </Typography> | |||
| <Typography> | |||
| {manhourFormatter.format(leaveHours)} | |||
| </Typography> | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| flexWrap: "wrap", | |||
| alignItems: "baseline", | |||
| }} | |||
| > | |||
| <Typography variant="body2"> | |||
| {t("Daily Total Hours")} | |||
| </Typography> | |||
| <Typography> | |||
| {manhourFormatter.format(timesheetHours + leaveHours)} | |||
| </Typography> | |||
| </Box> | |||
| </Stack> | |||
| </CardContent> | |||
| </CardActionArea> | |||
| </Card> | |||
| ); | |||
| })} | |||
| </Box> | |||
| <Typography variant="overline"> | |||
| {`${t("Total Monthly Work Hours")}: ${manhourFormatter.format( | |||
| Object.values(timesheetForCurrentMonth) | |||
| .flatMap((entries) => entries) | |||
| .map((entry) => (entry.inputHours ?? 0) + (entry.otHours ?? 0)) | |||
| .reduce((acc, cur) => { | |||
| return acc + cur; | |||
| }, 0), | |||
| )}`} | |||
| </Typography> | |||
| <Typography variant="overline"> | |||
| {`${t("Total Monthly Leave Hours")}: ${manhourFormatter.format( | |||
| Object.values(leavesForCurrentMonth) | |||
| .flatMap((entries) => entries) | |||
| .map((entry) => entry.inputHours) | |||
| .reduce((acc, cur) => { | |||
| return acc + cur; | |||
| }, 0), | |||
| )}`} | |||
| </Typography> | |||
| </Stack> | |||
| ); | |||
| }; | |||
| export default MonthlySummary; | |||
| @@ -26,6 +26,7 @@ export interface Props { | |||
| timesheet: RecordTimesheetInput; | |||
| leaves: RecordLeaveInput; | |||
| onDateSelect: (date: string) => void; | |||
| onMonthChange: (day: Dayjs) => void; | |||
| } | |||
| const getColor = ( | |||
| @@ -72,6 +73,7 @@ const PastEntryCalendar: React.FC<Props> = ({ | |||
| timesheet, | |||
| leaves, | |||
| onDateSelect, | |||
| onMonthChange, | |||
| }) => { | |||
| const { | |||
| i18n: { language }, | |||
| @@ -88,6 +90,7 @@ const PastEntryCalendar: React.FC<Props> = ({ | |||
| > | |||
| <DateCalendar | |||
| onChange={onChange} | |||
| onMonthChange={onMonthChange} | |||
| disableFuture | |||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| slots={{ day: EntryDay as any }} | |||
| @@ -20,12 +20,17 @@ import { ProjectWithTasks } from "@/app/api/projects"; | |||
| import { LeaveType } from "@/app/api/timesheets"; | |||
| import useIsMobile from "@/app/utils/useIsMobile"; | |||
| import FullscreenModal from "../FullscreenModal"; | |||
| import MonthlySummary from "./MonthlySummary"; | |||
| import { HolidaysResult } from "@/app/api/holidays"; | |||
| import dayjs from "dayjs"; | |||
| interface Props extends Omit<PastEntryCalendarProps, "onDateSelect"> { | |||
| interface Props | |||
| extends Omit<PastEntryCalendarProps, "onDateSelect" | "onMonthChange"> { | |||
| open: boolean; | |||
| handleClose: () => void; | |||
| leaveTypes: LeaveType[]; | |||
| allProjects: ProjectWithTasks[]; | |||
| companyHolidays: HolidaysResult[]; | |||
| } | |||
| const Indicator = styled(Box)(() => ({ | |||
| @@ -45,6 +50,7 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||
| const { t } = useTranslation("home"); | |||
| const [selectedDate, setSelectedDate] = useState(""); | |||
| const [currentMonth, setMonthChange] = useState(dayjs()); | |||
| const clearDate = useCallback(() => { | |||
| setSelectedDate(""); | |||
| @@ -54,40 +60,52 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||
| handleClose(); | |||
| }, [handleClose]); | |||
| const content = selectedDate ? ( | |||
| <> | |||
| <PastEntryList | |||
| date={selectedDate} | |||
| timesheet={timesheet} | |||
| leaves={leaves} | |||
| allProjects={allProjects} | |||
| leaveTypes={leaveTypes} | |||
| /> | |||
| </> | |||
| ) : ( | |||
| <> | |||
| <Stack marginBlockEnd={2}> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "info.light" }} /> | |||
| <Typography variant="caption">{t("Has timesheet entry")}</Typography> | |||
| </Box> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "warning.light" }} /> | |||
| <Typography variant="caption">{t("Has leave entry")}</Typography> | |||
| </Box> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "success.light" }} /> | |||
| <Typography variant="caption"> | |||
| {t("Has both timesheet and leave entry")} | |||
| </Typography> | |||
| </Box> | |||
| </Stack> | |||
| <PastEntryCalendar | |||
| timesheet={timesheet} | |||
| leaves={leaves} | |||
| onDateSelect={setSelectedDate} | |||
| /> | |||
| </> | |||
| const content = ( | |||
| <Box sx={{ display: "flex", flexDirection: { xs: "column", sm: "row" } }}> | |||
| <Box> | |||
| <Stack marginBlockEnd={2}> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "info.light" }} /> | |||
| <Typography variant="caption"> | |||
| {t("Has timesheet entry")} | |||
| </Typography> | |||
| </Box> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "warning.light" }} /> | |||
| <Typography variant="caption">{t("Has leave entry")}</Typography> | |||
| </Box> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "success.light" }} /> | |||
| <Typography variant="caption"> | |||
| {t("Has both timesheet and leave entry")} | |||
| </Typography> | |||
| </Box> | |||
| </Stack> | |||
| <PastEntryCalendar | |||
| timesheet={timesheet} | |||
| leaves={leaves} | |||
| onDateSelect={setSelectedDate} | |||
| onMonthChange={setMonthChange} | |||
| /> | |||
| </Box> | |||
| {selectedDate ? ( | |||
| <PastEntryList | |||
| date={selectedDate} | |||
| timesheet={timesheet} | |||
| leaves={leaves} | |||
| allProjects={allProjects} | |||
| leaveTypes={leaveTypes} | |||
| /> | |||
| ) : ( | |||
| <MonthlySummary | |||
| currentMonth={currentMonth} | |||
| timesheet={timesheet} | |||
| leaves={leaves} | |||
| companyHolidays={[]} | |||
| onDateSelect={setSelectedDate} | |||
| /> | |||
| )} | |||
| </Box> | |||
| ); | |||
| const isMobile = useIsMobile(); | |||
| @@ -115,14 +133,14 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||
| startIcon={<ArrowBack />} | |||
| onClick={clearDate} | |||
| > | |||
| {t("Back")} | |||
| {t("Back to Monthly Summary")} | |||
| </Button> | |||
| )} | |||
| </Box> | |||
| </Box> | |||
| </FullscreenModal> | |||
| ) : ( | |||
| <Dialog onClose={onClose} open={open}> | |||
| <Dialog onClose={onClose} open={open} maxWidth="md"> | |||
| <DialogTitle>{t("Past Entries")}</DialogTitle> | |||
| <DialogContent>{content}</DialogContent> | |||
| {selectedDate && ( | |||
| @@ -132,7 +150,7 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||
| startIcon={<ArrowBack />} | |||
| onClick={clearDate} | |||
| > | |||
| {t("Back")} | |||
| {t("Back to Monthly Summary")} | |||
| </Button> | |||
| </DialogActions> | |||
| )} | |||
| @@ -57,7 +57,12 @@ const PastEntryList: React.FC<Props> = ({ | |||
| const dayJsObj = dayjs(date); | |||
| return ( | |||
| <Stack gap={2} marginBlockEnd={2} minWidth={{ sm: 375 }}> | |||
| <Stack | |||
| gap={2} | |||
| marginBlockEnd={2} | |||
| minWidth={{ sm: 375 }} | |||
| maxHeight={{ sm: 500 }} | |||
| > | |||
| <Typography | |||
| variant="overline" | |||
| color={dayJsObj.day() === 0 ? "error.main" : undefined} | |||
| @@ -94,17 +99,25 @@ const PastEntryList: React.FC<Props> = ({ | |||
| leaveTypeMap={leaveTypeMap} | |||
| /> | |||
| ))} | |||
| <Typography | |||
| variant="overline" | |||
| > | |||
| {t("Total Work Hours")}: {manhourFormatter.format(timeEntries.map(entry => (entry.inputHours ?? 0) + (entry.otHours ?? 0)).reduce((acc, cur) => { return acc + cur }, 0))} | |||
| </Typography> | |||
| <Typography | |||
| variant="overline" | |||
| > | |||
| {t("Total Leave Hours")}: {manhourFormatter.format(leaveEntries.map(entry => entry.inputHours).reduce((acc, cur) => { return acc + cur }, 0))} | |||
| </Typography> | |||
| </Box> | |||
| <Typography variant="overline"> | |||
| {`${t("Total Work Hours")}: ${manhourFormatter.format( | |||
| timeEntries | |||
| .map((entry) => (entry.inputHours ?? 0) + (entry.otHours ?? 0)) | |||
| .reduce((acc, cur) => { | |||
| return acc + cur; | |||
| }, 0), | |||
| )}`} | |||
| </Typography> | |||
| <Typography variant="overline"> | |||
| {`${t("Total Leave Hours")}: ${manhourFormatter.format( | |||
| leaveEntries | |||
| .map((entry) => entry.inputHours) | |||
| .reduce((acc, cur) => { | |||
| return acc + cur; | |||
| }, 0), | |||
| )}`} | |||
| </Typography> | |||
| </Stack> | |||
| ); | |||
| }; | |||
| @@ -409,7 +409,7 @@ const ProgressByClient: React.FC<Props> = () => { | |||
| const spentManhours = chartProjectSpentHour[dataPointIndex]; | |||
| const value = series[seriesIndex][dataPointIndex]; | |||
| const tooltipContent = ` | |||
| <div style="width: 250px;"> | |||
| <div style="width: auto;"> | |||
| <span style="font-weight: bold;">${projectCode} - ${projectName}</span> | |||
| <br> | |||
| Budget Manhours: ${budgetManhours} hours | |||
| @@ -492,7 +492,7 @@ const ProgressByTeam: React.FC = () => { | |||
| const spentManhours = currentPageProjectSpentManhourList[dataPointIndex]; | |||
| const value = series[seriesIndex][dataPointIndex]; | |||
| const tooltipContent = ` | |||
| <div style="width: 100%;"> | |||
| <div style="width: auto;"> | |||
| <span style="font-weight: bold;">${projectCode} - ${projectName}</span> | |||
| <br> | |||
| Budget Manhours: ${budgetManhours} hours | |||
| @@ -954,7 +954,7 @@ const ProjectCashFlow: React.FC = () => { | |||
| className="text-sm font-medium ml-5" | |||
| style={{ color: "#898d8d" }} | |||
| > | |||
| Accounts Receivable | |||
| Remaining Budget | |||
| </div> | |||
| <div | |||
| className="text-lg font-medium ml-5 mb-2" | |||
| @@ -362,7 +362,7 @@ const StaffUtilization: React.FC<Props> = ({ abilities, staff }) => { | |||
| const startCount = weeklyPlanned[i].startCount | |||
| const endCount = weeklyPlanned[i].endCount | |||
| for (var j = 0; j < weeklyPlanned[i].searchDuration; j++) { | |||
| if (j >= startCount && j < endCount) { | |||
| if (j >= startCount && j <= endCount) { | |||
| weeklyPlannedSubList.push(weeklyPlanned[i].AverageManhours) | |||
| } else { | |||
| weeklyPlannedSubList.push(0) | |||
| @@ -503,7 +503,8 @@ const StaffUtilization: React.FC<Props> = ({ abilities, staff }) => { | |||
| const fetchMonthlyUnsubmittedData = async () => { | |||
| const fetchResult = await fetchMonthlyUnsubmit(teamUnsubmitTeamId, unsubmitMonthlyFromValue.format('YYYY-MM-DD'), unsubmitMonthlyToValue.endOf('month').format('YYYY-MM-DD'), holidayDates); | |||
| const fetchResult = await fetchMonthlyUnsubmit(teamUnsubmitTeamId, unsubmitMonthlyFromValue.startOf('month').format('YYYY-MM-DD'), unsubmitMonthlyToValue.endOf('month').format('YYYY-MM-DD'), holidayDates); | |||
| const result = [] | |||
| const staffList = [] | |||
| var maxValue = 5 | |||
| @@ -102,7 +102,7 @@ const TimeLeaveInputTable: React.FC<Props> = ({ | |||
| }, {}); | |||
| }, [assignedProjects]); | |||
| const { getValues, setValue, clearErrors } = | |||
| const { getValues, setValue, clearErrors, setError } = | |||
| useFormContext<RecordTimeLeaveInput>(); | |||
| const currentEntries = getValues(day); | |||
| @@ -393,7 +393,9 @@ const TimeLeaveInputTable: React.FC<Props> = ({ | |||
| params.row._error?.[ | |||
| params.field as keyof Omit<TimeLeaveEntry, "type"> | |||
| ]; | |||
| const content = <GridEditInputCell {...params} />; | |||
| const content = ( | |||
| <GridEditInputCell {...params} inputProps={{ min: 0 }} /> | |||
| ); | |||
| return errorMessage ? ( | |||
| <Tooltip title={t(errorMessage, { DAILY_NORMAL_MAX_HOURS })}> | |||
| <Box width="100%">{content}</Box> | |||
| @@ -423,7 +425,9 @@ const TimeLeaveInputTable: React.FC<Props> = ({ | |||
| params.row._error?.[ | |||
| params.field as keyof Omit<TimeLeaveEntry, "type"> | |||
| ]; | |||
| const content = <GridEditInputCell {...params} />; | |||
| const content = ( | |||
| <GridEditInputCell {...params} inputProps={{ min: 0 }} /> | |||
| ); | |||
| return errorMessage ? ( | |||
| <Tooltip title={t(errorMessage)}> | |||
| <Box width="100%">{content}</Box> | |||
| @@ -486,8 +490,13 @@ const TimeLeaveInputTable: React.FC<Props> = ({ | |||
| .filter((e): e is TimeLeaveEntry => Boolean(e)); | |||
| setValue(day, newEntries); | |||
| clearErrors(day); | |||
| }, [getValues, entries, setValue, day, clearErrors]); | |||
| if (entries.some((e) => e._isNew)) { | |||
| setError(day, { message: "There are some unsaved entries." }); | |||
| } else { | |||
| clearErrors(day); | |||
| } | |||
| }, [getValues, entries, setValue, day, clearErrors, setError]); | |||
| const hasOutOfPlannedStages = entries.some( | |||
| (entry) => entry._isPlanned !== undefined && !entry._isPlanned, | |||
| @@ -49,6 +49,7 @@ interface Props { | |||
| companyHolidays: HolidaysResult[]; | |||
| fastEntryEnabled?: boolean; | |||
| leaveTypes: LeaveType[]; | |||
| isFullTime: boolean; | |||
| } | |||
| const modalSx: SxProps = { | |||
| @@ -71,6 +72,7 @@ const TimeLeaveModal: React.FC<Props> = ({ | |||
| companyHolidays, | |||
| fastEntryEnabled, | |||
| leaveTypes, | |||
| isFullTime | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| @@ -106,7 +108,7 @@ const TimeLeaveModal: React.FC<Props> = ({ | |||
| const onSubmit = useCallback<SubmitHandler<RecordTimeLeaveInput>>( | |||
| async (data) => { | |||
| const errors = validateTimeLeaveRecord(data, companyHolidays); | |||
| const errors = validateTimeLeaveRecord(data, companyHolidays, isFullTime); | |||
| if (errors) { | |||
| Object.keys(errors).forEach((date) => | |||
| formProps.setError(date, { | |||
| @@ -131,7 +133,7 @@ const TimeLeaveModal: React.FC<Props> = ({ | |||
| formProps.reset(newFormValues); | |||
| onClose(); | |||
| }, | |||
| [companyHolidays, formProps, onClose], | |||
| [companyHolidays, formProps, onClose, isFullTime], | |||
| ); | |||
| const onCancel = useCallback(() => { | |||
| @@ -347,6 +347,10 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||
| if (!date) { | |||
| throw Error("Invalid date"); | |||
| } | |||
| const dayJsObj = dayjs(date); | |||
| const holiday = getHolidayForDate(date, companyHolidays); | |||
| const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; | |||
| const intStaffId = parseInt(selectedStaff.id); | |||
| const leaves = localTeamLeaves[intStaffId].leaveEntries[date] || []; | |||
| const timesheets = | |||
| @@ -360,7 +364,11 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||
| leaves, | |||
| "id", | |||
| ); | |||
| totalHourError = checkTotalHours(timesheets, leavesWithNewEntry); | |||
| totalHourError = checkTotalHours( | |||
| timesheets, | |||
| leavesWithNewEntry, | |||
| Boolean(isHoliday), | |||
| ); | |||
| } else { | |||
| // newEntry is a timesheet entry | |||
| const timesheetsWithNewEntry = unionBy( | |||
| @@ -368,11 +376,15 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||
| timesheets, | |||
| "id", | |||
| ); | |||
| totalHourError = checkTotalHours(timesheetsWithNewEntry, leaves); | |||
| totalHourError = checkTotalHours( | |||
| timesheetsWithNewEntry, | |||
| leaves, | |||
| Boolean(isHoliday), | |||
| ); | |||
| } | |||
| if (totalHourError) throw Error(totalHourError); | |||
| }, | |||
| [localTeamLeaves, localTeamTimesheets, selectedStaff.id], | |||
| [localTeamLeaves, localTeamTimesheets, selectedStaff, companyHolidays], | |||
| ); | |||
| const handleSave = useCallback( | |||
| @@ -1,223 +0,0 @@ | |||
| import React, { useCallback, useEffect, useMemo } from "react"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Card, | |||
| CardActions, | |||
| CardContent, | |||
| Modal, | |||
| ModalProps, | |||
| SxProps, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import TimesheetTable from "../TimesheetTable"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { Check, Close } from "@mui/icons-material"; | |||
| import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | |||
| import { | |||
| RecordLeaveInput, | |||
| RecordTimesheetInput, | |||
| saveTimesheet, | |||
| } from "@/app/api/timesheets/actions"; | |||
| import dayjs from "dayjs"; | |||
| import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||
| import FullscreenModal from "../FullscreenModal"; | |||
| import MobileTimesheetTable from "../TimesheetTable/MobileTimesheetTable"; | |||
| import useIsMobile from "@/app/utils/useIsMobile"; | |||
| import { HolidaysResult } from "@/app/api/holidays"; | |||
| import { | |||
| DAILY_NORMAL_MAX_HOURS, | |||
| TIMESHEET_DAILY_MAX_HOURS, | |||
| validateTimesheet, | |||
| } from "@/app/api/timesheets/utils"; | |||
| import ErrorAlert from "../ErrorAlert"; | |||
| interface Props { | |||
| isOpen: boolean; | |||
| onClose: () => void; | |||
| allProjects: ProjectWithTasks[]; | |||
| assignedProjects: AssignedProject[]; | |||
| defaultTimesheets?: RecordTimesheetInput; | |||
| leaveRecords: RecordLeaveInput; | |||
| companyHolidays: HolidaysResult[]; | |||
| fastEntryEnabled?: boolean; | |||
| } | |||
| const modalSx: SxProps = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| width: { xs: "calc(100% - 2rem)", sm: "90%" }, | |||
| maxHeight: "90%", | |||
| maxWidth: 1400, | |||
| }; | |||
| const TimesheetModal: React.FC<Props> = ({ | |||
| isOpen, | |||
| onClose, | |||
| allProjects, | |||
| assignedProjects, | |||
| defaultTimesheets, | |||
| leaveRecords, | |||
| companyHolidays, | |||
| fastEntryEnabled, | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| const defaultValues = useMemo(() => { | |||
| const today = dayjs(); | |||
| return Array(7) | |||
| .fill(undefined) | |||
| .reduce<RecordTimesheetInput>((acc, _, index) => { | |||
| const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); | |||
| return { | |||
| ...acc, | |||
| [date]: defaultTimesheets?.[date] ?? [], | |||
| }; | |||
| }, {}); | |||
| }, [defaultTimesheets]); | |||
| const formProps = useForm<RecordTimesheetInput>({ defaultValues }); | |||
| useEffect(() => { | |||
| formProps.reset(defaultValues); | |||
| }, [defaultValues, formProps]); | |||
| const onSubmit = useCallback<SubmitHandler<RecordTimesheetInput>>( | |||
| async (data) => { | |||
| const errors = validateTimesheet(data, leaveRecords, companyHolidays); | |||
| if (errors) { | |||
| Object.keys(errors).forEach((date) => | |||
| formProps.setError(date, { | |||
| message: errors[date], | |||
| }), | |||
| ); | |||
| return; | |||
| } | |||
| const savedRecords = await saveTimesheet(data); | |||
| const today = dayjs(); | |||
| const newFormValues = Array(7) | |||
| .fill(undefined) | |||
| .reduce<RecordTimesheetInput>((acc, _, index) => { | |||
| const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); | |||
| return { | |||
| ...acc, | |||
| [date]: savedRecords[date] ?? [], | |||
| }; | |||
| }, {}); | |||
| formProps.reset(newFormValues); | |||
| onClose(); | |||
| }, | |||
| [companyHolidays, formProps, leaveRecords, onClose], | |||
| ); | |||
| const onCancel = useCallback(() => { | |||
| formProps.reset(defaultValues); | |||
| onClose(); | |||
| }, [defaultValues, formProps, onClose]); | |||
| const onModalClose = useCallback<NonNullable<ModalProps["onClose"]>>( | |||
| (_, reason) => { | |||
| if (reason !== "backdropClick") { | |||
| onClose(); | |||
| } | |||
| }, | |||
| [onClose], | |||
| ); | |||
| const errorComponent = ( | |||
| <ErrorAlert | |||
| errors={Object.keys(formProps.formState.errors).map((date) => { | |||
| const error = formProps.formState.errors[date]?.message; | |||
| return error | |||
| ? `${date}: ${t(error, { | |||
| TIMESHEET_DAILY_MAX_HOURS, | |||
| DAILY_NORMAL_MAX_HOURS, | |||
| })}` | |||
| : undefined; | |||
| })} | |||
| /> | |||
| ); | |||
| const matches = useIsMobile(); | |||
| return ( | |||
| <FormProvider {...formProps}> | |||
| {!matches ? ( | |||
| // Desktop version | |||
| <Modal open={isOpen} onClose={onModalClose}> | |||
| <Card sx={modalSx}> | |||
| <CardContent | |||
| component="form" | |||
| onSubmit={formProps.handleSubmit(onSubmit)} | |||
| > | |||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||
| {t("Timesheet Input")} | |||
| </Typography> | |||
| <Box | |||
| sx={{ | |||
| marginInline: -3, | |||
| marginBlock: 4, | |||
| }} | |||
| > | |||
| <TimesheetTable | |||
| companyHolidays={companyHolidays} | |||
| assignedProjects={assignedProjects} | |||
| allProjects={allProjects} | |||
| leaveRecords={leaveRecords} | |||
| fastEntryEnabled={fastEntryEnabled} | |||
| /> | |||
| </Box> | |||
| {errorComponent} | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Close />} | |||
| onClick={onCancel} | |||
| > | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button variant="contained" startIcon={<Check />} type="submit"> | |||
| {t("Save")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| </Modal> | |||
| ) : ( | |||
| // Mobile version | |||
| <FullscreenModal | |||
| open={isOpen} | |||
| onClose={onModalClose} | |||
| closeModal={onCancel} | |||
| > | |||
| <Box | |||
| display="flex" | |||
| flexDirection="column" | |||
| gap={2} | |||
| height="100%" | |||
| component="form" | |||
| onSubmit={formProps.handleSubmit(onSubmit)} | |||
| > | |||
| <Typography variant="h6" padding={2} flex="none"> | |||
| {t("Timesheet Input")} | |||
| </Typography> | |||
| <MobileTimesheetTable | |||
| fastEntryEnabled={fastEntryEnabled} | |||
| companyHolidays={companyHolidays} | |||
| assignedProjects={assignedProjects} | |||
| allProjects={allProjects} | |||
| leaveRecords={leaveRecords} | |||
| errorComponent={errorComponent} | |||
| /> | |||
| </Box> | |||
| </FullscreenModal> | |||
| )} | |||
| </FormProvider> | |||
| ); | |||
| }; | |||
| export default TimesheetModal; | |||
| @@ -1 +0,0 @@ | |||
| export { default } from "./TimesheetModal"; | |||
| @@ -174,6 +174,7 @@ const FastTimeEntryModal: React.FC<Props> = ({ | |||
| name="projectIds" | |||
| render={({ field }) => ( | |||
| <ProjectSelect | |||
| includeLeaves={false} | |||
| error={Boolean(formState.errors.projectIds)} | |||
| multiple | |||
| allProjects={allProjectsWithFastEntry} | |||
| @@ -1,82 +1,171 @@ | |||
| import React from "react"; | |||
| import { Box, Card, CardContent, Grid, Typography } from "@mui/material"; | |||
| import React, { useCallback, useState } from "react"; | |||
| import { | |||
| Box, | |||
| Card, | |||
| CardContent, | |||
| Grid, | |||
| ToggleButton, | |||
| ToggleButtonGroup, | |||
| ToggleButtonProps, | |||
| Tooltip, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { manhourFormatter } from "@/app/utils/formatUtil"; | |||
| import { AssignedProject } from "@/app/api/projects"; | |||
| import { TableRows, ViewModule, TableChart } from "@mui/icons-material"; | |||
| import ProjectTable from "./ProjectTable"; | |||
| interface Props { | |||
| export interface Props { | |||
| projects: AssignedProject[]; | |||
| maintainNormalStaffWorkspaceAbility?: boolean; | |||
| maintainManagementStaffWorkspaceAbility?: boolean; | |||
| } | |||
| const ProjectGrid: React.FC<Props> = ({ projects, maintainNormalStaffWorkspaceAbility, maintainManagementStaffWorkspaceAbility }) => { | |||
| const ProjectGrid: React.FC<Props> = ({ | |||
| projects, | |||
| maintainNormalStaffWorkspaceAbility, | |||
| maintainManagementStaffWorkspaceAbility, | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| const [view, setView] = useState<"grid" | "list" | "table">("grid"); | |||
| const handleViewChange = useCallback< | |||
| NonNullable<ToggleButtonProps["onChange"]> | |||
| >((e, value) => { | |||
| if (value) { | |||
| setView(value); | |||
| } | |||
| }, []); | |||
| return ( | |||
| <Box> | |||
| <Grid container columns={{ xs: 4, sm: 8, md: 12, lg: 16 }} spacing={2}> | |||
| {projects.map((project, idx) => ( | |||
| <Grid key={`${project.code}${idx}`} item xs={4}> | |||
| <Card> | |||
| <CardContent> | |||
| <Typography variant="overline">{project.code}</Typography> | |||
| <Typography | |||
| variant="h6" | |||
| sx={{ | |||
| overflow: "hidden", | |||
| textOverflow: "ellipsis", | |||
| whiteSpace: "nowrap", | |||
| marginBlockEnd: 3, | |||
| }} | |||
| > | |||
| {project.name} | |||
| </Typography> | |||
| {/* Hours Spent */} | |||
| {(Boolean(maintainNormalStaffWorkspaceAbility) || Boolean(maintainManagementStaffWorkspaceAbility)) && <><Typography variant="subtitle2">{t("Hours Spent:")}</Typography> | |||
| <Box | |||
| <ToggleButtonGroup | |||
| color="primary" | |||
| value={view} | |||
| exclusive | |||
| onChange={handleViewChange} | |||
| sx={{ marginBlockEnd: 2 }} | |||
| > | |||
| <ToggleButton value="grid"> | |||
| <Tooltip title={t("Grid view")}> | |||
| <ViewModule /> | |||
| </Tooltip> | |||
| </ToggleButton> | |||
| <ToggleButton value="list"> | |||
| <Tooltip title={t("List view")}> | |||
| <TableRows /> | |||
| </Tooltip> | |||
| </ToggleButton> | |||
| <ToggleButton value="table"> | |||
| <Tooltip title={t("Table view")}> | |||
| <TableChart /> | |||
| </Tooltip> | |||
| </ToggleButton> | |||
| </ToggleButtonGroup> | |||
| {view === "table" ? ( | |||
| <ProjectTable | |||
| projects={projects} | |||
| maintainManagementStaffWorkspaceAbility={ | |||
| maintainManagementStaffWorkspaceAbility | |||
| } | |||
| maintainNormalStaffWorkspaceAbility={ | |||
| maintainNormalStaffWorkspaceAbility | |||
| } | |||
| /> | |||
| ) : ( | |||
| <Grid | |||
| container | |||
| columns={view === "list" ? 4 : { xs: 4, sm: 8, md: 12, lg: 16 }} | |||
| spacing={2} | |||
| alignItems="stretch" | |||
| > | |||
| {projects.map((project, idx) => ( | |||
| <Grid key={`${project.code}${idx}`} item xs={4}> | |||
| <Card sx={{ height: "100%" }}> | |||
| <CardContent | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| alignItems: "baseline", | |||
| flexDirection: "column", | |||
| height: "100%", | |||
| }} | |||
| > | |||
| <Typography variant="caption">{t("Normal")}</Typography> | |||
| <Typography> | |||
| {manhourFormatter.format(Boolean(maintainManagementStaffWorkspaceAbility) ? project.hoursSpent : project.currentStaffHoursSpent)} | |||
| </Typography> | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| alignItems: "baseline", | |||
| }} | |||
| > | |||
| <Typography variant="caption">{t("Others")}</Typography> | |||
| <Typography>{`${manhourFormatter.format( | |||
| Boolean(maintainManagementStaffWorkspaceAbility) ? project.hoursSpentOther : project.currentStaffHoursSpentOther, | |||
| )}`}</Typography> | |||
| </Box></>} | |||
| {/* Hours Allocated */} | |||
| {Boolean(maintainManagementStaffWorkspaceAbility) && <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| alignItems: "baseline", | |||
| }} | |||
| > | |||
| <Typography variant="subtitle2" sx={{ marginBlockStart: 2 }}> | |||
| {t("Hours Allocated:")} | |||
| </Typography> | |||
| <Typography> | |||
| {manhourFormatter.format(project.hoursAllocated)} | |||
| <Typography variant="overline">{project.code}</Typography> | |||
| <Typography | |||
| variant="h6" | |||
| sx={{ | |||
| marginBlockEnd: 3, | |||
| }} | |||
| > | |||
| {project.name} | |||
| </Typography> | |||
| </Box>} | |||
| </CardContent> | |||
| </Card> | |||
| </Grid> | |||
| ))} | |||
| </Grid> | |||
| {/* Spacer */} | |||
| <Box sx={{ flex: 1 }} /> | |||
| {/* Hours Spent */} | |||
| {(Boolean(maintainNormalStaffWorkspaceAbility) || | |||
| Boolean(maintainManagementStaffWorkspaceAbility)) && ( | |||
| <> | |||
| <Typography variant="subtitle2"> | |||
| {t("Hours Spent:")} | |||
| </Typography> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| alignItems: "baseline", | |||
| }} | |||
| > | |||
| <Typography variant="caption">{t("Normal")}</Typography> | |||
| <Typography> | |||
| {manhourFormatter.format( | |||
| Boolean(maintainManagementStaffWorkspaceAbility) | |||
| ? project.hoursSpent | |||
| : project.currentStaffHoursSpent, | |||
| )} | |||
| </Typography> | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| alignItems: "baseline", | |||
| }} | |||
| > | |||
| <Typography variant="caption">{t("Others")}</Typography> | |||
| <Typography>{`${manhourFormatter.format( | |||
| Boolean(maintainManagementStaffWorkspaceAbility) | |||
| ? project.hoursSpentOther | |||
| : project.currentStaffHoursSpentOther, | |||
| )}`}</Typography> | |||
| </Box> | |||
| </> | |||
| )} | |||
| {/* Hours Allocated */} | |||
| {Boolean(maintainManagementStaffWorkspaceAbility) && ( | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| alignItems: "baseline", | |||
| }} | |||
| > | |||
| <Typography | |||
| variant="subtitle2" | |||
| sx={{ marginBlockStart: 2 }} | |||
| > | |||
| {t("Hours Allocated:")} | |||
| </Typography> | |||
| <Typography> | |||
| {manhourFormatter.format(project.hoursAllocated)} | |||
| </Typography> | |||
| </Box> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| </Grid> | |||
| ))} | |||
| </Grid> | |||
| )} | |||
| </Box> | |||
| ); | |||
| }; | |||
| @@ -0,0 +1,106 @@ | |||
| import { useTranslation } from "react-i18next"; | |||
| import { Props } from "./ProjectGrid"; | |||
| import { | |||
| Paper, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| } from "@mui/material"; | |||
| import { useMemo } from "react"; | |||
| import { AssignedProject } from "@/app/api/projects"; | |||
| import { manhourFormatter } from "@/app/utils/formatUtil"; | |||
| interface Column { | |||
| name: keyof AssignedProject; | |||
| label: string; | |||
| } | |||
| const hourColumns: Array<keyof AssignedProject> = [ | |||
| "currentStaffHoursSpent", | |||
| "currentStaffHoursSpentOther", | |||
| "hoursAllocated", | |||
| "hoursSpent", | |||
| "hoursSpentOther", | |||
| ]; | |||
| const ProjectTable: React.FC<Props> = ({ | |||
| projects, | |||
| maintainManagementStaffWorkspaceAbility, | |||
| maintainNormalStaffWorkspaceAbility, | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| const columns = useMemo<Column[]>(() => { | |||
| return [ | |||
| { name: "code", label: t("Project Code") }, | |||
| { name: "name", label: t("Project Name") }, | |||
| ...(maintainManagementStaffWorkspaceAbility || | |||
| maintainNormalStaffWorkspaceAbility | |||
| ? maintainManagementStaffWorkspaceAbility | |||
| ? ([ | |||
| { name: "hoursSpent", label: t("Total Normal Hours Spent") }, | |||
| { name: "hoursSpentOther", label: t("Total Other Hours Spent") }, | |||
| { name: "hoursAllocated", label: t("Hours Allocated") }, | |||
| ] satisfies Column[]) | |||
| : ([ | |||
| { | |||
| name: "currentStaffHoursSpent", | |||
| label: t("Normal Hours Spent"), | |||
| }, | |||
| { | |||
| name: "currentStaffHoursSpentOther", | |||
| label: t("Other Hours Spent"), | |||
| }, | |||
| ] satisfies Column[]) | |||
| : []), | |||
| ]; | |||
| }, [ | |||
| maintainManagementStaffWorkspaceAbility, | |||
| maintainNormalStaffWorkspaceAbility, | |||
| t, | |||
| ]); | |||
| return ( | |||
| <Paper sx={{ overflow: "hidden" }}> | |||
| <TableContainer> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| {columns.map((column, idx) => ( | |||
| <TableCell key={`${column.name.toString()}-${idx}`}> | |||
| {column.label} | |||
| </TableCell> | |||
| ))} | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {projects.map((project) => { | |||
| return ( | |||
| <TableRow hover tabIndex={-1} key={project.id}> | |||
| {columns.map((column, idx) => { | |||
| const columnName = column.name; | |||
| const needsFormatting = hourColumns.includes(columnName); | |||
| return ( | |||
| <TableCell key={`${columnName.toString()}-${idx}`}> | |||
| {needsFormatting | |||
| ? manhourFormatter.format( | |||
| project[columnName] as number, | |||
| ) | |||
| : project[columnName]?.toString()} | |||
| </TableCell> | |||
| ); | |||
| })} | |||
| </TableRow> | |||
| ); | |||
| })} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default ProjectTable; | |||
| @@ -4,7 +4,12 @@ import React, { useCallback, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import Button from "@mui/material/Button"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import { CalendarMonth, EditCalendar, MoreTime } from "@mui/icons-material"; | |||
| import { | |||
| CalendarMonth, | |||
| EditCalendar, | |||
| Luggage, | |||
| MoreTime, | |||
| } from "@mui/icons-material"; | |||
| import { Menu, MenuItem, SxProps, Typography } from "@mui/material"; | |||
| import AssignedProjects from "./AssignedProjects"; | |||
| import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||
| @@ -19,6 +24,7 @@ import PastEntryCalendarModal from "../PastEntryCalendar/PastEntryCalendarModal" | |||
| import { HolidaysResult } from "@/app/api/holidays"; | |||
| import { TimesheetAmendmentModal } from "../TimesheetAmendment/TimesheetAmendmentModal"; | |||
| import TimeLeaveModal from "../TimeLeaveModal/TimeLeaveModal"; | |||
| import LeaveModal from "../LeaveModal"; | |||
| export interface Props { | |||
| leaveTypes: LeaveType[]; | |||
| @@ -32,6 +38,7 @@ export interface Props { | |||
| fastEntryEnabled: boolean; | |||
| maintainNormalStaffWorkspaceAbility: boolean; | |||
| maintainManagementStaffWorkspaceAbility: boolean; | |||
| isFullTime: boolean; | |||
| } | |||
| const menuItemSx: SxProps = { | |||
| @@ -51,10 +58,12 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| fastEntryEnabled, | |||
| maintainNormalStaffWorkspaceAbility, | |||
| maintainManagementStaffWorkspaceAbility, | |||
| isFullTime, | |||
| }) => { | |||
| const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | |||
| const [isTimeLeaveModalVisible, setTimeLeaveModalVisible] = useState(false); | |||
| const [isLeaveCalendarVisible, setLeaveCalendarVisible] = useState(false); | |||
| const [isPastEventModalVisible, setPastEventModalVisible] = useState(false); | |||
| const [isTimesheetAmendmentVisible, setisTimesheetAmendmentVisible] = | |||
| useState(false); | |||
| @@ -81,6 +90,15 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| setTimeLeaveModalVisible(false); | |||
| }, []); | |||
| const handleOpenLeaveCalendarButton = useCallback(() => { | |||
| setAnchorEl(null); | |||
| setLeaveCalendarVisible(true); | |||
| }, []); | |||
| const handleCloseLeaveCalendarButton = useCallback(() => { | |||
| setLeaveCalendarVisible(false); | |||
| }, []); | |||
| const handlePastEventClick = useCallback(() => { | |||
| setAnchorEl(null); | |||
| setPastEventModalVisible(true); | |||
| @@ -136,6 +154,10 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| <MoreTime /> | |||
| {t("Enter Timesheet")} | |||
| </MenuItem> | |||
| <MenuItem onClick={handleOpenLeaveCalendarButton} sx={menuItemSx}> | |||
| <Luggage /> | |||
| {t("Record Leave")} | |||
| </MenuItem> | |||
| <MenuItem onClick={handlePastEventClick} sx={menuItemSx}> | |||
| <CalendarMonth /> | |||
| {t("View Past Entries")} | |||
| @@ -154,6 +176,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| leaves={defaultLeaveRecords} | |||
| allProjects={allProjects} | |||
| leaveTypes={leaveTypes} | |||
| companyHolidays={holidays} | |||
| /> | |||
| <TimeLeaveModal | |||
| fastEntryEnabled={fastEntryEnabled} | |||
| @@ -165,6 +188,17 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| assignedProjects={assignedProjects} | |||
| timesheetRecords={defaultTimesheets} | |||
| leaveRecords={defaultLeaveRecords} | |||
| isFullTime={isFullTime} | |||
| /> | |||
| <LeaveModal | |||
| open={isLeaveCalendarVisible} | |||
| onClose={handleCloseLeaveCalendarButton} | |||
| leaveTypes={leaveTypes} | |||
| companyHolidays={holidays} | |||
| allProjects={allProjects} | |||
| leaveRecords={defaultLeaveRecords} | |||
| timesheetRecords={defaultTimesheets} | |||
| isFullTime={isFullTime} | |||
| /> | |||
| {assignedProjects.length > 0 ? ( | |||
| <AssignedProjects | |||
| @@ -11,8 +11,12 @@ import { | |||
| fetchTimesheets, | |||
| } from "@/app/api/timesheets"; | |||
| import { fetchHolidays } from "@/app/api/holidays"; | |||
| import { getUserAbilities } from "@/app/utils/commonUtil"; | |||
| import { MAINTAIN_TIMESHEET_FAST_TIME_ENTRY, MAINTAIN_NORMAL_STAFF_WORKSPACE, MAINTAIN_MANAGEMENT_STAFF_WORKSPACE } from "@/middleware"; | |||
| import { getUserAbilities, getUserStaff } from "@/app/utils/commonUtil"; | |||
| import { | |||
| MAINTAIN_TIMESHEET_FAST_TIME_ENTRY, | |||
| MAINTAIN_NORMAL_STAFF_WORKSPACE, | |||
| MAINTAIN_MANAGEMENT_STAFF_WORKSPACE, | |||
| } from "@/middleware"; | |||
| const UserWorkspaceWrapper: React.FC = async () => { | |||
| const [ | |||
| @@ -25,6 +29,7 @@ const UserWorkspaceWrapper: React.FC = async () => { | |||
| leaveTypes, | |||
| holidays, | |||
| abilities, | |||
| userStaff, | |||
| ] = await Promise.all([ | |||
| fetchTeamMemberLeaves(), | |||
| fetchTeamMemberTimesheets(), | |||
| @@ -34,15 +39,24 @@ const UserWorkspaceWrapper: React.FC = async () => { | |||
| fetchLeaves(), | |||
| fetchLeaveTypes(), | |||
| fetchHolidays(), | |||
| getUserAbilities() | |||
| getUserAbilities(), | |||
| getUserStaff(), | |||
| ]); | |||
| const fastEntryEnabled = abilities.includes(MAINTAIN_TIMESHEET_FAST_TIME_ENTRY) | |||
| const maintainNormalStaffWorkspaceAbility = abilities.includes(MAINTAIN_NORMAL_STAFF_WORKSPACE) | |||
| const maintainManagementStaffWorkspaceAbility = abilities.includes(MAINTAIN_MANAGEMENT_STAFF_WORKSPACE) | |||
| const fastEntryEnabled = abilities.includes( | |||
| MAINTAIN_TIMESHEET_FAST_TIME_ENTRY, | |||
| ); | |||
| const maintainNormalStaffWorkspaceAbility = abilities.includes( | |||
| MAINTAIN_NORMAL_STAFF_WORKSPACE, | |||
| ); | |||
| const maintainManagementStaffWorkspaceAbility = abilities.includes( | |||
| MAINTAIN_MANAGEMENT_STAFF_WORKSPACE, | |||
| ); | |||
| const isFullTime = userStaff?.employType === "FT"; | |||
| return ( | |||
| <UserWorkspacePage | |||
| isFullTime={isFullTime} | |||
| teamLeaves={teamLeaves} | |||
| teamTimesheets={teamTimesheets} | |||
| allProjects={allProjects} | |||
| @@ -54,7 +68,9 @@ const UserWorkspaceWrapper: React.FC = async () => { | |||
| // Change to access check | |||
| fastEntryEnabled={fastEntryEnabled} | |||
| maintainNormalStaffWorkspaceAbility={maintainNormalStaffWorkspaceAbility} | |||
| maintainManagementStaffWorkspaceAbility={maintainManagementStaffWorkspaceAbility} | |||
| maintainManagementStaffWorkspaceAbility={ | |||
| maintainManagementStaffWorkspaceAbility | |||
| } | |||
| /> | |||
| ); | |||
| }; | |||
| @@ -6,10 +6,11 @@ export interface SessionStaff { | |||
| id: number; | |||
| teamId: number; | |||
| isTeamLead: boolean; | |||
| employType: string | null; | |||
| } | |||
| export interface SessionWithTokens extends Session { | |||
| staff?: SessionStaff; | |||
| role?: String; | |||
| role?: string; | |||
| abilities?: string[]; | |||
| accessToken?: string; | |||
| refreshToken?: string; | |||
| @@ -60,14 +61,14 @@ export const authOptions: AuthOptions = { | |||
| session({ session, token }) { | |||
| const sessionWithToken: SessionWithTokens = { | |||
| ...session, | |||
| role: token.role as String, | |||
| role: token.role as string, | |||
| // Add the data from the token to the session | |||
| abilities: (token.abilities as ability[]).map( | |||
| (item: ability) => item.actionSubjectCombo, | |||
| ) as string[], | |||
| accessToken: token.accessToken as string | undefined, | |||
| refreshToken: token.refreshToken as string | undefined, | |||
| staff: token.staff as SessionStaff | |||
| staff: token.staff as SessionStaff, | |||
| }; | |||
| // console.log(sessionWithToken) | |||
| return sessionWithToken; | |||
| @@ -59,16 +59,16 @@ export const [ | |||
| VIEW_PROJECT_RESOURCE_CONSUMPTION_RANKING, | |||
| MAINTAIN_NORMAL_STAFF_WORKSPACE, | |||
| MAINTAIN_MANAGEMENT_STAFF_WORKSPACE, | |||
| GENERATE_LATE_START_REPORTS, | |||
| GENERATE_LATE_START_REPORT, | |||
| GENERATE_PROJECT_POTENTIAL_DELAY_REPORT, | |||
| GENERATE_RESOURCE_OVERCONSUMPTION_REPORT, | |||
| GENERATE_COST_ANT_EXPENSE_REPORT, | |||
| GENERATE_COST_AND_EXPENSE_REPORT, | |||
| GENERATE_PROJECT_COMPLETION_REPORT, | |||
| GENERATE_PROJECT_PANDL_REPORT, | |||
| GENERATE_FINANCIAL_STATUS_REPORT, | |||
| GENERATE_PROJECT_CASH_FLOW_REPORT, | |||
| GENERATE_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT, | |||
| GENERATE_CROSS_TEAM_CHARGE_REPORT, | |||
| GENERATE_CROSS_TEAM_CHARGE_REPORT | |||
| ] = [ | |||
| 'MAINTAIN_USER', | |||
| 'MAINTAIN_TIMESHEET', | |||
| @@ -109,16 +109,16 @@ export const [ | |||
| 'VIEW_PROJECT_RESOURCE_CONSUMPTION_RANKING', | |||
| 'MAINTAIN_NORMAL_STAFF_WORKSPACE', | |||
| 'MAINTAIN_MANAGEMENT_STAFF_WORKSPACE', | |||
| 'GENERATE_LATE_START_REPORTS', | |||
| 'GENERATE_PROJECT_POTENTIAL_DELAY_REPORT', | |||
| 'GENERATE_RESOURCE_OVERCONSUMPTION_REPORT', | |||
| 'GENERATE_COST_ANT_EXPENSE_REPORT', | |||
| 'GENERATE_PROJECT_COMPLETION_REPORT', | |||
| 'GENERATE_PROJECT_P&L_REPORT', | |||
| 'GENERATE_FINANCIAL_STATUS_REPORT', | |||
| 'GENERATE_PROJECT_CASH_FLOW_REPORT', | |||
| 'GENERATE_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT', | |||
| 'GENERATE_CROSS_TEAM_CHARGE_REPORT', | |||
| 'G_LATE_START_REPORT', | |||
| 'G_PROJECT_POTENTIAL_DELAY_REPORT', | |||
| 'G_RESOURCE_OVERCONSUMPTION_REPORT', | |||
| 'G_COST_AND_EXPENSE_REPORT', | |||
| 'G_PROJECT_COMPLETION_REPORT', | |||
| 'G_PROJECT_P&L_REPORT', | |||
| 'G_FINANCIAL_STATUS_REPORT', | |||
| 'G_PROJECT_CASH_FLOW_REPORT', | |||
| 'G_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT', | |||
| 'G_CROSS_TEAM_CHARGE_REPORT' | |||
| ] | |||
| const PRIVATE_ROUTES = [ | |||
| @@ -243,20 +243,21 @@ export default async function middleware( | |||
| if (req.nextUrl.pathname.startsWith('/analytics')) { | |||
| isAuth = [ | |||
| GENERATE_LATE_START_REPORTS, | |||
| GENERATE_LATE_START_REPORT, | |||
| GENERATE_PROJECT_POTENTIAL_DELAY_REPORT, | |||
| GENERATE_RESOURCE_OVERCONSUMPTION_REPORT, | |||
| GENERATE_COST_ANT_EXPENSE_REPORT, | |||
| GENERATE_COST_AND_EXPENSE_REPORT, | |||
| GENERATE_PROJECT_COMPLETION_REPORT, | |||
| GENERATE_PROJECT_PANDL_REPORT, | |||
| GENERATE_FINANCIAL_STATUS_REPORT, | |||
| GENERATE_PROJECT_CASH_FLOW_REPORT, | |||
| GENERATE_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT, | |||
| GENERATE_CROSS_TEAM_CHARGE_REPORT,].some((ability) => abilities.includes(ability)); | |||
| GENERATE_CROSS_TEAM_CHARGE_REPORT | |||
| ].some((ability) => abilities.includes(ability)); | |||
| } | |||
| if (req.nextUrl.pathname.startsWith('/analytics/LateStartReport')) { | |||
| isAuth = [GENERATE_LATE_START_REPORTS].some((ability) => abilities.includes(ability)); | |||
| isAuth = [GENERATE_LATE_START_REPORT].some((ability) => abilities.includes(ability)); | |||
| } | |||
| if (req.nextUrl.pathname.startsWith('/analytics/ProjectPotentialDelayReport')) { | |||
| @@ -268,7 +269,7 @@ export default async function middleware( | |||
| } | |||
| if (req.nextUrl.pathname.startsWith('/analytics/CostandExpenseReport')) { | |||
| isAuth = [GENERATE_COST_ANT_EXPENSE_REPORT].some((ability) => abilities.includes(ability)); | |||
| isAuth = [GENERATE_COST_AND_EXPENSE_REPORT].some((ability) => abilities.includes(ability)); | |||
| } | |||
| if (req.nextUrl.pathname.startsWith('/analytics/ProjectCompletionReport')) { | |||