Selaa lähdekoodia

Add leave input

tags/Baseline_30082024_FRONTEND_UAT
Wayne 1 vuosi sitten
vanhempi
commit
3aeafee402
13 muutettua tiedostoa jossa 653 lisäystä ja 50 poistoa
  1. +10
    -2
      src/app/(main)/home/page.tsx
  2. +2
    -2
      src/app/api/projects/actions.ts
  3. +2
    -2
      src/app/api/projects/index.ts
  4. +25
    -0
      src/app/api/timesheets/actions.ts
  5. +21
    -1
      src/app/api/timesheets/index.ts
  6. +127
    -0
      src/components/LeaveModal/LeaveModal.tsx
  7. +1
    -0
      src/components/LeaveModal/index.ts
  8. +283
    -0
      src/components/LeaveTable/LeaveEntryTable.tsx
  9. +133
    -0
      src/components/LeaveTable/LeaveTable.tsx
  10. +1
    -0
      src/components/LeaveTable/index.ts
  11. +1
    -3
      src/components/TimesheetModal/TimesheetModal.tsx
  12. +36
    -37
      src/components/UserWorkspacePage/UserWorkspacePage.tsx
  13. +11
    -3
      src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx

+ 10
- 2
src/app/(main)/home/page.tsx Näytä tiedosto

@@ -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 (
<I18nProvider namespaces={["home"]}>


+ 2
- 2
src/app/api/projects/actions.ts Näytä tiedosto

@@ -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;
};

+ 2
- 2
src/app/api/projects/index.ts Näytä tiedosto

@@ -138,11 +138,11 @@ export const fetchProjectWorkNatures = cache(async () => {
});
});

export const fetchAssignedProjects = cache(async () => {
export const fetchAssignedProjects = cache(async (username: string) => {
return serverFetchJson<AssignedProject[]>(
`${BASE_API_URL}/projects/assignedProjects`,
{
next: { tags: ["assignedProjects"] },
next: { tags: [`assignedProjects__${username}`] },
},
);
});


+ 25
- 0
src/app/api/timesheets/actions.ts Näytä tiedosto

@@ -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<RecordLeaveInput>(
`${BASE_API_URL}/timesheets/saveLeave`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);

revalidateTag(`leaves_${username}`);

return savedRecords;
};

+ 21
- 1
src/app/api/timesheets/index.ts Näytä tiedosto

@@ -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<RecordTimesheetInput>(`${BASE_API_URL}/timesheets`, {
next: { tags: [`timesheets_${username}`] },
});
});

export const fetchLeaves = cache(async (username: string) => {
return serverFetchJson<RecordLeaveInput>(
`${BASE_API_URL}/timesheets/leaves`,
{
next: { tags: [`leaves_${username}`] },
},
);
});

export const fetchLeaveTypes = cache(async () => {
return serverFetchJson<LeaveType[]>(`${BASE_API_URL}/timesheets/leaveTypes`, {
next: { tags: ["leaveTypes"] },
});
});

+ 127
- 0
src/components/LeaveModal/LeaveModal.tsx Näytä tiedosto

@@ -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<Props> = ({
isOpen,
onClose,
username,
defaultLeaveRecords,
leaveTypes,
}) => {
const { t } = useTranslation("home");

const defaultValues = useMemo(() => {
const today = dayjs();
return Array(7)
.fill(undefined)
.reduce<RecordLeaveInput>((acc, _, index) => {
const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT);
return {
...acc,
[date]: defaultLeaveRecords?.[date] ?? [],
};
}, {});
}, [defaultLeaveRecords]);

const formProps = useForm<RecordLeaveInput>({ defaultValues });

