| @@ -68,7 +68,6 @@ export interface AssignedProject extends ProjectWithTasks { | |||
| hoursSpent: number; | |||
| hoursSpentOther: number; | |||
| hoursAllocated: number; | |||
| hoursAllocatedOther: number; | |||
| } | |||
| export const preloadProjects = () => { | |||
| @@ -21,41 +21,52 @@ export const OUTPUT_DATE_FORMAT = "YYYY/MM/DD"; | |||
| export const OUTPUT_TIME_FORMAT = "HH:mm:ss"; | |||
| export const convertDateToString = (date: Date, format: string = OUTPUT_DATE_FORMAT) => { | |||
| return dayjs(date).format(format) | |||
| } | |||
| export const convertDateToString = ( | |||
| date: Date, | |||
| format: string = OUTPUT_DATE_FORMAT, | |||
| ) => { | |||
| return dayjs(date).format(format); | |||
| }; | |||
| export const convertDateArrayToString = (dateArray: number[], format: string = OUTPUT_DATE_FORMAT, needTime: boolean = false) => { | |||
| export const convertDateArrayToString = ( | |||
| dateArray: number[], | |||
| format: string = OUTPUT_DATE_FORMAT, | |||
| needTime: boolean = false, | |||
| ) => { | |||
| if (dateArray.length === 6) { | |||
| if (!needTime) { | |||
| const dateString = `${dateArray[0]}-${dateArray[1]}-${dateArray[2]}` | |||
| return dayjs(dateString).format(format) | |||
| const dateString = `${dateArray[0]}-${dateArray[1]}-${dateArray[2]}`; | |||
| return dayjs(dateString).format(format); | |||
| } | |||
| } | |||
| if (dateArray.length === 3) { | |||
| if (!needTime) { | |||
| const dateString = `${dateArray[0]}-${dateArray[1]}-${dateArray[2]}` | |||
| return dayjs(dateString).format(format) | |||
| const dateString = `${dateArray[0]}-${dateArray[1]}-${dateArray[2]}`; | |||
| return dayjs(dateString).format(format); | |||
| } | |||
| } | |||
| } | |||
| }; | |||
| export const convertTimeArrayToString = (timeArray: number[], format: string = OUTPUT_TIME_FORMAT, needTime: boolean = false) => { | |||
| let timeString = ''; | |||
| export const convertTimeArrayToString = ( | |||
| timeArray: number[], | |||
| format: string = OUTPUT_TIME_FORMAT, | |||
| needTime: boolean = false, | |||
| ) => { | |||
| let timeString = ""; | |||
| if (timeArray !== null && timeArray !== undefined) { | |||
| const hour = timeArray[0] || 0; | |||
| const minute = timeArray[1] || 0; | |||
| timeString = dayjs() | |||
| .set('hour', hour) | |||
| .set('minute', minute) | |||
| .set('second', 0) | |||
| .format('HH:mm:ss'); | |||
| const hour = timeArray[0] || 0; | |||
| const minute = timeArray[1] || 0; | |||
| timeString = dayjs() | |||
| .set("hour", hour) | |||
| .set("minute", minute) | |||
| .set("second", 0) | |||
| .format("HH:mm:ss"); | |||
| } | |||
| return timeString | |||
| } | |||
| return timeString; | |||
| }; | |||
| const shortDateFormatter_en = new Intl.DateTimeFormat("en-HK", { | |||
| weekday: "short", | |||
| @@ -81,6 +92,36 @@ export const shortDateFormatter = (locale?: string) => { | |||
| } | |||
| }; | |||
| const clockFormatOptions: Intl.DateTimeFormatOptions = { | |||
| year: "numeric", | |||
| month: "long", | |||
| day: "numeric", | |||
| weekday: "long", | |||
| hour: "2-digit", | |||
| minute: "2-digit", | |||
| second: "2-digit", | |||
| hour12: true, | |||
| }; | |||
| const clockTimeFormatter_en = new Intl.DateTimeFormat( | |||
| "en-HK", | |||
| clockFormatOptions, | |||
| ); | |||
| const clockTimeformatter_zh = new Intl.DateTimeFormat( | |||
| "zh-HK", | |||
| clockFormatOptions, | |||
| ); | |||
| export const clockTimeFormatter = (locale?: string) => { | |||
| switch (locale) { | |||
| case "zh": | |||
| return clockTimeformatter_zh; | |||
| case "en": | |||
| default: | |||
| return clockTimeFormatter_en; | |||
| } | |||
| }; | |||
| export function convertLocaleStringToNumber(numberString: string): number { | |||
| const numberWithoutCommas = numberString.replace(/,/g, ""); | |||
| return parseFloat(numberWithoutCommas); | |||
| @@ -91,6 +132,6 @@ export function timestampToDateString(timestamp: string): string { | |||
| const year = date.getFullYear(); | |||
| const month = String(date.getMonth() + 1).padStart(2, "0"); | |||
| const day = String(date.getDate()).padStart(2, "0"); | |||
| console.log(`${year}-${month}-${day}`) | |||
| console.log(`${year}-${month}-${day}`); | |||
| return `${year}-${month}-${day}`; | |||
| } | |||
| } | |||
| @@ -7,7 +7,7 @@ import MUILink from "@mui/material/Link"; | |||
| import { usePathname } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import Clock from "./Clock"; | |||
| import { Grid } from "@mui/material"; | |||
| import { Box, Grid } from "@mui/material"; | |||
| import { I18nProvider } from "@/i18n"; | |||
| const pathToLabelMap: { [path: string]: string } = { | |||
| @@ -46,42 +46,43 @@ const Breadcrumb = () => { | |||
| // const { t } = useTranslation("customer"); | |||
| return ( | |||
| <Grid container> | |||
| <Grid item xs={6}> | |||
| <Breadcrumbs> | |||
| {segments.map((segment, index) => { | |||
| const href = segments.slice(0, index + 1).join("/"); | |||
| const label = pathToLabelMap[href] || segment; | |||
| <Box | |||
| display="flex" | |||
| flexDirection={{ xs: "column-reverse", sm: "row"}} | |||
| justifyContent={{ sm: "space-between" }} | |||
| > | |||
| <Breadcrumbs> | |||
| {segments.map((segment, index) => { | |||
| const href = segments.slice(0, index + 1).join("/"); | |||
| const label = pathToLabelMap[href] || segment; | |||
| if (index === segments.length - 1) { | |||
| return ( | |||
| <Typography key={index} color="text.primary"> | |||
| {label} | |||
| {/* {t(label)} */} | |||
| </Typography> | |||
| ); | |||
| } else { | |||
| return ( | |||
| <MUILink | |||
| underline="hover" | |||
| color="inherit" | |||
| key={index} | |||
| component={Link} | |||
| href={href || "/"} | |||
| > | |||
| {label} | |||
| </MUILink> | |||
| ); | |||
| } | |||
| })} | |||
| </Breadcrumbs> | |||
| </Grid> | |||
| <Grid item xs={6} sx={{ display: 'flex', justifyContent: 'flex-end' }}> | |||
| <Clock /> | |||
| </Grid> | |||
| </Grid> | |||
| if (index === segments.length - 1) { | |||
| return ( | |||
| <Typography key={index} color="text.primary"> | |||
| {label} | |||
| {/* {t(label)} */} | |||
| </Typography> | |||
| ); | |||
| } else { | |||
| return ( | |||
| <MUILink | |||
| underline="hover" | |||
| color="inherit" | |||
| key={index} | |||
| component={Link} | |||
| href={href || "/"} | |||
| > | |||
| {label} | |||
| </MUILink> | |||
| ); | |||
| } | |||
| })} | |||
| </Breadcrumbs> | |||
| <Box width={{ xs: "100%", sm: "auto" }} marginBlockEnd={{ xs: 1, sm: 0 }}> | |||
| <Clock variant="body2" /> | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| }; | |||
| @@ -1,32 +1,33 @@ | |||
| "use client" | |||
| import { useState, useEffect, useLayoutEffect } from 'react'; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { useTranslation } from 'react-i18next'; | |||
| "use client"; | |||
| import React, { useState, useLayoutEffect } from "react"; | |||
| import Typography, { TypographyProps } from "@mui/material/Typography"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { clockTimeFormatter } from "@/app/utils/formatUtil"; | |||
| import { NoSsr } from "@mui/material"; | |||
| const Clock = () => { | |||
| const { | |||
| i18n: { language }, | |||
| } = useTranslation(); | |||
| const [currentDateTime, setCurrentDateTime] = useState(new Date()); | |||
| const Clock: React.FC<TypographyProps> = (props) => { | |||
| const { | |||
| i18n: { language }, | |||
| } = useTranslation(); | |||
| const [currentDateTime, setCurrentDateTime] = useState(new Date()); | |||
| useLayoutEffect(() => { | |||
| const timer = setInterval(() => { | |||
| setCurrentDateTime(new Date()); | |||
| }, 1000); | |||
| useLayoutEffect(() => { | |||
| const timer = setInterval(() => { | |||
| setCurrentDateTime(new Date()); | |||
| }, 1000); | |||
| return () => { | |||
| clearInterval(timer); | |||
| }; | |||
| }, []); | |||
| return () => { | |||
| clearInterval(timer); | |||
| }; | |||
| }, []); | |||
| const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true }; | |||
| const formattedDateTime = new Intl.DateTimeFormat(language, options).format(currentDateTime) | |||
| return ( | |||
| <Typography color="text.primary" suppressHydrationWarning> | |||
| {formattedDateTime} | |||
| </Typography> | |||
| ); | |||
| return ( | |||
| <NoSsr> | |||
| <Typography {...props}> | |||
| {clockTimeFormatter(language).format(currentDateTime)} | |||
| </Typography> | |||
| </NoSsr> | |||
| ); | |||
| }; | |||
| export default Clock; | |||
| @@ -58,8 +58,8 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
| const apiRef = useGridApiRef(); | |||
| const addRow = useCallback(() => { | |||
| // const id = Date.now(); | |||
| const minId = Math.min(...payments.map((payment) => payment.id!!)); | |||
| const id = minId >= 0 ? -1 : minId - 1 | |||
| const minId = Math.min(...payments.map((payment) => payment.id!)); | |||
| const id = minId >= 0 ? -1 : minId - 1; | |||
| setPayments((p) => [...p, { id, _isNew: true }]); | |||
| setRowModesModel((model) => ({ | |||
| ...model, | |||
| @@ -241,26 +241,30 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||
| <Grid item xs> | |||
| <FormControl fullWidth> | |||
| <DatePicker | |||
| label={t("Stage Start Date")} | |||
| value={startDate ? dayjs(startDate) : null} | |||
| onChange={(date) => { | |||
| if (!date) return; | |||
| const milestones = getValues("milestones"); | |||
| setValue("milestones", { | |||
| ...milestones, | |||
| [taskGroupId]: { | |||
| ...milestones[taskGroupId], | |||
| startDate: date.format(INPUT_DATE_FORMAT), | |||
| }, | |||
| }); | |||
| }} | |||
| slotProps={{ | |||
| textField: { | |||
| error: startDate === "Invalid Date" || new Date(startDate) > new Date(endDate) || (Boolean(formState.errors.milestones) && !Boolean(startDate)), | |||
| }, | |||
| }} | |||
| /> | |||
| <DatePicker | |||
| label={t("Stage Start Date")} | |||
| value={startDate ? dayjs(startDate) : null} | |||
| onChange={(date) => { | |||
| if (!date) return; | |||
| const milestones = getValues("milestones"); | |||
| setValue("milestones", { | |||
| ...milestones, | |||
| [taskGroupId]: { | |||
| ...milestones[taskGroupId], | |||
| startDate: date.format(INPUT_DATE_FORMAT), | |||
| }, | |||
| }); | |||
| }} | |||
| slotProps={{ | |||
| textField: { | |||
| error: | |||
| startDate === "Invalid Date" || | |||
| new Date(startDate) > new Date(endDate) || | |||
| (Boolean(formState.errors.milestones) && | |||
| !Boolean(startDate)), | |||
| }, | |||
| }} | |||
| /> | |||
| </FormControl> | |||
| </Grid> | |||
| <Grid item xs> | |||
| @@ -281,7 +285,11 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
| }} | |||
| slotProps={{ | |||
| textField: { | |||
| error: endDate === "Invalid Date" || new Date(startDate) > new Date(endDate) || (Boolean(formState.errors.milestones) && !Boolean(endDate)), | |||
| error: | |||
| endDate === "Invalid Date" || | |||
| new Date(startDate) > new Date(endDate) || | |||
| (Boolean(formState.errors.milestones) && | |||
| !Boolean(endDate)), | |||
| }, | |||
| }} | |||
| /> | |||
| @@ -0,0 +1,106 @@ | |||
| import React from "react"; | |||
| import { | |||
| RecordTimesheetInput, | |||
| RecordLeaveInput, | |||
| } from "@/app/api/timesheets/actions"; | |||
| import { | |||
| DateCalendar, | |||
| LocalizationProvider, | |||
| PickersDay, | |||
| PickersDayProps, | |||
| } from "@mui/x-date-pickers"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import dayjs, { Dayjs } from "dayjs"; | |||
| import "dayjs/locale/zh-hk"; | |||
| import timezone from "dayjs/plugin/timezone"; | |||
| import utc from "dayjs/plugin/utc"; | |||
| import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| dayjs.extend(utc); | |||
| dayjs.extend(timezone); | |||
| dayjs.tz.guess(); | |||
| export interface Props { | |||
| timesheet: RecordTimesheetInput; | |||
| leaves: RecordLeaveInput; | |||
| onDateSelect: (date: string) => void; | |||
| } | |||
| const getColor = ( | |||
| hasTimeInput: boolean, | |||
| hasLeave: boolean, | |||
| ): string | undefined => { | |||
| if (hasTimeInput && hasLeave) { | |||
| return "success.light"; | |||
| } else if (hasTimeInput) { | |||
| return "info.light"; | |||
| } else if (hasLeave) { | |||
| return "warning.light"; | |||
| } else { | |||
| return undefined; | |||
| } | |||
| }; | |||
| const EntryDay: React.FC<PickersDayProps<Dayjs> & Props> = ({ | |||
| timesheet, | |||
| leaves, | |||
| ...pickerProps | |||
| }) => { | |||
| const timesheetDays = Object.keys(timesheet); | |||
| const leaveDays = Object.keys(leaves); | |||
| const hasTimesheetInput = timesheetDays.some((day) => | |||
| dayjs(day).isSame(pickerProps.day, "day"), | |||
| ); | |||
| const hasLeaveInput = leaveDays.some((day) => | |||
| dayjs(day).isSame(pickerProps.day, "day"), | |||
| ); | |||
| return ( | |||
| <PickersDay | |||
| {...pickerProps} | |||
| disabled={!(hasTimesheetInput || hasLeaveInput)} | |||
| sx={{ backgroundColor: getColor(hasTimesheetInput, hasLeaveInput) }} | |||
| /> | |||
| ); | |||
| }; | |||
| const PastEntryCalendar: React.FC<Props> = ({ | |||
| timesheet, | |||
| leaves, | |||
| onDateSelect, | |||
| }) => { | |||
| const { | |||
| i18n: { language }, | |||
| } = useTranslation("home"); | |||
| const onChange = (day: Dayjs) => { | |||
| onDateSelect(day.format(INPUT_DATE_FORMAT)); | |||
| }; | |||
| return ( | |||
| <LocalizationProvider | |||
| dateAdapter={AdapterDayjs} | |||
| adapterLocale={`${language}-hk`} | |||
| > | |||
| <DateCalendar | |||
| onChange={onChange} | |||
| disableFuture | |||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| slots={{ day: EntryDay as any }} | |||
| slotProps={{ | |||
| day: { | |||
| timesheet, | |||
| leaves, | |||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| } as any, | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| ); | |||
| }; | |||
| export default PastEntryCalendar; | |||
| @@ -0,0 +1,99 @@ | |||
| import { | |||
| Box, | |||
| Button, | |||
| Dialog, | |||
| DialogActions, | |||
| DialogContent, | |||
| DialogTitle, | |||
| Stack, | |||
| Typography, | |||
| styled, | |||
| } from "@mui/material"; | |||
| import PastEntryCalendar, { | |||
| Props as PastEntryCalendarProps, | |||
| } from "./PastEntryCalendar"; | |||
| import { useCallback, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { ArrowBack } from "@mui/icons-material"; | |||
| interface Props extends Omit<PastEntryCalendarProps, "onDateSelect"> { | |||
| open: boolean; | |||
| handleClose: () => void; | |||
| } | |||
| const Indicator = styled(Box)(() => ({ | |||
| borderRadius: "50%", | |||
| width: "1rem", | |||
| height: "1rem", | |||
| })); | |||
| const PastEntryCalendarModal: React.FC<Props> = ({ | |||
| handleClose, | |||
| open, | |||
| timesheet, | |||
| leaves, | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| const [selectedDate, setSelectedDate] = useState(""); | |||
| const clearDate = useCallback(() => { | |||
| setSelectedDate(""); | |||
| }, []); | |||
| const onClose = useCallback(() => { | |||
| handleClose(); | |||
| }, [handleClose]); | |||
| return ( | |||
| <Dialog onClose={onClose} open={open}> | |||
| <DialogTitle>{t("Past Entries")}</DialogTitle> | |||
| <DialogContent> | |||
| {selectedDate ? ( | |||
| <Box>{selectedDate}</Box> | |||
| ) : ( | |||
| <Box> | |||
| <Stack> | |||
| <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} | |||
| /> | |||
| </Box> | |||
| )} | |||
| </DialogContent> | |||
| {selectedDate && ( | |||
| <DialogActions> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<ArrowBack />} | |||
| onClick={clearDate} | |||
| > | |||
| {t("Back")} | |||
| </Button> | |||
| </DialogActions> | |||
| )} | |||
| </Dialog> | |||
| ); | |||
| }; | |||
| export default PastEntryCalendarModal; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./PastEntryCalendar"; | |||
| @@ -28,6 +28,10 @@ const StyledDataGrid = styled(DataGrid)(({ theme }) => ({ | |||
| borderRadius: 0, | |||
| maxHeight: 50, | |||
| }, | |||
| "& .MuiAutocomplete-root .MuiFilledInput-root": { | |||
| borderRadius: 0, | |||
| maxHeight: 50, | |||
| }, | |||
| })); | |||
| export default StyledDataGrid; | |||
| @@ -17,6 +17,7 @@ import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||
| import TimesheetEditModal, { | |||
| Props as TimesheetEditModalProps, | |||
| } from "./TimesheetEditModal"; | |||
| import TimeEntryCard from "./TimeEntryCard"; | |||
| interface Props { | |||
| date: string; | |||
| @@ -119,91 +120,13 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||
| const task = project?.tasks.find((t) => t.id === entry.taskId); | |||
| return ( | |||
| <Card | |||
| <TimeEntryCard | |||
| key={`${entry.id}-${index}`} | |||
| sx={{ marginInline: 1, overflow: "visible" }} | |||
| > | |||
| <CardContent | |||
| sx={{ | |||
| padding: 2, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| gap: 2, | |||
| "&:last-child": { | |||
| paddingBottom: 2, | |||
| }, | |||
| }} | |||
| > | |||
| <Box | |||
| display="flex" | |||
| justifyContent="space-between" | |||
| alignItems="flex-start" | |||
| gap={2} | |||
| > | |||
| <Box> | |||
| <Typography | |||
| variant="body2" | |||
| component="div" | |||
| fontWeight="bold" | |||
| > | |||
| {project | |||
| ? `${project.code} - ${project.name}` | |||
| : t("Non-billable Task")} | |||
| </Typography> | |||
| {task && ( | |||
| <Typography variant="body2" component="div"> | |||
| {task.name} | |||
| </Typography> | |||
| )} | |||
| </Box> | |||
| <IconButton | |||
| size="small" | |||
| color="primary" | |||
| onClick={openEditModal(entry)} | |||
| > | |||
| <Edit /> | |||
| </IconButton> | |||
| </Box> | |||
| <Box display="flex" gap={2}> | |||
| <Box> | |||
| <Typography | |||
| variant="body2" | |||
| component="div" | |||
| fontWeight="bold" | |||
| > | |||
| {t("Hours")} | |||
| </Typography> | |||
| <Typography component="p"> | |||
| {manhourFormatter.format(entry.inputHours || 0)} | |||
| </Typography> | |||
| </Box> | |||
| <Box> | |||
| <Typography | |||
| variant="body2" | |||
| component="div" | |||
| fontWeight="bold" | |||
| > | |||
| {t("Other Hours")} | |||
| </Typography> | |||
| <Typography component="p"> | |||
| {manhourFormatter.format(entry.otHours || 0)} | |||
| </Typography> | |||
| </Box> | |||
| </Box> | |||
| {entry.remark && ( | |||
| <Box> | |||
| <Typography | |||
| variant="body2" | |||
| component="div" | |||
| fontWeight="bold" | |||
| > | |||
| {t("Remark")} | |||
| </Typography> | |||
| <Typography component="p">{entry.remark}</Typography> | |||
| </Box> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| project={project} | |||
| task={task} | |||
| entry={entry} | |||
| onEdit={openEditModal(entry)} | |||
| /> | |||
| ); | |||
| }) | |||
| ) : ( | |||
| @@ -10,6 +10,7 @@ import { | |||
| import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import differenceBy from "lodash/differenceBy"; | |||
| import { TFunction } from "i18next"; | |||
| interface Props { | |||
| allProjects: ProjectWithTasks[]; | |||
| @@ -18,119 +19,159 @@ interface Props { | |||
| onProjectSelect: (projectId: number | string) => void; | |||
| } | |||
| // const AutocompleteProjectSelect: React.FC<Props> = ({ | |||
| // allProjects, | |||
| // assignedProjects, | |||
| // value, | |||
| // onProjectSelect, | |||
| // }) => { | |||
| // const { t } = useTranslation("home"); | |||
| // const nonAssignedProjects = useMemo(() => { | |||
| // return differenceBy(allProjects, assignedProjects, "id"); | |||
| // }, [allProjects, assignedProjects]); | |||
| // const options = useMemo(() => { | |||
| // return [ | |||
| // { | |||
| // value: "", | |||
| // label: t("None"), | |||
| // group: "non-billable", | |||
| // }, | |||
| // ...assignedProjects.map((p) => ({ | |||
| // value: p.id, | |||
| // label: `${p.code} - ${p.name}`, | |||
| // group: "assigned", | |||
| // })), | |||
| // ...nonAssignedProjects.map((p) => ({ | |||
| // value: p.id, | |||
| // label: `${p.code} - ${p.name}`, | |||
| // group: "non-assigned", | |||
| // })), | |||
| // ]; | |||
| // }, [assignedProjects, nonAssignedProjects, t]); | |||
| // return ( | |||
| // <Autocomplete | |||
| // disableClearable | |||
| // fullWidth | |||
| // groupBy={(option) => option.group} | |||
| // getOptionLabel={(option) => option.label} | |||
| // options={options} | |||
| // renderInput={(params) => <TextField {...params} />} | |||
| // /> | |||
| // ); | |||
| // }; | |||
| const getGroupName = (t: TFunction, groupName: string): string => { | |||
| switch (groupName) { | |||
| case "non-billable": | |||
| return t("Non-billable"); | |||
| case "assigned": | |||
| return t("Assigned Projects"); | |||
| case "non-assigned": | |||
| return t("Non-assigned Projects"); | |||
| default: | |||
| return t("Ungrouped"); | |||
| } | |||
| }; | |||
| const ProjectSelect: React.FC<Props> = ({ | |||
| const AutocompleteProjectSelect: React.FC<Props> = ({ | |||
| allProjects, | |||
| assignedProjects, | |||
| value, | |||
| onProjectSelect, | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| const nonAssignedProjects = useMemo(() => { | |||
| return differenceBy(allProjects, assignedProjects, "id"); | |||
| }, [allProjects, assignedProjects]); | |||
| const options = useMemo(() => { | |||
| return [ | |||
| { | |||
| value: "", | |||
| label: t("None"), | |||
| group: "non-billable", | |||
| }, | |||
| ...assignedProjects.map((p) => ({ | |||
| value: p.id, | |||
| label: `${p.code} - ${p.name}`, | |||
| group: "assigned", | |||
| })), | |||
| ...nonAssignedProjects.map((p) => ({ | |||
| value: p.id, | |||
| label: `${p.code} - ${p.name}`, | |||
| group: "non-assigned", | |||
| })), | |||
| ]; | |||
| }, [assignedProjects, nonAssignedProjects, t]); | |||
| const currentValue = options.find((o) => o.value === value) || options[0]; | |||
| const onChange = useCallback( | |||
| (event: SelectChangeEvent<number>) => { | |||
| const newValue = event.target.value; | |||
| onProjectSelect(newValue); | |||
| (event: React.SyntheticEvent, newValue: { value: number | string }) => { | |||
| onProjectSelect(newValue.value); | |||
| }, | |||
| [onProjectSelect], | |||
| ); | |||
| return ( | |||
| <Select | |||
| displayEmpty | |||
| value={value || ""} | |||
| <Autocomplete | |||
| noOptionsText={t("No projects")} | |||
| disableClearable | |||
| fullWidth | |||
| value={currentValue} | |||
| onChange={onChange} | |||
| sx={{ width: "100%" }} | |||
| MenuProps={{ | |||
| slotProps: { | |||
| paper: { | |||
| sx: { maxHeight: 400 }, | |||
| }, | |||
| }, | |||
| anchorOrigin: { | |||
| vertical: "bottom", | |||
| horizontal: "left", | |||
| }, | |||
| transformOrigin: { | |||
| vertical: "top", | |||
| horizontal: "left", | |||
| }, | |||
| groupBy={(option) => option.group} | |||
| getOptionLabel={(option) => option.label} | |||
| options={options} | |||
| renderGroup={(params) => ( | |||
| <> | |||
| <ListSubheader key={params.key}> | |||
| {getGroupName(t, params.group)} | |||
| </ListSubheader> | |||
| {params.children} | |||
| </> | |||
| )} | |||
| renderOption={(params, option) => { | |||
| return ( | |||
| <MenuItem {...params} key={option.value} value={option.value}> | |||
| {option.label} | |||
| </MenuItem> | |||
| ); | |||
| }} | |||
| > | |||
| <ListSubheader>{t("Non-billable")}</ListSubheader> | |||
| <MenuItem value={""}>{t("None")}</MenuItem> | |||
| {assignedProjects.length > 0 && [ | |||
| <ListSubheader key="assignedProjectsSubHeader"> | |||
| {t("Assigned Projects")} | |||
| </ListSubheader>, | |||
| ...assignedProjects.map((project) => ( | |||
| <MenuItem | |||
| key={project.id} | |||
| value={project.id} | |||
| sx={{ whiteSpace: "wrap" }} | |||
| >{`${project.code} - ${project.name}`}</MenuItem> | |||
| )), | |||
| ]} | |||
| {nonAssignedProjects.length > 0 && [ | |||
| <ListSubheader key="nonAssignedProjectsSubHeader"> | |||
| {t("Non-assigned Projects")} | |||
| </ListSubheader>, | |||
| ...nonAssignedProjects.map((project) => ( | |||
| <MenuItem | |||
| key={project.id} | |||
| value={project.id} | |||
| sx={{ whiteSpace: "wrap" }} | |||
| >{`${project.code} - ${project.name}`}</MenuItem> | |||
| )), | |||
| ]} | |||
| </Select> | |||
| renderInput={(params) => <TextField {...params} />} | |||
| /> | |||
| ); | |||
| }; | |||
| export default ProjectSelect; | |||
| // const ProjectSelect: React.FC<Props> = ({ | |||
| // allProjects, | |||
| // assignedProjects, | |||
| // value, | |||
| // onProjectSelect, | |||
| // }) => { | |||
| // const { t } = useTranslation("home"); | |||
| // const nonAssignedProjects = useMemo(() => { | |||
| // return differenceBy(allProjects, assignedProjects, "id"); | |||
| // }, [allProjects, assignedProjects]); | |||
| // const onChange = useCallback( | |||
| // (event: SelectChangeEvent<number>) => { | |||
| // const newValue = event.target.value; | |||
| // onProjectSelect(newValue); | |||
| // }, | |||
| // [onProjectSelect], | |||
| // ); | |||
| // return ( | |||
| // <Select | |||
| // displayEmpty | |||
| // value={value || ""} | |||
| // onChange={onChange} | |||
| // sx={{ width: "100%" }} | |||
| // MenuProps={{ | |||
| // slotProps: { | |||
| // paper: { | |||
| // sx: { maxHeight: 400 }, | |||
| // }, | |||
| // }, | |||
| // anchorOrigin: { | |||
| // vertical: "bottom", | |||
| // horizontal: "left", | |||
| // }, | |||
| // transformOrigin: { | |||
| // vertical: "top", | |||
| // horizontal: "left", | |||
| // }, | |||
| // }} | |||
| // > | |||
| // <ListSubheader>{t("Non-billable")}</ListSubheader> | |||
| // <MenuItem value={""}>{t("None")}</MenuItem> | |||
| // {assignedProjects.length > 0 && [ | |||
| // <ListSubheader key="assignedProjectsSubHeader"> | |||
| // {t("Assigned Projects")} | |||
| // </ListSubheader>, | |||
| // ...assignedProjects.map((project) => ( | |||
| // <MenuItem | |||
| // key={project.id} | |||
| // value={project.id} | |||
| // sx={{ whiteSpace: "wrap" }} | |||
| // >{`${project.code} - ${project.name}`}</MenuItem> | |||
| // )), | |||
| // ]} | |||
| // {nonAssignedProjects.length > 0 && [ | |||
| // <ListSubheader key="nonAssignedProjectsSubHeader"> | |||
| // {t("Non-assigned Projects")} | |||
| // </ListSubheader>, | |||
| // ...nonAssignedProjects.map((project) => ( | |||
| // <MenuItem | |||
| // key={project.id} | |||
| // value={project.id} | |||
| // sx={{ whiteSpace: "wrap" }} | |||
| // >{`${project.code} - ${project.name}`}</MenuItem> | |||
| // )), | |||
| // ]} | |||
| // </Select> | |||
| // ); | |||
| // }; | |||
| export default AutocompleteProjectSelect; | |||
| @@ -0,0 +1,87 @@ | |||
| import { ProjectWithTasks } from "@/app/api/projects"; | |||
| import { Task } from "@/app/api/tasks"; | |||
| import { TimeEntry } from "@/app/api/timesheets/actions"; | |||
| import { manhourFormatter } from "@/app/utils/formatUtil"; | |||
| import { Edit } from "@mui/icons-material"; | |||
| import { Box, Card, CardContent, IconButton, Typography } from "@mui/material"; | |||
| import React from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| interface Props { | |||
| project?: ProjectWithTasks; | |||
| task?: Task; | |||
| entry: TimeEntry; | |||
| onEdit?: () => void; | |||
| } | |||
| const TimeEntryCard: React.FC<Props> = ({ project, task, entry, onEdit }) => { | |||
| const { t } = useTranslation("home"); | |||
| return ( | |||
| <Card sx={{ marginInline: 1, overflow: "visible" }}> | |||
| <CardContent | |||
| sx={{ | |||
| padding: 2, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| gap: 2, | |||
| "&:last-child": { | |||
| paddingBottom: 2, | |||
| }, | |||
| }} | |||
| > | |||
| <Box | |||
| display="flex" | |||
| justifyContent="space-between" | |||
| alignItems="flex-start" | |||
| gap={2} | |||
| > | |||
| <Box> | |||
| <Typography variant="body2" component="div" fontWeight="bold"> | |||
| {project | |||
| ? `${project.code} - ${project.name}` | |||
| : t("Non-billable Task")} | |||
| </Typography> | |||
| {task && ( | |||
| <Typography variant="body2" component="div"> | |||
| {task.name} | |||
| </Typography> | |||
| )} | |||
| </Box> | |||
| {onEdit && ( | |||
| <IconButton size="small" color="primary" onClick={onEdit}> | |||
| <Edit /> | |||
| </IconButton> | |||
| )} | |||
| </Box> | |||
| <Box display="flex" gap={2}> | |||
| <Box> | |||
| <Typography variant="body2" component="div" fontWeight="bold"> | |||
| {t("Hours")} | |||
| </Typography> | |||
| <Typography component="p"> | |||
| {manhourFormatter.format(entry.inputHours || 0)} | |||
| </Typography> | |||
| </Box> | |||
| <Box> | |||
| <Typography variant="body2" component="div" fontWeight="bold"> | |||
| {t("Other Hours")} | |||
| </Typography> | |||
| <Typography component="p"> | |||
| {manhourFormatter.format(entry.otHours || 0)} | |||
| </Typography> | |||
| </Box> | |||
| </Box> | |||
| {entry.remark && ( | |||
| <Box> | |||
| <Typography variant="body2" component="div" fontWeight="bold"> | |||
| {t("Remark")} | |||
| </Typography> | |||
| <Typography component="p">{entry.remark}</Typography> | |||
| </Box> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default TimeEntryCard; | |||
| @@ -56,9 +56,6 @@ const ProjectGrid: React.FC<Props> = ({ projects }) => { | |||
| )})`}</Typography> | |||
| </Box> | |||
| {/* Hours Allocated */} | |||
| <Typography variant="subtitle2" sx={{ marginBlockStart: 2 }}> | |||
| {t("Hours Allocated:")} | |||
| </Typography> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| @@ -66,23 +63,13 @@ const ProjectGrid: React.FC<Props> = ({ projects }) => { | |||
| alignItems: "baseline", | |||
| }} | |||
| > | |||
| <Typography variant="caption">{t("Normal")}</Typography> | |||
| <Typography variant="subtitle2" sx={{ marginBlockStart: 2 }}> | |||
| {t("Hours Allocated:")} | |||
| </Typography> | |||
| <Typography> | |||
| {manhourFormatter.format(project.hoursAllocated)} | |||
| </Typography> | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| alignItems: "baseline", | |||
| }} | |||
| > | |||
| <Typography variant="caption">{t("(Others)")}</Typography> | |||
| <Typography>{`(${manhourFormatter.format( | |||
| project.hoursAllocatedOther, | |||
| )})`}</Typography> | |||
| </Box> | |||
| </CardContent> | |||
| </Card> | |||
| </Grid> | |||
| @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; | |||
| import Button from "@mui/material/Button"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import { Add } from "@mui/icons-material"; | |||
| import { Typography } from "@mui/material"; | |||
| import { Box, Typography } from "@mui/material"; | |||
| import ButtonGroup from "@mui/material/ButtonGroup"; | |||
| import AssignedProjects from "./AssignedProjects"; | |||
| import TimesheetModal from "../TimesheetModal"; | |||
| @@ -16,6 +16,8 @@ import { | |||
| } from "@/app/api/timesheets/actions"; | |||
| import LeaveModal from "../LeaveModal"; | |||
| import { LeaveType } from "@/app/api/timesheets"; | |||
| import { CalendarIcon } from "@mui/x-date-pickers"; | |||
| import PastEntryCalendarModal from "../PastEntryCalendar/PastEntryCalendarModal"; | |||
| export interface Props { | |||
| leaveTypes: LeaveType[]; | |||
| @@ -36,6 +38,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| }) => { | |||
| const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false); | |||
| const [isLeaveModalVisible, setLeaveModalVisible] = useState(false); | |||
| const [isPastEventModalVisible, setPastEventModalVisible] = useState(false); | |||
| const { t } = useTranslation("home"); | |||
| const handleAddTimesheetButtonClick = useCallback(() => { | |||
| @@ -54,6 +57,14 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| setLeaveModalVisible(false); | |||
| }, []); | |||
| const handlePastEventClick = useCallback(() => { | |||
| setPastEventModalVisible(true); | |||
| }, []); | |||
| const handlePastEventClose = useCallback(() => { | |||
| setPastEventModalVisible(false); | |||
| }, []); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| @@ -65,12 +76,14 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("User Workspace")} | |||
| </Typography> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="right" | |||
| flexWrap="wrap" | |||
| spacing={2} | |||
| > | |||
| <Box display="flex" flexWrap="wrap" gap={2}> | |||
| <Button | |||
| startIcon={<CalendarIcon />} | |||
| variant="outlined" | |||
| onClick={handlePastEventClick} | |||
| > | |||
| {t("View Past Entries")} | |||
| </Button> | |||
| <ButtonGroup variant="contained"> | |||
| <Button startIcon={<Add />} onClick={handleAddTimesheetButtonClick}> | |||
| {t("Enter Time")} | |||
| @@ -79,8 +92,14 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| {t("Record Leave")} | |||
| </Button> | |||
| </ButtonGroup> | |||
| </Stack> | |||
| </Box> | |||
| </Stack> | |||
| <PastEntryCalendarModal | |||
| open={isPastEventModalVisible} | |||
| handleClose={handlePastEventClose} | |||
| timesheet={defaultTimesheets} | |||
| leaves={defaultLeaveRecords} | |||
| /> | |||
| <TimesheetModal | |||
| isOpen={isTimeheetModalVisible} | |||
| onClose={handleCloseTimesheetModal} | |||
| @@ -172,6 +172,16 @@ const components: ThemeOptions["components"] = { | |||
| }, | |||
| }, | |||
| }, | |||
| MuiAutocomplete: { | |||
| styleOverrides: { | |||
| root: { | |||
| "& .MuiFilledInput-root": { | |||
| paddingTop: 8, | |||
| paddingBottom: 8, | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiFilledInput: { | |||
| styleOverrides: { | |||
| root: { | |||