From 795b67d68636bedee83375b6b6e8dc40c2ff35c9 Mon Sep 17 00:00:00 2001 From: Wayne Date: Sat, 4 May 2024 17:49:02 +0900 Subject: [PATCH] Add timehseet API --- src/app/(main)/home/page.tsx | 11 +- src/app/(main)/layout.tsx | 8 +- src/app/api/projects/actions.ts | 36 +++-- src/app/api/timesheets/actions.ts | 22 +++ src/app/api/timesheets/index.ts | 10 ++ .../CreateProject/CreateProject.tsx | 152 ++++++++++++------ .../CreateProject/CreateProjectWrapper.tsx | 2 +- .../TimesheetModal/TimesheetModal.tsx | 51 ++++-- .../TimesheetTable/EntryInputTable.tsx | 13 +- .../UserWorkspacePage/AssignedProjects.tsx | 8 +- .../UserWorkspacePage/UserWorkspacePage.tsx | 12 +- .../UserWorkspaceWrapper.tsx | 21 ++- src/config/authConfig.ts | 7 +- 13 files changed, 251 insertions(+), 102 deletions(-) create mode 100644 src/app/api/timesheets/index.ts diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx index 176c9a2..155388f 100644 --- a/src/app/(main)/home/page.tsx +++ b/src/app/(main)/home/page.tsx @@ -1,15 +1,24 @@ import { Metadata } from "next"; import { I18nProvider } from "@/i18n"; import UserWorkspacePage from "@/components/UserWorkspacePage"; +import { fetchTimesheets } from "@/app/api/timesheets"; +import { authOptions } from "@/config/authConfig"; +import { getServerSession } from "next-auth"; export const metadata: Metadata = { title: "User Workspace", }; const Home: React.FC = async () => { + const session = await getServerSession(authOptions); + // Get name for caching + const username = session!.user!.name!; + + await fetchTimesheets(username); + return ( - + ); }; diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index cfaa1a9..b93ed10 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -31,10 +31,10 @@ export default async function MainLayout({ padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" }, }} > - - - {children} - + + + {children} + ); diff --git a/src/app/api/projects/actions.ts b/src/app/api/projects/actions.ts index e383e89..6bb5596 100644 --- a/src/app/api/projects/actions.ts +++ b/src/app/api/projects/actions.ts @@ -1,13 +1,16 @@ "use server"; -import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; +import { + serverFetchJson, + serverFetchWithNoContent, +} from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; import { Task, TaskGroup } from "../tasks"; import { Customer } from "../customer"; import { revalidateTag } from "next/cache"; export interface CreateProjectInputs { - // Project + // Project projectId: number | null; projectDeleted: boolean | null; projectCode: string; @@ -67,19 +70,22 @@ export interface PaymentInputs { } export interface CreateProjectResponse { - id: number, - name: string, - code: string, - category: string, - team: string, - client: string, + id: number; + name: string; + code: string; + category: string; + team: string; + client: string; } export const saveProject = async (data: CreateProjectInputs) => { - const newProject = await serverFetchJson(`${BASE_API_URL}/projects/new`, { - method: "POST", - body: JSON.stringify(data), - headers: { "Content-Type": "application/json" }, - }); + const newProject = await serverFetchJson( + `${BASE_API_URL}/projects/new`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); revalidateTag("projects"); return newProject; @@ -94,5 +100,7 @@ export const deleteProject = async (id: number) => { }, ); - return project + revalidateTag("projects"); + revalidateTag("assignedProjects"); + return project; }; diff --git a/src/app/api/timesheets/actions.ts b/src/app/api/timesheets/actions.ts index 6b959ec..631c076 100644 --- a/src/app/api/timesheets/actions.ts +++ b/src/app/api/timesheets/actions.ts @@ -1,9 +1,13 @@ "use server"; +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"; export interface TimeEntry { + id: number; projectId: ProjectResult["id"]; taskGroupId: TaskGroup["id"]; taskId: Task["id"]; @@ -13,3 +17,21 @@ export interface TimeEntry { export interface RecordTimesheetInput { [date: string]: TimeEntry[]; } + +export const saveTimesheet = async ( + data: RecordTimesheetInput, + username: string, +) => { + const savedRecords = await serverFetchJson( + `${BASE_API_URL}/timesheets/save`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); + + revalidateTag(`timesheets_${username}`); + + return savedRecords; +}; diff --git a/src/app/api/timesheets/index.ts b/src/app/api/timesheets/index.ts new file mode 100644 index 0000000..fd7d20d --- /dev/null +++ b/src/app/api/timesheets/index.ts @@ -0,0 +1,10 @@ +import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { cache } from "react"; +import { RecordTimesheetInput } from "./actions"; + +export const fetchTimesheets = cache(async (username: string) => { + return serverFetchJson(`${BASE_API_URL}/timesheets`, { + next: { tags: [`timesheets_${username}`] }, + }); +}); diff --git a/src/components/CreateProject/CreateProject.tsx b/src/components/CreateProject/CreateProject.tsx index fb57dd7..5511464 100644 --- a/src/components/CreateProject/CreateProject.tsx +++ b/src/components/CreateProject/CreateProject.tsx @@ -1,6 +1,6 @@ "use client"; -import DoneIcon from '@mui/icons-material/Done' +import DoneIcon from "@mui/icons-material/Done"; import Check from "@mui/icons-material/Check"; import Close from "@mui/icons-material/Close"; import Button from "@mui/material/Button"; @@ -22,7 +22,11 @@ import { SubmitHandler, useForm, } from "react-hook-form"; -import { CreateProjectInputs, deleteProject, saveProject } from "@/app/api/projects/actions"; +import { + CreateProjectInputs, + deleteProject, + saveProject, +} from "@/app/api/projects/actions"; import { Delete, Error, PlayArrow } from "@mui/icons-material"; import { BuildingType, @@ -38,7 +42,12 @@ import { Typography } from "@mui/material"; import { Grade } from "@/app/api/grades"; import { Customer, Subsidiary } from "@/app/api/customer"; import { isEmpty } from "lodash"; -import { deleteDialog, errorDialog, submitDialog, successDialog } from "../Swal/CustomAlerts"; +import { + deleteDialog, + errorDialog, + submitDialog, + successDialog, +} from "../Swal/CustomAlerts"; import dayjs from "dayjs"; export interface Props { @@ -103,16 +112,15 @@ const CreateProject: React.FC = ({ const handleDelete = () => { deleteDialog(async () => { - await deleteProject(formProps.getValues("projectId")!!) + await deleteProject(formProps.getValues("projectId")!); - const clickSuccessDialog = await successDialog("Delete Success", t) + const clickSuccessDialog = await successDialog("Delete Success", t); if (clickSuccessDialog) { router.replace("/projects"); } - - }, t) - } + }, t); + }; const handleTabChange = useCallback>( (_e, newValue) => { @@ -124,51 +132,55 @@ const CreateProject: React.FC = ({ const onSubmit = useCallback>( async (data, event) => { try { - console.log("first") + console.log("first"); setServerError(""); - let title = t("Do you want to submit?") - let confirmButtonText = t("Submit") - let successTitle = t("Submit Success") - let errorTitle = t("Submit Fail") - const buttonName = (event?.nativeEvent as any).submitter.name + let title = t("Do you want to submit?"); + let confirmButtonText = t("Submit"); + let successTitle = t("Submit Success"); + let errorTitle = t("Submit Fail"); + const buttonName = (event?.nativeEvent as any).submitter.name; if (buttonName === "start") { - title = t("Do you want to start?") - confirmButtonText = t("Start") - successTitle = t("Start Success") - errorTitle = t("Start Fail") + title = t("Do you want to start?"); + confirmButtonText = t("Start"); + successTitle = t("Start Success"); + errorTitle = t("Start Fail"); } else if (buttonName === "complete") { - title = t("Do you want to complete?") - confirmButtonText = t("Complete") - successTitle = t("Complete Success") - errorTitle = t("Complete Fail") + title = t("Do you want to complete?"); + confirmButtonText = t("Complete"); + successTitle = t("Complete Success"); + errorTitle = t("Complete Fail"); } - submitDialog(async () => { - if (buttonName === "start") { - data.projectActualStart = dayjs().format("YYYY-MM-DD") - } else if (buttonName === "complete") { - data.projectActualEnd = dayjs().format("YYYY-MM-DD") - } + submitDialog( + async () => { + if (buttonName === "start") { + data.projectActualStart = dayjs().format("YYYY-MM-DD"); + } else if (buttonName === "complete") { + data.projectActualEnd = dayjs().format("YYYY-MM-DD"); + } - const response = await saveProject(data); + const response = await saveProject(data); - if (response.id > 0) { - successDialog(successTitle, t).then(() => { - router.replace("/projects"); - }) - } else { - errorDialog(errorTitle, t).then(() => { - return false - }) - } - }, t, { title: title, confirmButtonText: confirmButtonText }) + if (response.id > 0) { + successDialog(successTitle, t).then(() => { + router.replace("/projects"); + }); + } else { + errorDialog(errorTitle, t).then(() => { + return false; + }); + } + }, + t, + { title: title, confirmButtonText: confirmButtonText }, + ); } catch (e) { setServerError(t("An error has occurred. Please try again later.")); } }, - [router, t, isEditMode], + [router, t], ); const onSubmitError = useCallback>( @@ -196,8 +208,8 @@ const CreateProject: React.FC = ({ // manhourPercentageByGrade should have a sensible default manhourPercentageByGrade: isEmpty(defaultInputs?.manhourPercentageByGrade) ? grades.reduce((acc, grade) => { - return { ...acc, [grade.id]: 1 / grades.length }; - }, {}) + return { ...acc, [grade.id]: 1 / grades.length }; + }, {}) : defaultInputs?.manhourPercentageByGrade, }, }); @@ -214,15 +226,42 @@ const CreateProject: React.FC = ({ > {isEditMode && !(formProps.getValues("projectDeleted") === true) && ( - {!formProps.getValues("projectActualStart") && } - {formProps.getValues("projectActualStart") && !formProps.getValues("projectActualEnd") && } - {!(formProps.getValues("projectActualStart") && formProps.getValues("projectActualEnd")) && } + {!formProps.getValues("projectActualStart") && ( + + )} + {formProps.getValues("projectActualStart") && + !formProps.getValues("projectActualEnd") && ( + + )} + {!( + formProps.getValues("projectActualStart") && + formProps.getValues("projectActualEnd") + ) && ( + + )} )} = ({ > {t("Cancel")} - diff --git a/src/components/CreateProject/CreateProjectWrapper.tsx b/src/components/CreateProject/CreateProjectWrapper.tsx index f380b2f..6ce6242 100644 --- a/src/components/CreateProject/CreateProjectWrapper.tsx +++ b/src/components/CreateProject/CreateProjectWrapper.tsx @@ -56,7 +56,7 @@ const CreateProjectWrapper: React.FC = async (props) => { ]); const projectInfo = props.isEditMode - ? await fetchProjectDetails(props.projectId!!) + ? await fetchProjectDetails(props.projectId!) : undefined; return ( diff --git a/src/components/TimesheetModal/TimesheetModal.tsx b/src/components/TimesheetModal/TimesheetModal.tsx index d6146b0..c336cef 100644 --- a/src/components/TimesheetModal/TimesheetModal.tsx +++ b/src/components/TimesheetModal/TimesheetModal.tsx @@ -12,8 +12,11 @@ import { import TimesheetTable from "../TimesheetTable"; import { useTranslation } from "react-i18next"; import { Check, Close } from "@mui/icons-material"; -import { FormProvider, useForm } from "react-hook-form"; -import { RecordTimesheetInput } from "@/app/api/timesheets/actions"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { + RecordTimesheetInput, + saveTimesheet, +} from "@/app/api/timesheets/actions"; import dayjs from "dayjs"; import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; import { AssignedProject } from "@/app/api/projects"; @@ -23,6 +26,8 @@ interface Props { onClose: () => void; timesheetType: "time" | "leave"; assignedProjects: AssignedProject[]; + username: string; + defaultTimesheets?: RecordTimesheetInput; } const modalSx: SxProps = { @@ -40,6 +45,8 @@ const TimesheetModal: React.FC = ({ onClose, timesheetType, assignedProjects, + username, + defaultTimesheets, }) => { const { t } = useTranslation("home"); @@ -48,15 +55,37 @@ const TimesheetModal: React.FC = ({ return Array(7) .fill(undefined) .reduce((acc, _, index) => { + const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); return { ...acc, - [today.subtract(index, "day").format(INPUT_DATE_FORMAT)]: [], + [date]: defaultTimesheets?.[date] ?? [], }; }, {}); - }, []); + }, [defaultTimesheets]); const formProps = useForm({ defaultValues }); + const onSubmit = useCallback>( + async (data) => { + const savedRecords = await saveTimesheet(data, username); + + const today = dayjs(); + const newFormValues = Array(7) + .fill(undefined) + .reduce((acc, _, index) => { + const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); + return { + ...acc, + [date]: savedRecords[date] ?? [], + }; + }, {}); + + formProps.reset(newFormValues); + onClose(); + }, + [formProps, onClose, username], + ); + const onCancel = useCallback(() => { formProps.reset(defaultValues); onClose(); @@ -66,7 +95,10 @@ const TimesheetModal: React.FC = ({ - + {t(timesheetType === "time" ? "Timesheet Input" : "Record Leave")} @@ -86,13 +118,8 @@ const TimesheetModal: React.FC = ({ > {t("Cancel")} - diff --git a/src/components/TimesheetTable/EntryInputTable.tsx b/src/components/TimesheetTable/EntryInputTable.tsx index 87eac77..2e704fe 100644 --- a/src/components/TimesheetTable/EntryInputTable.tsx +++ b/src/components/TimesheetTable/EntryInputTable.tsx @@ -36,7 +36,6 @@ type TimeEntryRow = Partial< _isNew: boolean; _error: string; isPlanned: boolean; - id: string; } >; @@ -74,21 +73,19 @@ const EntryInputTable: React.FC = ({ day, assignedProjects }) => { const { getValues, setValue } = useFormContext(); const currentEntries = getValues(day); - const [entries, setEntries] = useState( - currentEntries.map((e, index) => ({ ...e, id: `${day}-${index}` })) || [], - ); + const [entries, setEntries] = useState(currentEntries || []); const [rowModesModel, setRowModesModel] = useState({}); const apiRef = useGridApiRef(); const addRow = useCallback(() => { - const id = `${day}-${Date.now()}`; + const id = Date.now(); setEntries((e) => [...e, { id, _isNew: true }]); setRowModesModel((model) => ({ ...model, [id]: { mode: GridRowModes.Edit, fieldToFocus: "projectId" }, })); - }, [day]); + }, []); const validateRow = useCallback( (id: GridRowId) => { @@ -318,9 +315,11 @@ const EntryInputTable: React.FC = ({ day, assignedProjects }) => { e.inputHours && e.projectId && e.taskId && - e.taskGroupId, + e.taskGroupId && + e.id, ) .map((e) => ({ + id: e.id!, inputHours: e.inputHours!, projectId: e.projectId!, taskId: e.taskId!, diff --git a/src/components/UserWorkspacePage/AssignedProjects.tsx b/src/components/UserWorkspacePage/AssignedProjects.tsx index ccd088c..6baa133 100644 --- a/src/components/UserWorkspacePage/AssignedProjects.tsx +++ b/src/components/UserWorkspacePage/AssignedProjects.tsx @@ -14,9 +14,11 @@ import { Clear, Search } from "@mui/icons-material"; import ProjectGrid from "./ProjectGrid"; import { Props as UserWorkspaceProps } from "./UserWorkspacePage"; -const AssignedProjects: React.FC = ({ - assignedProjects, -}) => { +interface Props { + assignedProjects: UserWorkspaceProps["assignedProjects"]; +} + +const AssignedProjects: React.FC = ({ assignedProjects }) => { const { t } = useTranslation("home"); // Projects diff --git a/src/components/UserWorkspacePage/UserWorkspacePage.tsx b/src/components/UserWorkspacePage/UserWorkspacePage.tsx index fc0b233..e8d51d7 100644 --- a/src/components/UserWorkspacePage/UserWorkspacePage.tsx +++ b/src/components/UserWorkspacePage/UserWorkspacePage.tsx @@ -10,12 +10,19 @@ import ButtonGroup from "@mui/material/ButtonGroup"; import AssignedProjects from "./AssignedProjects"; import TimesheetModal from "../TimesheetModal"; import { AssignedProject } from "@/app/api/projects"; +import { RecordTimesheetInput } from "@/app/api/timesheets/actions"; export interface Props { assignedProjects: AssignedProject[]; + username: string; + defaultTimesheets: RecordTimesheetInput; } -const UserWorkspacePage: React.FC = ({ assignedProjects }) => { +const UserWorkspacePage: React.FC = ({ + assignedProjects, + username, + defaultTimesheets, +}) => { const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false); const [isLeaveModalVisible, setLeaveModalVisible] = useState(false); const { t } = useTranslation("home"); @@ -75,12 +82,15 @@ const UserWorkspacePage: React.FC = ({ assignedProjects }) => { isOpen={isTimeheetModalVisible} onClose={handleCloseTimesheetModal} assignedProjects={assignedProjects} + username={username} + defaultTimesheets={defaultTimesheets} /> diff --git a/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx b/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx index c311488..cd5fe66 100644 --- a/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx +++ b/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx @@ -1,9 +1,24 @@ import { fetchAssignedProjects } from "@/app/api/projects"; import UserWorkspacePage from "./UserWorkspacePage"; +import { fetchTimesheets } from "@/app/api/timesheets"; -const UserWorkspaceWrapper: React.FC = async () => { - const assignedProjects = await fetchAssignedProjects(); - return ; +interface Props { + username: string; +} + +const UserWorkspaceWrapper: React.FC = async ({ username }) => { + const [assignedProjects, timesheets] = await Promise.all([ + fetchAssignedProjects(), + fetchTimesheets(username), + ]); + + return ( + + ); }; export default UserWorkspaceWrapper; diff --git a/src/config/authConfig.ts b/src/config/authConfig.ts index e10f60d..2c2b9da 100644 --- a/src/config/authConfig.ts +++ b/src/config/authConfig.ts @@ -8,12 +8,10 @@ export interface SessionWithTokens extends Session { refreshToken?: string; } - export interface ability { actionSubjectCombo: string; } - export const authOptions: AuthOptions = { debug: process.env.NODE_ENV === "development", providers: [ @@ -55,11 +53,12 @@ export const authOptions: AuthOptions = { const sessionWithToken: SessionWithTokens = { ...session, // Add the data from the token to the session - abilities: (token.abilities as ability[]).map((item: ability) => item.actionSubjectCombo) as string[], + abilities: (token.abilities as ability[]).map( + (item: ability) => item.actionSubjectCombo, + ) as string[], accessToken: token.accessToken as string | undefined, refreshToken: token.refreshToken as string | undefined, }; - // console.log(sessionWithToken) return sessionWithToken; },