const onSubmit = useCallback<SubmitHandler<RecordLeaveInput>>(
async (data) => {
const savedRecords = await saveLeave(data, username);

const today = dayjs();
const newFormValues = Array(7)
.fill(undefined)
.reduce<RecordLeaveInput>((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 (
<Modal open={isOpen} onClose={onClose}>
<Card sx={modalSx}>
<FormProvider {...formProps}>
<CardContent
component="form"
onSubmit={formProps.handleSubmit(onSubmit)}
>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Record Leave")}
</Typography>
<Box
sx={{
marginInline: -3,
marginBlock: 4,
}}
>
<LeaveTable leaveTypes={leaveTypes} />
</Box>
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button
variant="outlined"
startIcon={<Close />}
onClick={onCancel}
>
{t("Cancel")}
</Button>
<Button variant="contained" startIcon={<Check />} type="submit">
{t("Save")}
</Button>
</CardActions>
</CardContent>
</FormProvider>
</Card>
</Modal>
);
};

export default LeaveModal;

+ 1
- 0
src/components/LeaveModal/index.ts Näytä tiedosto

@@ -0,0 +1 @@
export { default } from "./LeaveModal";

+ 283
- 0
src/components/LeaveTable/LeaveEntryTable.tsx Näytä tiedosto

@@ -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<Props> = ({ day, leaveTypes }) => {
const { t } = useTranslation("home");

const { getValues, setValue } = useFormContext<RecordLeaveInput>();
const currentEntries = getValues(day);

const [entries, setEntries] = useState<LeaveEntryRow[]>(currentEntries || []);

const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});

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<GridEventListener<"rowEditStop">>(
(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<GridColDef[]>(
() => [
{
type: "actions",
field: "actions",
headerName: t("Actions"),
getActions: ({ id }) => {
if (rowModesModel[id]?.mode === GridRowModes.Edit) {
return [
<GridActionsCellItem
key="accpet-action"
icon={<Check />}
label={t("Save")}
onClick={handleSave(id)}
/>,
<GridActionsCellItem
key="cancel-action"
icon={<Close />}
label={t("Cancel")}
onClick={handleCancel(id)}
/>,
];
}

return [
<GridActionsCellItem
key="delete-action"
icon={<Delete />}
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 = (
<Box display="flex" gap={2} alignItems="center">
<Button
disableRipple
variant="outlined"
startIcon={<Add />}
onClick={addRow}
size="small"
>
{t("Record leave")}
</Button>
</Box>
);

return (
<StyledDataGrid
apiRef={apiRef}
autoHeight
sx={{
"--DataGrid-overlayHeight": "100px",
".MuiDataGrid-row .MuiDataGrid-cell.hasError": {
border: "1px solid",
borderColor: "error.main",
},
".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": {
border: "1px solid",
borderColor: "warning.main",
},
}}
disableColumnMenu
editMode="row"
rows={entries}
rowModesModel={rowModesModel}
onRowModesModelChange={setRowModesModel}
onRowEditStop={handleEditStop}
processRowUpdate={processRowUpdate}
columns={columns}
getCellClassName={(params) => {
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 (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100%"
>
<Typography variant="caption">{t("Add some leave entries!")}</Typography>
</Box>
);
};

const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => {
return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>;
};

export default EntryInputTable;

+ 133
- 0
src/components/LeaveTable/LeaveTable.tsx Näytä tiedosto

@@ -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<Props> = ({ leaveTypes }) => {
const { t } = useTranslation("home");

const { watch } = useFormContext<RecordLeaveInput>();
const currentInput = watch();
const days = Object.keys(currentInput);

return (
<TableContainer sx={{ maxHeight: 400 }}>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell />
<TableCell>{t("Date")}</TableCell>
<TableCell>{t("Daily Total Hours")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{days.map((day, index) => {
const entries = currentInput[day];
return (
<DayRow
key={`${day}${index}`}
day={day}
entries={entries}
leaveTypes={leaveTypes}
/>
);
})}
</TableBody>
</Table>
</TableContainer>
);
};

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 (
<>
<TableRow>
<TableCell align="center" width={70}>
<IconButton
disableRipple
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUp /> : <KeyboardArrowDown />}
</IconButton>
</TableCell>
<TableCell
sx={{ color: dayJsObj.day() === 0 ? "error.main" : undefined }}
>
{shortDateFormatter(language).format(dayJsObj.toDate())}
</TableCell>
<TableCell
sx={{ color: totalHours > MAX_HOURS ? "error.main" : undefined }}
>
{manhourFormatter.format(totalHours)}
{totalHours > MAX_HOURS && (
<Typography
color="error.main"
variant="body2"
component="span"
sx={{ marginInlineStart: 1 }}
>
{t("(the daily total hours cannot be more than 8.)")}
</Typography>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell
sx={{
p: 0,
border: "none",
outline: open ? "1px solid" : undefined,
outlineColor: "primary.main",
}}
colSpan={3}
>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box>
<LeaveEntryTable day={day} leaveTypes={leaveTypes} />
</Box>
</Collapse>
</TableCell>
</TableRow>
</>
);
};

export default LeaveTable;

+ 1
- 0
src/components/LeaveTable/index.ts Näytä tiedosto

@@ -0,0 +1 @@
export { default } from "./LeaveTable";

+ 1
- 3
src/components/TimesheetModal/TimesheetModal.tsx Näytä tiedosto

@@ -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<Props> = ({
isOpen,
onClose,
timesheetType,
assignedProjects,
username,
defaultTimesheets,
@@ -100,7 +98,7 @@ const TimesheetModal: React.FC<Props> = ({
onSubmit={formProps.handleSubmit(onSubmit)}
>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t(timesheetType === "time" ? "Timesheet Input" : "Record Leave")}
{t("Timesheet Input")}
</Typography>
<Box
sx={{


+ 36
- 37
src/components/UserWorkspacePage/UserWorkspacePage.tsx Näytä tiedosto

@@ -10,17 +10,26 @@ 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";
import {
RecordLeaveInput,
RecordTimesheetInput,
} from "@/app/api/timesheets/actions";
import LeaveModal from "../LeaveModal";
import { LeaveType } from "@/app/api/timesheets";

export interface Props {
leaveTypes: LeaveType[];
assignedProjects: AssignedProject[];
username: string;
defaultLeaveRecords: RecordLeaveInput;
defaultTimesheets: RecordTimesheetInput;
}

const UserWorkspacePage: React.FC<Props> = ({
leaveTypes,
assignedProjects,
username,
defaultLeaveRecords,
defaultTimesheets,
}) => {
const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false);
@@ -60,46 +69,36 @@ const UserWorkspacePage: React.FC<Props> = ({
flexWrap="wrap"
spacing={2}
>
{Boolean(assignedProjects.length) && (
<ButtonGroup variant="contained">
<Button
startIcon={<Add />}
onClick={handleAddTimesheetButtonClick}
>
{t("Enter Time")}
</Button>
<Button startIcon={<Add />} onClick={handleAddLeaveButtonClick}>
{t("Record Leave")}
</Button>
</ButtonGroup>
)}
<ButtonGroup variant="contained">
<Button startIcon={<Add />} onClick={handleAddTimesheetButtonClick}>
{t("Enter Time")}
</Button>
<Button startIcon={<Add />} onClick={handleAddLeaveButtonClick}>
{t("Record Leave")}
</Button>
</ButtonGroup>
</Stack>
</Stack>
<TimesheetModal
isOpen={isTimeheetModalVisible}
onClose={handleCloseTimesheetModal}
assignedProjects={assignedProjects}
username={username}
defaultTimesheets={defaultTimesheets}
/>
<LeaveModal
leaveTypes={leaveTypes}
isOpen={isLeaveModalVisible}
onClose={handleCloseLeaveModal}
defaultLeaveRecords={defaultLeaveRecords}
username={username}
/>
{assignedProjects.length > 0 ? (
<>
<TimesheetModal
timesheetType="time"
isOpen={isTimeheetModalVisible}
onClose={handleCloseTimesheetModal}
assignedProjects={assignedProjects}
username={username}
defaultTimesheets={defaultTimesheets}
/>
<TimesheetModal
timesheetType="leave"
isOpen={isLeaveModalVisible}
onClose={handleCloseLeaveModal}
assignedProjects={assignedProjects}
username={username}
/>
<AssignedProjects assignedProjects={assignedProjects} />
</>
<AssignedProjects assignedProjects={assignedProjects} />
) : (
<>
<Typography variant="subtitle1">
{t("You have no assigned projects!")}
</Typography>
</>
<Typography variant="subtitle1">
{t("You have no assigned projects!")}
</Typography>
)}
</>
);


+ 11
- 3
src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx Näytä tiedosto

@@ -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<Props> = 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<Props> = async ({ username }) => {
assignedProjects={assignedProjects}
username={username}
defaultTimesheets={timesheets}
defaultLeaveRecords={leaves}
leaveTypes={leaveTypes}
/>
);
};


Ladataan…
Peruuta
Tallenna