@@ -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: { | ||||