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