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