| @@ -68,7 +68,6 @@ export interface AssignedProject extends ProjectWithTasks { | |||||
| hoursSpent: number; | hoursSpent: number; | ||||
| hoursSpentOther: number; | hoursSpentOther: number; | ||||
| hoursAllocated: number; | hoursAllocated: number; | ||||
| hoursAllocatedOther: number; | |||||
| } | } | ||||
| export const preloadProjects = () => { | 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 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 (dateArray.length === 6) { | ||||
| if (!needTime) { | 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 (dateArray.length === 3) { | ||||
| if (!needTime) { | 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) { | 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", { | const shortDateFormatter_en = new Intl.DateTimeFormat("en-HK", { | ||||
| weekday: "short", | 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 { | export function convertLocaleStringToNumber(numberString: string): number { | ||||
| const numberWithoutCommas = numberString.replace(/,/g, ""); | const numberWithoutCommas = numberString.replace(/,/g, ""); | ||||
| return parseFloat(numberWithoutCommas); | return parseFloat(numberWithoutCommas); | ||||
| @@ -91,6 +132,6 @@ export function timestampToDateString(timestamp: string): string { | |||||
| const year = date.getFullYear(); | const year = date.getFullYear(); | ||||
| const month = String(date.getMonth() + 1).padStart(2, "0"); | const month = String(date.getMonth() + 1).padStart(2, "0"); | ||||
| const day = String(date.getDate()).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}`; | return `${year}-${month}-${day}`; | ||||
| } | |||||
| } | |||||
| @@ -7,7 +7,7 @@ import MUILink from "@mui/material/Link"; | |||||
| import { usePathname } from "next/navigation"; | import { usePathname } from "next/navigation"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import Clock from "./Clock"; | import Clock from "./Clock"; | ||||
| import { Grid } from "@mui/material"; | |||||
| import { Box, Grid } from "@mui/material"; | |||||
| import { I18nProvider } from "@/i18n"; | import { I18nProvider } from "@/i18n"; | ||||
| const pathToLabelMap: { [path: string]: string } = { | const pathToLabelMap: { [path: string]: string } = { | ||||
| @@ -46,42 +46,43 @@ const Breadcrumb = () => { | |||||
| // const { t } = useTranslation("customer"); | // const { t } = useTranslation("customer"); | ||||
| return ( | 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; | export default Clock; | ||||
| @@ -58,8 +58,8 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
| const apiRef = useGridApiRef(); | const apiRef = useGridApiRef(); | ||||
| const addRow = useCallback(() => { | const addRow = useCallback(() => { | ||||
| // const id = Date.now(); | // 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 }]); | setPayments((p) => [...p, { id, _isNew: true }]); | ||||
| setRowModesModel((model) => ({ | setRowModesModel((model) => ({ | ||||
| ...model, | ...model, | ||||
| @@ -241,26 +241,30 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | ||||
| <Grid item xs> | <Grid item xs> | ||||
| <FormControl fullWidth> | <FormControl fullWidth> | ||||
| <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> | </FormControl> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs> | <Grid item xs> | ||||
| @@ -281,7 +285,11 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
| }} | }} | ||||
| slotProps={{ | slotProps={{ | ||||
| textField: { | 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, | borderRadius: 0, | ||||
| maxHeight: 50, | maxHeight: 50, | ||||
| }, | }, | ||||
| "& .MuiAutocomplete-root .MuiFilledInput-root": { | |||||
| borderRadius: 0, | |||||
| maxHeight: 50, | |||||
| }, | |||||
| })); | })); | ||||
| export default StyledDataGrid; | export default StyledDataGrid; | ||||
| @@ -17,6 +17,7 @@ import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||||
| import TimesheetEditModal, { | import TimesheetEditModal, { | ||||
| Props as TimesheetEditModalProps, | Props as TimesheetEditModalProps, | ||||
| } from "./TimesheetEditModal"; | } from "./TimesheetEditModal"; | ||||
| import TimeEntryCard from "./TimeEntryCard"; | |||||
| interface Props { | interface Props { | ||||
| date: string; | date: string; | ||||
| @@ -119,91 +120,13 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
| const task = project?.tasks.find((t) => t.id === entry.taskId); | const task = project?.tasks.find((t) => t.id === entry.taskId); | ||||
| return ( | return ( | ||||
| <Card | |||||
| <TimeEntryCard | |||||
| key={`${entry.id}-${index}`} | 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 { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import differenceBy from "lodash/differenceBy"; | import differenceBy from "lodash/differenceBy"; | ||||
| import { TFunction } from "i18next"; | |||||
| interface Props { | interface Props { | ||||
| allProjects: ProjectWithTasks[]; | allProjects: ProjectWithTasks[]; | ||||
| @@ -18,119 +19,159 @@ interface Props { | |||||
| onProjectSelect: (projectId: number | string) => void; | 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, | allProjects, | ||||
| assignedProjects, | assignedProjects, | ||||
| value, | value, | ||||
| onProjectSelect, | onProjectSelect, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| const nonAssignedProjects = useMemo(() => { | const nonAssignedProjects = useMemo(() => { | ||||
| return differenceBy(allProjects, assignedProjects, "id"); | return differenceBy(allProjects, assignedProjects, "id"); | ||||
| }, [allProjects, assignedProjects]); | }, [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( | const onChange = useCallback( | ||||
| (event: SelectChangeEvent<number>) => { | |||||
| const newValue = event.target.value; | |||||
| onProjectSelect(newValue); | |||||
| (event: React.SyntheticEvent, newValue: { value: number | string }) => { | |||||
| onProjectSelect(newValue.value); | |||||
| }, | }, | ||||
| [onProjectSelect], | [onProjectSelect], | ||||
| ); | ); | ||||
| return ( | return ( | ||||
| <Select | |||||
| displayEmpty | |||||
| value={value || ""} | |||||
| <Autocomplete | |||||
| noOptionsText={t("No projects")} | |||||
| disableClearable | |||||
| fullWidth | |||||
| value={currentValue} | |||||
| onChange={onChange} | 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> | )})`}</Typography> | ||||
| </Box> | </Box> | ||||
| {/* Hours Allocated */} | {/* Hours Allocated */} | ||||
| <Typography variant="subtitle2" sx={{ marginBlockStart: 2 }}> | |||||
| {t("Hours Allocated:")} | |||||
| </Typography> | |||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| display: "flex", | display: "flex", | ||||
| @@ -66,23 +63,13 @@ const ProjectGrid: React.FC<Props> = ({ projects }) => { | |||||
| alignItems: "baseline", | alignItems: "baseline", | ||||
| }} | }} | ||||
| > | > | ||||
| <Typography variant="caption">{t("Normal")}</Typography> | |||||
| <Typography variant="subtitle2" sx={{ marginBlockStart: 2 }}> | |||||
| {t("Hours Allocated:")} | |||||
| </Typography> | |||||
| <Typography> | <Typography> | ||||
| {manhourFormatter.format(project.hoursAllocated)} | {manhourFormatter.format(project.hoursAllocated)} | ||||
| </Typography> | </Typography> | ||||
| </Box> | </Box> | ||||
| <Box | |||||
| sx={{ | |||||
| display: "flex", | |||||
| justifyContent: "space-between", | |||||
| alignItems: "baseline", | |||||
| }} | |||||
| > | |||||
| <Typography variant="caption">{t("(Others)")}</Typography> | |||||
| <Typography>{`(${manhourFormatter.format( | |||||
| project.hoursAllocatedOther, | |||||
| )})`}</Typography> | |||||
| </Box> | |||||
| </CardContent> | </CardContent> | ||||
| </Card> | </Card> | ||||
| </Grid> | </Grid> | ||||
| @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; | |||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import { Add } from "@mui/icons-material"; | import { Add } from "@mui/icons-material"; | ||||
| import { Typography } from "@mui/material"; | |||||
| import { Box, Typography } from "@mui/material"; | |||||
| import ButtonGroup from "@mui/material/ButtonGroup"; | import ButtonGroup from "@mui/material/ButtonGroup"; | ||||
| import AssignedProjects from "./AssignedProjects"; | import AssignedProjects from "./AssignedProjects"; | ||||
| import TimesheetModal from "../TimesheetModal"; | import TimesheetModal from "../TimesheetModal"; | ||||
| @@ -16,6 +16,8 @@ import { | |||||
| } from "@/app/api/timesheets/actions"; | } from "@/app/api/timesheets/actions"; | ||||
| import LeaveModal from "../LeaveModal"; | import LeaveModal from "../LeaveModal"; | ||||
| import { LeaveType } from "@/app/api/timesheets"; | import { LeaveType } from "@/app/api/timesheets"; | ||||
| import { CalendarIcon } from "@mui/x-date-pickers"; | |||||
| import PastEntryCalendarModal from "../PastEntryCalendar/PastEntryCalendarModal"; | |||||
| export interface Props { | export interface Props { | ||||
| leaveTypes: LeaveType[]; | leaveTypes: LeaveType[]; | ||||
| @@ -36,6 +38,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| }) => { | }) => { | ||||
| const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false); | const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false); | ||||
| const [isLeaveModalVisible, setLeaveModalVisible] = useState(false); | const [isLeaveModalVisible, setLeaveModalVisible] = useState(false); | ||||
| const [isPastEventModalVisible, setPastEventModalVisible] = useState(false); | |||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| const handleAddTimesheetButtonClick = useCallback(() => { | const handleAddTimesheetButtonClick = useCallback(() => { | ||||
| @@ -54,6 +57,14 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| setLeaveModalVisible(false); | setLeaveModalVisible(false); | ||||
| }, []); | }, []); | ||||
| const handlePastEventClick = useCallback(() => { | |||||
| setPastEventModalVisible(true); | |||||
| }, []); | |||||
| const handlePastEventClose = useCallback(() => { | |||||
| setPastEventModalVisible(false); | |||||
| }, []); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Stack | <Stack | ||||
| @@ -65,12 +76,14 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| <Typography variant="h4" marginInlineEnd={2}> | <Typography variant="h4" marginInlineEnd={2}> | ||||
| {t("User Workspace")} | {t("User Workspace")} | ||||
| </Typography> | </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"> | <ButtonGroup variant="contained"> | ||||
| <Button startIcon={<Add />} onClick={handleAddTimesheetButtonClick}> | <Button startIcon={<Add />} onClick={handleAddTimesheetButtonClick}> | ||||
| {t("Enter Time")} | {t("Enter Time")} | ||||
| @@ -79,8 +92,14 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| {t("Record Leave")} | {t("Record Leave")} | ||||
| </Button> | </Button> | ||||
| </ButtonGroup> | </ButtonGroup> | ||||
| </Stack> | |||||
| </Box> | |||||
| </Stack> | </Stack> | ||||
| <PastEntryCalendarModal | |||||
| open={isPastEventModalVisible} | |||||
| handleClose={handlePastEventClose} | |||||
| timesheet={defaultTimesheets} | |||||
| leaves={defaultLeaveRecords} | |||||
| /> | |||||
| <TimesheetModal | <TimesheetModal | ||||
| isOpen={isTimeheetModalVisible} | isOpen={isTimeheetModalVisible} | ||||
| onClose={handleCloseTimesheetModal} | onClose={handleCloseTimesheetModal} | ||||
| @@ -172,6 +172,16 @@ const components: ThemeOptions["components"] = { | |||||
| }, | }, | ||||
| }, | }, | ||||
| }, | }, | ||||
| MuiAutocomplete: { | |||||
| styleOverrides: { | |||||
| root: { | |||||
| "& .MuiFilledInput-root": { | |||||
| paddingTop: 8, | |||||
| paddingBottom: 8, | |||||
| }, | |||||
| }, | |||||
| }, | |||||
| }, | |||||
| MuiFilledInput: { | MuiFilledInput: { | ||||
| styleOverrides: { | styleOverrides: { | ||||
| root: { | root: { | ||||