Przeglądaj źródła

Save team member leave entry

tags/Baseline_30082024_FRONTEND_UAT
Wayne 1 rok temu
rodzic
commit
25af9ac316
10 zmienionych plików z 209 dodań i 22 usunięć
  1. +2
    -0
      src/app/(main)/home/page.tsx
  2. +15
    -0
      src/app/api/timesheets/actions.ts
  3. +14
    -0
      src/app/api/timesheets/index.ts
  4. +25
    -5
      src/components/LeaveTable/LeaveEditModal.tsx
  5. +1
    -1
      src/components/LeaveTable/MobileLeaveEntry.tsx
  6. +133
    -13
      src/components/TimesheetAmendment/TimesheetAmendment.tsx
  7. +4
    -0
      src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx
  8. +6
    -2
      src/components/TimesheetTable/TimesheetEditModal.tsx
  9. +5
    -1
      src/components/UserWorkspacePage/UserWorkspacePage.tsx
  10. +4
    -0
      src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx

+ 2
- 0
src/app/(main)/home/page.tsx Wyświetl plik

@@ -4,6 +4,7 @@ import UserWorkspacePage from "@/components/UserWorkspacePage";
import {
fetchLeaveTypes,
fetchLeaves,
fetchTeamMemberLeaves,
fetchTeamMemberTimesheets,
fetchTimesheets,
} from "@/app/api/timesheets";
@@ -31,6 +32,7 @@ const Home: React.FC = async () => {
fetchProjectWithTasks();
fetchHolidays();
fetchTeamMemberTimesheets(username);
fetchTeamMemberLeaves(username);

return (
<I18nProvider namespaces={["home", "common"]}>


+ 15
- 0
src/app/api/timesheets/actions.ts Wyświetl plik

@@ -79,6 +79,21 @@ export const saveMemberEntry = async (data: {
);
};

export const saveMemberLeave = async (data: {
staffId: number;
entry: LeaveEntry;
recordDate?: string;
}) => {
return serverFetchJson<RecordLeaveInput>(
`${BASE_API_URL}/timesheets/saveMemberLeave`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);
};

export const revalidateCacheAfterAmendment = () => {
revalidatePath("/(main)/home");
};

+ 14
- 0
src/app/api/timesheets/index.ts Wyświetl plik

@@ -16,6 +16,14 @@ export type TeamTimeSheets = {
};
};

export type TeamLeaves = {
[memberId: number]: {
leaveEntries: RecordLeaveInput;
staffId: string;
name: string;
};
};

export const fetchTimesheets = cache(async (username: string) => {
return serverFetchJson<RecordTimesheetInput>(`${BASE_API_URL}/timesheets`, {
next: { tags: [`timesheets_${username}`] },
@@ -45,3 +53,9 @@ export const fetchTeamMemberTimesheets = cache(async (username: string) => {
},
);
});

export const fetchTeamMemberLeaves = cache(async (username: string) => {
return serverFetchJson<TeamLeaves>(`${BASE_API_URL}/timesheets/teamLeaves`, {
next: { tags: [`team_leaves_${username}`] },
});
});

+ 25
- 5
src/components/LeaveTable/LeaveEditModal.tsx Wyświetl plik

@@ -1,6 +1,7 @@
import { LeaveType } from "@/app/api/timesheets";
import { LeaveEntry } from "@/app/api/timesheets/actions";
import { LEAVE_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils";
import { shortDateFormatter } from "@/app/utils/formatUtil";
import { roundToNearestQuarter } from "@/app/utils/manhourUtils";
import { Check, Delete } from "@mui/icons-material";
import {
@@ -15,16 +16,20 @@ import {
Select,
SxProps,
TextField,
Typography,
} from "@mui/material";
import React, { useCallback, useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";

export interface Props extends Omit<ModalProps, "children"> {
onSave: (leaveEntry: LeaveEntry) => void;
onSave: (leaveEntry: LeaveEntry, recordDate?: string) => Promise<void>;
onDelete?: () => void;
leaveTypes: LeaveType[];
defaultValues?: Partial<LeaveEntry>;
modalSx?: SxProps;
recordDate?: string;
isHoliday?: boolean;
}

const modalSx: SxProps = {
@@ -46,8 +51,14 @@ const LeaveEditModal: React.FC<Props> = ({
onClose,
leaveTypes,
defaultValues,
recordDate,
modalSx: mSx,
isHoliday,
}) => {
const { t } = useTranslation("home");
const {
t,
i18n: { language },
} = useTranslation("home");
const { register, control, reset, getValues, trigger, formState } =
useForm<LeaveEntry>({
defaultValues: {
@@ -62,10 +73,10 @@ const LeaveEditModal: React.FC<Props> = ({
const saveHandler = useCallback(async () => {
const valid = await trigger();
if (valid) {
onSave(getValues());
await onSave(getValues(), recordDate);
reset({ id: Date.now() });
}
}, [getValues, onSave, reset, trigger]);
}, [getValues, onSave, recordDate, reset, trigger]);

const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
(...args) => {
@@ -77,7 +88,16 @@ const LeaveEditModal: React.FC<Props> = ({

return (
<Modal open={open} onClose={closeHandler}>
<Paper sx={modalSx}>
<Paper sx={{ ...modalSx, ...mSx }}>
{recordDate && (
<Typography
variant="h6"
marginBlockEnd={2}
color={isHoliday ? "error.main" : undefined}
>
{shortDateFormatter(language).format(new Date(recordDate))}
</Typography>
)}
<FormControl fullWidth>
<InputLabel>{t("Leave Type")}</InputLabel>
<Controller


+ 1
- 1
src/components/LeaveTable/MobileLeaveEntry.tsx Wyświetl plik

@@ -72,7 +72,7 @@ const MobileLeaveEntry: React.FC<Props> = ({
}, []);

const onSaveEntry = useCallback(
(entry: LeaveEntry) => {
async (entry: LeaveEntry) => {
const existingEntry = currentEntries.find((e) => e.id === entry.id);
if (existingEntry) {
setValue(


+ 133
- 13
src/components/TimesheetAmendment/TimesheetAmendment.tsx Wyświetl plik

@@ -1,31 +1,44 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";

import { HolidaysResult } from "@/app/api/holidays";
import { TeamTimeSheets } from "@/app/api/timesheets";
import { LeaveType, TeamLeaves, 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";
import { getPublicHolidaysForNYears } from "@/app/utils/holidayUtils";
import {
getHolidayForDate,
getPublicHolidaysForNYears,
} from "@/app/utils/holidayUtils";
import {
INPUT_DATE_FORMAT,
convertDateArrayToString,
} from "@/app/utils/formatUtil";
import StyledFullCalendar from "../StyledFullCalendar";
import { ProjectWithTasks } from "@/app/api/projects";
import { TimeEntry, saveMemberEntry } from "@/app/api/timesheets/actions";
import {
LeaveEntry,
TimeEntry,
saveMemberEntry,
saveMemberLeave,
} from "@/app/api/timesheets/actions";
import TimesheetEditModal, {
Props as TimesheetEditModalProps,
} from "../TimesheetTable/TimesheetEditModal";
import { Props as LeaveEditModalProps } from "../LeaveTable/LeaveEditModal";
import LeaveEditModal from "../LeaveTable/LeaveEditModal";
import dayjs from "dayjs";

export interface Props {
leaveTypes: LeaveType[];
teamLeaves: TeamLeaves;
teamTimesheets: TeamTimeSheets;
companyHolidays: HolidaysResult[];
allProjects: ProjectWithTasks[];
}

type MemberOption = TeamTimeSheets[0] & { id: string };
type MemberOption = TeamTimeSheets[0] & TeamLeaves[0] & { id: string };

interface EventClickArg {
event: {
@@ -33,7 +46,7 @@ interface EventClickArg {
startStr: string;
extendedProps: {
calendar?: string;
entry?: TimeEntry;
entry?: TimeEntry | LeaveEntry;
memberId?: string;
};
};
@@ -41,8 +54,10 @@ interface EventClickArg {

const TimesheetAmendment: React.FC<Props> = ({
teamTimesheets,
teamLeaves,
companyHolidays,
allProjects,
leaveTypes,
}) => {
const { t } = useTranslation(["home", "common"]);

@@ -56,23 +71,34 @@ const TimesheetAmendment: React.FC<Props> = ({
}, {});
}, [allProjects]);

const leaveMap = useMemo(() => {
return leaveTypes.reduce<{ [id: LeaveType["id"]]: string }>(
(acc, leaveType) => ({ ...acc, [leaveType.id]: leaveType.name }),
{},
);
}, [leaveTypes]);

// Use a local state to manage updates after a mutation
const [localTeamTimesheets, setLocalTeamTimesheets] =
useState(teamTimesheets);
const [localTeamLeaves, setLocalTeamLeaves] = useState(teamLeaves);

// member select
const allMembers = useMemo(() => {
return transform<TeamTimeSheets[0], MemberOption[]>(
localTeamTimesheets,
(acc, memberTimesheet, id) => {
const leaves = localTeamLeaves[parseInt(id)];
return acc.push({
...leaves,
...memberTimesheet,
id,
});
},
[],
);
}, [localTeamTimesheets]);
}, [localTeamLeaves, localTeamTimesheets]);

const [selectedStaff, setSelectedStaff] = useState<MemberOption>(
allMembers[0],
);
@@ -91,10 +117,11 @@ const TimesheetAmendment: React.FC<Props> = ({
const [editModalOpen, setEditModalOpen] = useState(false);

const openEditModal = useCallback(
(defaultValues?: TimeEntry, recordDate?: string) => {
(defaultValues?: TimeEntry, recordDate?: string, isHoliday?: boolean) => {
setEditModalProps({
defaultValues,
recordDate,
isHoliday,
});
setEditModalOpen(true);
},
@@ -105,6 +132,28 @@ const TimesheetAmendment: React.FC<Props> = ({
setEditModalOpen(false);
}, []);

// leave edit modal related
const [leaveEditModalProps, setLeaveEditModalProps] = useState<
Partial<LeaveEditModalProps>
>({});
const [leaveEditModalOpen, setLeaveEditModalOpen] = useState(false);

const openLeaveEditModal = useCallback(
(defaultValues?: LeaveEntry, recordDate?: string, isHoliday?: boolean) => {
setLeaveEditModalProps({
defaultValues,
recordDate,
isHoliday,
});
setLeaveEditModalOpen(true);
},
[],
);

const closeLeaveEditModal = useCallback(() => {
setLeaveEditModalOpen(false);
}, []);

// calendar related
const holidays = useMemo(() => {
return [
@@ -123,11 +172,34 @@ const TimesheetAmendment: React.FC<Props> = ({
}));
}, [companyHolidays, theme.palette.error.main]);

const leaveEntries = useMemo(
() =>
Object.keys(selectedStaff.leaveEntries).flatMap((date, index) => {
return selectedStaff.leaveEntries[date].map((entry) => ({
id: `${date}-${index}-leave-${entry.id}`,
date,
title: `${t("{{count}} hour", {
ns: "common",
count: entry.inputHours || 0,
})} (${leaveMap[entry.leaveTypeId]})`,
backgroundColor: theme.palette.warning.light,
borderColor: theme.palette.warning.light,
textColor: theme.palette.text.primary,
extendedProps: {
calendar: "leaveEntry",
entry,
memberId: selectedStaff.id,
},
}));
}),
[leaveMap, selectedStaff, t, theme],
);

const timeEntries = useMemo(
() =>
Object.keys(selectedStaff.timeEntries).flatMap((date, index) => {
return selectedStaff.timeEntries[date].map((entry) => ({
id: `${date}-${index}-entry-${entry.id}`,
id: `${date}-${index}-time-${entry.id}`,
date,
title: `${t("{{count}} hour", {
ns: "common",
@@ -151,21 +223,41 @@ const TimesheetAmendment: React.FC<Props> = ({

const handleEventClick = useCallback(
({ event }: EventClickArg) => {
const dayJsObj = dayjs(event.startStr);
const holiday = getHolidayForDate(event.startStr, companyHolidays);
const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6;

if (
event.extendedProps.calendar === "timeEntry" &&
event.extendedProps.entry
) {
openEditModal(event.extendedProps.entry, event.startStr);
openEditModal(
event.extendedProps.entry as TimeEntry,
event.startStr,
Boolean(isHoliday),
);
} else if (
event.extendedProps.calendar === "leaveEntry" &&
event.extendedProps.entry
) {
openLeaveEditModal(
event.extendedProps.entry as LeaveEntry,
event.startStr,
Boolean(isHoliday),
);
}
},
[openEditModal],
[companyHolidays, openEditModal, openLeaveEditModal],
);

const handleDateClick = useCallback(
(e: { dateStr: string }) => {
openEditModal(undefined, e.dateStr);
const dayJsObj = dayjs(e.dateStr);
const holiday = getHolidayForDate(e.dateStr, companyHolidays);
const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6;
openEditModal(undefined, e.dateStr, Boolean(isHoliday));
},
[openEditModal],
[companyHolidays, openEditModal],
);

const handleSave = useCallback(
@@ -189,6 +281,26 @@ const TimesheetAmendment: React.FC<Props> = ({
[selectedStaff.id],
);

const handleSaveLeave = useCallback(
async (leaveEntry: LeaveEntry, recordDate?: string) => {
const intStaffId = parseInt(selectedStaff.id);
const newMemberLeaves = await saveMemberLeave({
staffId: intStaffId,
recordDate,
entry: leaveEntry,
});
setLocalTeamLeaves((leaves) => ({
...leaves,
[intStaffId]: {
...leaves[intStaffId],
leaveEntries: newMemberLeaves,
},
}));
setLeaveEditModalOpen(false);
},
[selectedStaff.id],
);

return (
<Stack spacing={2}>
<Autocomplete
@@ -207,7 +319,7 @@ const TimesheetAmendment: React.FC<Props> = ({
plugins={[dayGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
buttonText={{ today: t("Today") }}
events={[...holidays, ...timeEntries]}
events={[...holidays, ...timeEntries, ...leaveEntries]}
eventClick={handleEventClick}
dateClick={handleDateClick}
/>
@@ -220,6 +332,14 @@ const TimesheetAmendment: React.FC<Props> = ({
onSave={handleSave}
{...editModalProps}
/>
<LeaveEditModal
modalSx={{ maxWidth: 400 }}
leaveTypes={leaveTypes}
open={leaveEditModalOpen}
onClose={closeLeaveEditModal}
onSave={handleSaveLeave}
{...leaveEditModalProps}
/>
</Stack>
);
};


+ 4
- 0
src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx Wyświetl plik

@@ -32,6 +32,8 @@ interface Props extends TimesheetAmendmentProps {
export const TimesheetAmendmentModal: React.FC<Props> = ({
open,
onClose,
leaveTypes,
teamLeaves,
teamTimesheets,
companyHolidays,
allProjects,
@@ -42,6 +44,8 @@ export const TimesheetAmendmentModal: React.FC<Props> = ({
const title = t("Timesheet Amendment");
const content = (
<TimesheetAmendment
leaveTypes={leaveTypes}
teamLeaves={teamLeaves}
companyHolidays={companyHolidays}
teamTimesheets={teamTimesheets}
allProjects={allProjects}


+ 6
- 2
src/components/TimesheetTable/TimesheetEditModal.tsx Wyświetl plik

@@ -105,7 +105,7 @@ const TimesheetEditModal: React.FC<Props> = ({
const saveHandler = useCallback(async () => {
const valid = await trigger();
if (valid) {
onSave(getValues(), recordDate);
await onSave(getValues(), recordDate);
reset({ id: Date.now() });
}
}, [getValues, onSave, recordDate, reset, trigger]);
@@ -126,7 +126,11 @@ const TimesheetEditModal: React.FC<Props> = ({
<Modal open={open} onClose={closeHandler}>
<Paper sx={{ ...modalSx, ...mSx }}>
{recordDate && (
<Typography variant="h6" marginBlockEnd={2}>
<Typography
variant="h6"
marginBlockEnd={2}
color={isHoliday ? "error.main" : undefined}
>
{shortDateFormatter(language).format(new Date(recordDate))}
</Typography>
)}


+ 5
- 1
src/components/UserWorkspacePage/UserWorkspacePage.tsx Wyświetl plik

@@ -20,7 +20,7 @@ import {
revalidateCacheAfterAmendment,
} from "@/app/api/timesheets/actions";
import LeaveModal from "../LeaveModal";
import { LeaveType, TeamTimeSheets } from "@/app/api/timesheets";
import { LeaveType, TeamLeaves, TeamTimeSheets } from "@/app/api/timesheets";
import { CalendarIcon } from "@mui/x-date-pickers";
import PastEntryCalendarModal from "../PastEntryCalendar/PastEntryCalendarModal";
import { HolidaysResult } from "@/app/api/holidays";
@@ -35,6 +35,7 @@ export interface Props {
defaultTimesheets: RecordTimesheetInput;
holidays: HolidaysResult[];
teamTimesheets: TeamTimeSheets;
teamLeaves: TeamLeaves;
fastEntryEnabled?: boolean;
}

@@ -52,6 +53,7 @@ const UserWorkspacePage: React.FC<Props> = ({
defaultTimesheets,
holidays,
teamTimesheets,
teamLeaves,
fastEntryEnabled,
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
@@ -201,7 +203,9 @@ const UserWorkspacePage: React.FC<Props> = ({
{showTimesheetAmendment && (
<TimesheetAmendmentModal
allProjects={allProjects}
leaveTypes={leaveTypes}
companyHolidays={holidays}
teamLeaves={teamLeaves}
teamTimesheets={teamTimesheets}
open={isTimesheetAmendmentVisible}
onClose={handleAmendmentClose}


+ 4
- 0
src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx Wyświetl plik

@@ -6,6 +6,7 @@ import UserWorkspacePage from "./UserWorkspacePage";
import {
fetchLeaveTypes,
fetchLeaves,
fetchTeamMemberLeaves,
fetchTeamMemberTimesheets,
fetchTimesheets,
} from "@/app/api/timesheets";
@@ -17,6 +18,7 @@ interface Props {

const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => {
const [
teamLeaves,
teamTimesheets,
assignedProjects,
allProjects,
@@ -25,6 +27,7 @@ const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => {
leaveTypes,
holidays,
] = await Promise.all([
fetchTeamMemberLeaves(username),
fetchTeamMemberTimesheets(username),
fetchAssignedProjects(username),
fetchProjectWithTasks(),
@@ -36,6 +39,7 @@ const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => {

return (
<UserWorkspacePage
teamLeaves={teamLeaves}
teamTimesheets={teamTimesheets}
allProjects={allProjects}
assignedProjects={assignedProjects}


Ładowanie…
Anuluj
Zapisz