| @@ -4,7 +4,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { ProjectResult } from "../projects"; | |||
| import { Task, TaskGroup } from "../tasks"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidateTag } from "next/cache"; | |||
| import { revalidatePath, revalidateTag } from "next/cache"; | |||
| export interface TimeEntry { | |||
| id: number; | |||
| @@ -67,10 +67,18 @@ export const saveLeave = async (data: RecordLeaveInput, username: string) => { | |||
| export const saveMemberEntry = async (data: { | |||
| staffId: number; | |||
| entry: TimeEntry; | |||
| recordDate?: string; | |||
| }) => { | |||
| return serverFetchJson<TimeEntry>(`${BASE_API_URL}/timesheets/saveMemberEntry`, { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| return serverFetchJson<RecordTimesheetInput>( | |||
| `${BASE_API_URL}/timesheets/saveMemberEntry`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }, | |||
| ); | |||
| }; | |||
| export const revalidateCacheAfterAmendment = () => { | |||
| revalidatePath("/(main)/home"); | |||
| }; | |||
| @@ -1,8 +1,9 @@ | |||
| import React, { useCallback, useMemo, useState } from "react"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { HolidaysResult } from "@/app/api/holidays"; | |||
| import { TeamTimeSheets } from "@/app/api/timesheets"; | |||
| import dayGridPlugin from "@fullcalendar/daygrid"; | |||
| import interactionPlugin from "@fullcalendar/interaction"; | |||
| import { Autocomplete, Stack, TextField, useTheme } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import transform from "lodash/transform"; | |||
| @@ -29,6 +30,7 @@ type MemberOption = TeamTimeSheets[0] & { id: string }; | |||
| interface EventClickArg { | |||
| event: { | |||
| start: Date | null; | |||
| startStr: string; | |||
| extendedProps: { | |||
| calendar?: string; | |||
| entry?: TimeEntry; | |||
| @@ -54,10 +56,14 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||
| }, {}); | |||
| }, [allProjects]); | |||
| // Use a local state to manage updates after a mutation | |||
| const [localTeamTimesheets, setLocalTeamTimesheets] = | |||
| useState(teamTimesheets); | |||
| // member select | |||
| const allMembers = useMemo(() => { | |||
| return transform<TeamTimeSheets[0], MemberOption[]>( | |||
| teamTimesheets, | |||
| localTeamTimesheets, | |||
| (acc, memberTimesheet, id) => { | |||
| return acc.push({ | |||
| ...memberTimesheet, | |||
| @@ -66,10 +72,17 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||
| }, | |||
| [], | |||
| ); | |||
| }, [teamTimesheets]); | |||
| }, [localTeamTimesheets]); | |||
| const [selectedStaff, setSelectedStaff] = useState<MemberOption>( | |||
| allMembers[0], | |||
| ); | |||
| useEffect(() => { | |||
| setSelectedStaff( | |||
| (currentStaff) => | |||
| allMembers.find((member) => member.id === currentStaff.id) || | |||
| allMembers[0], | |||
| ); | |||
| }, [allMembers]); | |||
| // edit modal related | |||
| const [editModalProps, setEditModalProps] = useState< | |||
| @@ -77,12 +90,16 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||
| >({}); | |||
| const [editModalOpen, setEditModalOpen] = useState(false); | |||
| const openEditModal = useCallback((defaultValues?: TimeEntry) => { | |||
| setEditModalProps({ | |||
| defaultValues, | |||
| }); | |||
| setEditModalOpen(true); | |||
| }, []); | |||
| const openEditModal = useCallback( | |||
| (defaultValues?: TimeEntry, recordDate?: string) => { | |||
| setEditModalProps({ | |||
| defaultValues, | |||
| recordDate, | |||
| }); | |||
| setEditModalOpen(true); | |||
| }, | |||
| [], | |||
| ); | |||
| const closeEditModal = useCallback(() => { | |||
| setEditModalOpen(false); | |||
| @@ -138,17 +155,35 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||
| event.extendedProps.calendar === "timeEntry" && | |||
| event.extendedProps.entry | |||
| ) { | |||
| openEditModal(event.extendedProps.entry); | |||
| openEditModal(event.extendedProps.entry, event.startStr); | |||
| } | |||
| }, | |||
| [openEditModal], | |||
| ); | |||
| const handleDateClick = useCallback( | |||
| (e: { dateStr: string }) => { | |||
| openEditModal(undefined, e.dateStr); | |||
| }, | |||
| [openEditModal], | |||
| ); | |||
| const handleSave = useCallback( | |||
| async (timeEntry: TimeEntry) => { | |||
| async (timeEntry: TimeEntry, recordDate?: string) => { | |||
| // TODO: should be fine, but can handle parse error | |||
| const intStaffId = parseInt(selectedStaff.id); | |||
| await saveMemberEntry({ staffId: intStaffId, entry: timeEntry }); | |||
| const newMemberTimesheets = await saveMemberEntry({ | |||
| staffId: intStaffId, | |||
| entry: timeEntry, | |||
| recordDate, | |||
| }); | |||
| setLocalTeamTimesheets((timesheets) => ({ | |||
| ...timesheets, | |||
| [intStaffId]: { | |||
| ...timesheets[intStaffId], | |||
| timeEntries: newMemberTimesheets, | |||
| }, | |||
| })); | |||
| setEditModalOpen(false); | |||
| }, | |||
| [selectedStaff.id], | |||
| @@ -169,11 +204,12 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||
| renderInput={(params) => <TextField {...params} />} | |||
| /> | |||
| <StyledFullCalendar | |||
| plugins={[dayGridPlugin]} | |||
| plugins={[dayGridPlugin, interactionPlugin]} | |||
| initialView="dayGridMonth" | |||
| buttonText={{ today: t("Today") }} | |||
| events={[...holidays, ...timeEntries]} | |||
| eventClick={handleEventClick} | |||
| dateClick={handleDateClick} | |||
| /> | |||
| <TimesheetEditModal | |||
| modalSx={{ maxWidth: 400 }} | |||
| @@ -5,9 +5,6 @@ import { | |||
| Box, | |||
| Card, | |||
| CardContent, | |||
| Dialog, | |||
| DialogContent, | |||
| DialogTitle, | |||
| Modal, | |||
| SxProps, | |||
| Typography, | |||
| @@ -3,8 +3,6 @@ import { | |||
| Autocomplete, | |||
| ListSubheader, | |||
| MenuItem, | |||
| Select, | |||
| SelectChangeEvent, | |||
| TextField, | |||
| } from "@mui/material"; | |||
| import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||
| @@ -27,6 +25,8 @@ const getGroupName = (t: TFunction, groupName: string): string => { | |||
| return t("Assigned Projects"); | |||
| case "non-assigned": | |||
| return t("Non-assigned Projects"); | |||
| case "all-projects": | |||
| return t("All projects"); | |||
| default: | |||
| return t("Ungrouped"); | |||
| } | |||
| @@ -58,7 +58,7 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||
| ...nonAssignedProjects.map((p) => ({ | |||
| value: p.id, | |||
| label: `${p.code} - ${p.name}`, | |||
| group: "non-assigned", | |||
| group: assignedProjects.length === 0 ? "all-projects" : "non-assigned", | |||
| })), | |||
| ]; | |||
| }, [assignedProjects, nonAssignedProjects, t]); | |||
| @@ -10,6 +10,7 @@ import { | |||
| Paper, | |||
| SxProps, | |||
| TextField, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import React, { useCallback, useEffect, useMemo } from "react"; | |||
| import { Controller, useForm } from "react-hook-form"; | |||
| @@ -21,14 +22,16 @@ import TaskSelect from "./TaskSelect"; | |||
| import { TaskGroup } from "@/app/api/tasks"; | |||
| import uniqBy from "lodash/uniqBy"; | |||
| import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; | |||
| import { shortDateFormatter } from "@/app/utils/formatUtil"; | |||
| export interface Props extends Omit<ModalProps, "children"> { | |||
| onSave: (timeEntry: TimeEntry) => Promise<void>; | |||
| onSave: (timeEntry: TimeEntry, recordDate?: string) => Promise<void>; | |||
| onDelete?: () => void; | |||
| defaultValues?: Partial<TimeEntry>; | |||
| allProjects: ProjectWithTasks[]; | |||
| assignedProjects: AssignedProject[]; | |||
| modalSx?: SxProps; | |||
| recordDate?: string; | |||
| } | |||
| const modalSx: SxProps = { | |||
| @@ -52,8 +55,12 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||
| allProjects, | |||
| assignedProjects, | |||
| modalSx: mSx, | |||
| recordDate, | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| const { | |||
| t, | |||
| i18n: { language }, | |||
| } = useTranslation("home"); | |||
| const taskGroupsByProject = useMemo(() => { | |||
| return allProjects.reduce<{ | |||
| @@ -93,10 +100,10 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||
| const saveHandler = useCallback(async () => { | |||
| const valid = await trigger(); | |||
| if (valid) { | |||
| onSave(getValues()); | |||
| onSave(getValues(), recordDate); | |||
| reset({ id: Date.now() }); | |||
| } | |||
| }, [getValues, onSave, reset, trigger]); | |||
| }, [getValues, onSave, recordDate, reset, trigger]); | |||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | |||
| (...args) => { | |||
| @@ -113,6 +120,11 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||
| return ( | |||
| <Modal open={open} onClose={closeHandler}> | |||
| <Paper sx={{ ...modalSx, ...mSx }}> | |||
| {recordDate && ( | |||
| <Typography variant="h6" marginBlockEnd={2}> | |||
| {shortDateFormatter(language).format(new Date(recordDate))} | |||
| </Typography> | |||
| )} | |||
| <FormControl fullWidth> | |||
| <InputLabel shrink>{t("Project Code and Name")}</InputLabel> | |||
| <Controller | |||
| @@ -17,6 +17,7 @@ import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||
| import { | |||
| RecordLeaveInput, | |||
| RecordTimesheetInput, | |||
| revalidateCacheAfterAmendment, | |||
| } from "@/app/api/timesheets/actions"; | |||
| import LeaveModal from "../LeaveModal"; | |||
| import { LeaveType, TeamTimeSheets } from "@/app/api/timesheets"; | |||
| @@ -106,6 +107,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| const handleAmendmentClose = useCallback(() => { | |||
| setisTimesheetAmendmentVisible(false); | |||
| revalidateCacheAfterAmendment(); | |||
| }, []); | |||
| return ( | |||