diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx index 155388f..2766580 100644 --- a/src/app/(main)/home/page.tsx +++ b/src/app/(main)/home/page.tsx @@ -1,9 +1,14 @@ import { Metadata } from "next"; import { I18nProvider } from "@/i18n"; import UserWorkspacePage from "@/components/UserWorkspacePage"; -import { fetchTimesheets } from "@/app/api/timesheets"; +import { + fetchLeaveTypes, + fetchLeaves, + fetchTimesheets, +} from "@/app/api/timesheets"; import { authOptions } from "@/config/authConfig"; import { getServerSession } from "next-auth"; +import { fetchAssignedProjects } from "@/app/api/projects"; export const metadata: Metadata = { title: "User Workspace", @@ -14,7 +19,10 @@ const Home: React.FC = async () => { // Get name for caching const username = session!.user!.name!; - await fetchTimesheets(username); + fetchTimesheets(username); + fetchAssignedProjects(username); + fetchLeaves(username); + fetchLeaveTypes(); return ( diff --git a/src/app/api/projects/actions.ts b/src/app/api/projects/actions.ts index 6bb5596..c80dff0 100644 --- a/src/app/api/projects/actions.ts +++ b/src/app/api/projects/actions.ts @@ -7,7 +7,7 @@ import { import { BASE_API_URL } from "@/config/api"; import { Task, TaskGroup } from "../tasks"; import { Customer } from "../customer"; -import { revalidateTag } from "next/cache"; +import { revalidatePath, revalidateTag } from "next/cache"; export interface CreateProjectInputs { // Project @@ -101,6 +101,6 @@ export const deleteProject = async (id: number) => { ); revalidateTag("projects"); - revalidateTag("assignedProjects"); + revalidatePath("/(main)/home"); return project; }; diff --git a/src/app/api/projects/index.ts b/src/app/api/projects/index.ts index 90b0e10..9cc4f01 100644 --- a/src/app/api/projects/index.ts +++ b/src/app/api/projects/index.ts @@ -138,11 +138,11 @@ export const fetchProjectWorkNatures = cache(async () => { }); }); -export const fetchAssignedProjects = cache(async () => { +export const fetchAssignedProjects = cache(async (username: string) => { return serverFetchJson( `${BASE_API_URL}/projects/assignedProjects`, { - next: { tags: ["assignedProjects"] }, + next: { tags: [`assignedProjects__${username}`] }, }, ); }); diff --git a/src/app/api/timesheets/actions.ts b/src/app/api/timesheets/actions.ts index 631c076..97b03a9 100644 --- a/src/app/api/timesheets/actions.ts +++ b/src/app/api/timesheets/actions.ts @@ -18,6 +18,16 @@ export interface RecordTimesheetInput { [date: string]: TimeEntry[]; } +export interface LeaveEntry { + id: number; + inputHours: number; + leaveTypeId: number; +} + +export interface RecordLeaveInput { + [date: string]: LeaveEntry[]; +} + export const saveTimesheet = async ( data: RecordTimesheetInput, username: string, @@ -35,3 +45,18 @@ export const saveTimesheet = async ( return savedRecords; }; + +export const saveLeave = async (data: RecordLeaveInput, username: string) => { + const savedRecords = await serverFetchJson( + `${BASE_API_URL}/timesheets/saveLeave`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); + + revalidateTag(`leaves_${username}`); + + return savedRecords; +}; diff --git a/src/app/api/timesheets/index.ts b/src/app/api/timesheets/index.ts index fd7d20d..d9b1862 100644 --- a/src/app/api/timesheets/index.ts +++ b/src/app/api/timesheets/index.ts @@ -1,10 +1,30 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; import { cache } from "react"; -import { RecordTimesheetInput } from "./actions"; +import { RecordLeaveInput, RecordTimesheetInput } from "./actions"; + +export interface LeaveType { + id: number; + name: string; +} export const fetchTimesheets = cache(async (username: string) => { return serverFetchJson(`${BASE_API_URL}/timesheets`, { next: { tags: [`timesheets_${username}`] }, }); }); + +export const fetchLeaves = cache(async (username: string) => { + return serverFetchJson( + `${BASE_API_URL}/timesheets/leaves`, + { + next: { tags: [`leaves_${username}`] }, + }, + ); +}); + +export const fetchLeaveTypes = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/timesheets/leaveTypes`, { + next: { tags: ["leaveTypes"] }, + }); +}); diff --git a/src/components/LeaveModal/LeaveModal.tsx b/src/components/LeaveModal/LeaveModal.tsx new file mode 100644 index 0000000..b163003 --- /dev/null +++ b/src/components/LeaveModal/LeaveModal.tsx @@ -0,0 +1,127 @@ +import React, { useCallback, useMemo } from "react"; +import { + Box, + Button, + Card, + CardActions, + CardContent, + Modal, + SxProps, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { Check, Close } from "@mui/icons-material"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { RecordLeaveInput, saveLeave } from "@/app/api/timesheets/actions"; +import dayjs from "dayjs"; +import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; +import LeaveTable from "../LeaveTable"; +import { LeaveType } from "@/app/api/timesheets"; + +interface Props { + isOpen: boolean; + onClose: () => void; + username: string; + defaultLeaveRecords?: RecordLeaveInput; + leaveTypes: LeaveType[]; +} + +const modalSx: SxProps = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: { xs: "calc(100% - 2rem)", sm: "90%" }, + maxHeight: "90%", + maxWidth: 1200, +}; + +const LeaveModal: React.FC = ({ + isOpen, + onClose, + username, + defaultLeaveRecords, + leaveTypes, +}) => { + const { t } = useTranslation("home"); + + const defaultValues = useMemo(() => { + const today = dayjs(); + return Array(7) + .fill(undefined) + .reduce((acc, _, index) => { + const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); + return { + ...acc, + [date]: defaultLeaveRecords?.[date] ?? [], + }; + }, {}); + }, [defaultLeaveRecords]); + + const formProps = useForm({ defaultValues }); + + const onSubmit = useCallback>( + async (data) => { + const savedRecords = await saveLeave(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(); + }, [defaultValues, formProps, onClose]); + + return ( + + + + + + {t("Record Leave")} + + + + + + + + + + + + + ); +}; + +export default LeaveModal; diff --git a/src/components/LeaveModal/index.ts b/src/components/LeaveModal/index.ts new file mode 100644 index 0000000..cd099c7 --- /dev/null +++ b/src/components/LeaveModal/index.ts @@ -0,0 +1 @@ +export { default } from "./LeaveModal"; diff --git a/src/components/LeaveTable/LeaveEntryTable.tsx b/src/components/LeaveTable/LeaveEntryTable.tsx new file mode 100644 index 0000000..9e9170d --- /dev/null +++ b/src/components/LeaveTable/LeaveEntryTable.tsx @@ -0,0 +1,283 @@ +import { Add, Check, Close, Delete } from "@mui/icons-material"; +import { Box, Button, Typography } from "@mui/material"; +import { + FooterPropsOverrides, + GridActionsCellItem, + GridColDef, + GridEventListener, + GridRowId, + GridRowModel, + GridRowModes, + GridRowModesModel, + GridToolbarContainer, + useGridApiRef, +} from "@mui/x-data-grid"; +import { useTranslation } from "react-i18next"; +import StyledDataGrid from "../StyledDataGrid"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { RecordLeaveInput, LeaveEntry } from "@/app/api/timesheets/actions"; +import { manhourFormatter } from "@/app/utils/formatUtil"; +import dayjs from "dayjs"; +import isBetween from "dayjs/plugin/isBetween"; +import { LeaveType } from "@/app/api/timesheets"; + +dayjs.extend(isBetween); + +interface Props { + day: string; + leaveTypes: LeaveType[]; +} + +type LeaveEntryRow = Partial< + LeaveEntry & { + _isNew: boolean; + _error: string; + } +>; + +const EntryInputTable: React.FC = ({ day, leaveTypes }) => { + const { t } = useTranslation("home"); + + const { getValues, setValue } = useFormContext(); + const currentEntries = getValues(day); + + const [entries, setEntries] = useState(currentEntries || []); + + const [rowModesModel, setRowModesModel] = useState({}); + + const apiRef = useGridApiRef(); + const addRow = useCallback(() => { + const id = Date.now(); + setEntries((e) => [...e, { id, _isNew: true }]); + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.Edit, fieldToFocus: "leaveTypeId" }, + })); + }, []); + + const validateRow = useCallback( + (id: GridRowId) => { + const row = apiRef.current.getRowWithUpdatedValues( + id, + "", + ) as LeaveEntryRow; + + // Test for errrors + let error: keyof LeaveEntry | "" = ""; + if (!row.leaveTypeId) { + error = "leaveTypeId"; + } else if (!row.inputHours || !(row.inputHours >= 0)) { + error = "inputHours"; + } + + apiRef.current.updateRows([{ id, _error: error }]); + return !error; + }, + [apiRef], + ); + + const handleCancel = useCallback( + (id: GridRowId) => () => { + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.View, ignoreModifications: true }, + })); + const editedRow = entries.find((entry) => entry.id === id); + if (editedRow?._isNew) { + setEntries((es) => es.filter((e) => e.id !== id)); + } + }, + [entries], + ); + + const handleDelete = useCallback( + (id: GridRowId) => () => { + setEntries((es) => es.filter((e) => e.id !== id)); + }, + [], + ); + + const handleSave = useCallback( + (id: GridRowId) => () => { + if (validateRow(id)) { + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.View }, + })); + } + }, + [validateRow], + ); + + const handleEditStop = useCallback>( + (params, event) => { + if (!validateRow(params.id)) { + event.defaultMuiPrevented = true; + } + }, + [validateRow], + ); + + const processRowUpdate = useCallback((newRow: GridRowModel) => { + const updatedRow = { ...newRow, _isNew: false }; + setEntries((es) => es.map((e) => (e.id === newRow.id ? updatedRow : e))); + return updatedRow; + }, []); + + const columns = useMemo( + () => [ + { + type: "actions", + field: "actions", + headerName: t("Actions"), + getActions: ({ id }) => { + if (rowModesModel[id]?.mode === GridRowModes.Edit) { + return [ + } + label={t("Save")} + onClick={handleSave(id)} + />, + } + label={t("Cancel")} + onClick={handleCancel(id)} + />, + ]; + } + + return [ + } + label={t("Remove")} + onClick={handleDelete(id)} + />, + ]; + }, + }, + { + field: "leaveTypeId", + headerName: t("Leave Type"), + width: 200, + editable: true, + type: "singleSelect", + valueOptions() { + return leaveTypes.map((p) => ({ value: p.id, label: p.name })); + }, + valueGetter({ value }) { + return value ?? ""; + }, + }, + { + field: "inputHours", + headerName: t("Hours"), + width: 100, + editable: true, + type: "number", + valueFormatter(params) { + return manhourFormatter.format(params.value); + }, + }, + ], + [t, rowModesModel, handleDelete, handleSave, handleCancel, leaveTypes], + ); + + useEffect(() => { + setValue(day, [ + ...entries + .filter( + (e) => + !e._isNew && !e._error && e.inputHours && e.leaveTypeId && e.id, + ) + .map((e) => ({ + id: e.id!, + inputHours: e.inputHours!, + leaveTypeId: e.leaveTypeId!, + })), + ]); + }, [getValues, entries, setValue, day]); + + const footer = ( + + + + ); + + return ( + { + let classname = ""; + if (params.row._error === params.field) { + classname = "hasError"; + } else if ( + params.field === "taskGroupId" && + params.row.isPlanned !== undefined && + !params.row.isPlanned + ) { + classname = "hasWarning"; + } + return classname; + }} + slots={{ + footer: FooterToolbar, + noRowsOverlay: NoRowsOverlay, + }} + slotProps={{ + footer: { child: footer }, + }} + /> + ); +}; + +const NoRowsOverlay: React.FC = () => { + const { t } = useTranslation("home"); + return ( + + {t("Add some leave entries!")} + + ); +}; + +const FooterToolbar: React.FC = ({ child }) => { + return {child}; +}; + +export default EntryInputTable; diff --git a/src/components/LeaveTable/LeaveTable.tsx b/src/components/LeaveTable/LeaveTable.tsx new file mode 100644 index 0000000..5d0a003 --- /dev/null +++ b/src/components/LeaveTable/LeaveTable.tsx @@ -0,0 +1,133 @@ +import { RecordLeaveInput, LeaveEntry } from "@/app/api/timesheets/actions"; +import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; +import { KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; +import { + Box, + Collapse, + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import dayjs from "dayjs"; +import React, { useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import LeaveEntryTable from "./LeaveEntryTable"; +import { LeaveType } from "@/app/api/timesheets"; + +interface Props { + leaveTypes: LeaveType[]; +} + +const MAX_HOURS = 8; + +const LeaveTable: React.FC = ({ leaveTypes }) => { + const { t } = useTranslation("home"); + + const { watch } = useFormContext(); + const currentInput = watch(); + const days = Object.keys(currentInput); + + return ( + + + + + + {t("Date")} + {t("Daily Total Hours")} + + + + {days.map((day, index) => { + const entries = currentInput[day]; + return ( + + ); + })} + +
+
+ ); +}; + +const DayRow: React.FC<{ + day: string; + entries: LeaveEntry[]; + leaveTypes: LeaveType[]; +}> = ({ day, entries, leaveTypes }) => { + const { + t, + i18n: { language }, + } = useTranslation("home"); + const dayJsObj = dayjs(day); + const [open, setOpen] = useState(false); + + const totalHours = entries.reduce((acc, entry) => acc + entry.inputHours, 0); + + return ( + <> + + + setOpen(!open)} + > + {open ? : } + + + + {shortDateFormatter(language).format(dayJsObj.toDate())} + + MAX_HOURS ? "error.main" : undefined }} + > + {manhourFormatter.format(totalHours)} + {totalHours > MAX_HOURS && ( + + {t("(the daily total hours cannot be more than 8.)")} + + )} + + + + + + + + + + + + + ); +}; + +export default LeaveTable; diff --git a/src/components/LeaveTable/index.ts b/src/components/LeaveTable/index.ts new file mode 100644 index 0000000..9aeb679 --- /dev/null +++ b/src/components/LeaveTable/index.ts @@ -0,0 +1 @@ +export { default } from "./LeaveTable"; diff --git a/src/components/TimesheetModal/TimesheetModal.tsx b/src/components/TimesheetModal/TimesheetModal.tsx index c336cef..e8e5061 100644 --- a/src/components/TimesheetModal/TimesheetModal.tsx +++ b/src/components/TimesheetModal/TimesheetModal.tsx @@ -24,7 +24,6 @@ import { AssignedProject } from "@/app/api/projects"; interface Props { isOpen: boolean; onClose: () => void; - timesheetType: "time" | "leave"; assignedProjects: AssignedProject[]; username: string; defaultTimesheets?: RecordTimesheetInput; @@ -43,7 +42,6 @@ const modalSx: SxProps = { const TimesheetModal: React.FC = ({ isOpen, onClose, - timesheetType, assignedProjects, username, defaultTimesheets, @@ -100,7 +98,7 @@ const TimesheetModal: React.FC = ({ onSubmit={formProps.handleSubmit(onSubmit)} > - {t(timesheetType === "time" ? "Timesheet Input" : "Record Leave")} + {t("Timesheet Input")} = ({ + leaveTypes, assignedProjects, username, + defaultLeaveRecords, defaultTimesheets, }) => { const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false); @@ -60,46 +69,36 @@ const UserWorkspacePage: React.FC = ({ flexWrap="wrap" spacing={2} > - {Boolean(assignedProjects.length) && ( - - - - - )} + + + + + + {assignedProjects.length > 0 ? ( - <> - - - - + ) : ( - <> - - {t("You have no assigned projects!")} - - + + {t("You have no assigned projects!")} + )} ); diff --git a/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx b/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx index cd5fe66..529519e 100644 --- a/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx +++ b/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx @@ -1,15 +1,21 @@ import { fetchAssignedProjects } from "@/app/api/projects"; import UserWorkspacePage from "./UserWorkspacePage"; -import { fetchTimesheets } from "@/app/api/timesheets"; +import { + fetchLeaveTypes, + fetchLeaves, + fetchTimesheets, +} from "@/app/api/timesheets"; interface Props { username: string; } const UserWorkspaceWrapper: React.FC = async ({ username }) => { - const [assignedProjects, timesheets] = await Promise.all([ - fetchAssignedProjects(), + const [assignedProjects, timesheets, leaves, leaveTypes] = await Promise.all([ + fetchAssignedProjects(username), fetchTimesheets(username), + fetchLeaves(username), + fetchLeaveTypes(), ]); return ( @@ -17,6 +23,8 @@ const UserWorkspaceWrapper: React.FC = async ({ username }) => { assignedProjects={assignedProjects} username={username} defaultTimesheets={timesheets} + defaultLeaveRecords={leaves} + leaveTypes={leaveTypes} /> ); };