Parcourir la source

Merge branch 'main' of https://git.2fi-solutions.com/wayne.lee/tsms

# Conflicts:
#	src/app/utils/formatUtil.ts
#	src/components/InvoiceSearch/InvoiceSearch.tsx
#	src/components/InvoiceSearch/InvoiceSearchWrapper.tsx
tags/Baseline_30082024_FRONTEND_UAT
MSI\2Fi il y a 1 an
Parent
révision
20c45bf1f0
35 fichiers modifiés avec 1025 ajouts et 1983 suppressions
  1. +2
    -0
      src/app/api/timesheets/index.ts
  2. +21
    -87
      src/app/api/timesheets/utils.ts
  3. +2
    -2
      src/app/utils/formatUtil.ts
  4. +2
    -2
      src/components/CreateProject/ProjectClientDetails.tsx
  5. +0
    -109
      src/components/EnterLeave/EnterLeaveModal.tsx
  6. +0
    -548
      src/components/EnterLeave/LeaveInputGrid.tsx
  7. +0
    -1
      src/components/EnterLeave/index.ts
  8. +0
    -109
      src/components/EnterTimesheet/EnterTimesheetModal.tsx
  9. +0
    -548
      src/components/EnterTimesheet/TimesheetInputGrid.tsx
  10. +0
    -1
      src/components/EnterTimesheet/index.ts
  11. +1
    -1
      src/components/InvoiceSearch/InvoiceSearch.tsx
  12. +285
    -0
      src/components/LeaveModal/LeaveCalendar.tsx
  13. +45
    -184
      src/components/LeaveModal/LeaveModal.tsx
  14. +2
    -4
      src/components/Logo/Logo.tsx
  15. +8
    -8
      src/components/NavigationContent/NavigationContent.tsx
  16. +196
    -0
      src/components/PastEntryCalendar/MonthlySummary.tsx
  17. +3
    -0
      src/components/PastEntryCalendar/PastEntryCalendar.tsx
  18. +56
    -38
      src/components/PastEntryCalendar/PastEntryCalendarModal.tsx
  19. +24
    -11
      src/components/PastEntryCalendar/PastEntryList.tsx
  20. +1
    -1
      src/components/ProgressByClient/ProgressByClient.tsx
  21. +1
    -1
      src/components/ProgressByTeam/ProgressByTeam.tsx
  22. +1
    -1
      src/components/ProjectCashFlow/ProjectCashFlow.tsx
  23. +3
    -2
      src/components/StaffUtilization/StaffUtilization.tsx
  24. +14
    -5
      src/components/TimeLeaveModal/TimeLeaveInputTable.tsx
  25. +4
    -2
      src/components/TimeLeaveModal/TimeLeaveModal.tsx
  26. +15
    -3
      src/components/TimesheetAmendment/TimesheetAmendment.tsx
  27. +0
    -223
      src/components/TimesheetModal/TimesheetModal.tsx
  28. +0
    -1
      src/components/TimesheetModal/index.ts
  29. +1
    -0
      src/components/TimesheetTable/FastTimeEntryModal.tsx
  30. +151
    -62
      src/components/UserWorkspacePage/ProjectGrid.tsx
  31. +106
    -0
      src/components/UserWorkspacePage/ProjectTable.tsx
  32. +35
    -1
      src/components/UserWorkspacePage/UserWorkspacePage.tsx
  33. +23
    -7
      src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx
  34. +4
    -3
      src/config/authConfig.ts
  35. +19
    -18
      src/middleware.ts

+ 2
- 0
src/app/api/timesheets/index.ts Voir le fichier

@@ -13,6 +13,7 @@ export type TeamTimeSheets = {
timeEntries: RecordTimesheetInput;
staffId: string;
name: string;
employType: string | null;
};
};

@@ -21,6 +22,7 @@ export type TeamLeaves = {
leaveEntries: RecordLeaveInput;
staffId: string;
name: string;
employType: string | null;
};
};



+ 21
- 87
src/app/api/timesheets/utils.ts Voir le fichier

@@ -1,14 +1,9 @@
import { getPublicHolidaysForNYears } from "@/app/utils/holidayUtils";
import { HolidaysResult } from "../holidays";
import {
LeaveEntry,
RecordLeaveInput,
RecordTimeLeaveInput,
RecordTimesheetInput,
TimeEntry,
} from "./actions";
import { LeaveEntry, RecordTimeLeaveInput, TimeEntry } from "./actions";
import { convertDateArrayToString } from "@/app/utils/formatUtil";
import compact from "lodash/compact";
import dayjs from "dayjs";

export type TimeEntryError = {
[field in keyof TimeEntry]?: string;
@@ -83,85 +78,10 @@ export const validateLeaveEntry = (
return Object.keys(error).length > 0 ? error : undefined;
};

export const validateTimesheet = (
timesheet: RecordTimesheetInput,
leaveRecords: RecordLeaveInput,
companyHolidays: HolidaysResult[],
): { [date: string]: string } | undefined => {
const errors: { [date: string]: string } = {};

const holidays = new Set(
compact([
...getPublicHolidaysForNYears(2).map((h) => h.date),
...companyHolidays.map((h) => convertDateArrayToString(h.date)),
]),
);

Object.keys(timesheet).forEach((date) => {
const timeEntries = timesheet[date];

// Check each entry
for (const entry of timeEntries) {
const entryErrors = validateTimeEntry(entry, holidays.has(date));

if (entryErrors) {
errors[date] = "There are errors in the entries";
return;
}
}

// Check total hours
const leaves = leaveRecords[date] || [];
const totalHourError = checkTotalHours(timeEntries, leaves);
if (totalHourError) {
errors[date] = totalHourError;
}
});

return Object.keys(errors).length > 0 ? errors : undefined;
};

export const validateLeaveRecord = (
leaveRecords: RecordLeaveInput,
timesheet: RecordTimesheetInput,
companyHolidays: HolidaysResult[],
): { [date: string]: string } | undefined => {
const errors: { [date: string]: string } = {};

const holidays = new Set(
compact([
...getPublicHolidaysForNYears(2).map((h) => h.date),
...companyHolidays.map((h) => convertDateArrayToString(h.date)),
]),
);

Object.keys(leaveRecords).forEach((date) => {
const leaves = leaveRecords[date];

// Check each leave entry
for (const entry of leaves) {
const entryError = validateLeaveEntry(entry, holidays.has(date));
if (entryError) {
errors[date] = "There are errors in the entries";
return;
}
}

// Check total hours
const timeEntries = timesheet[date] || [];

const totalHourError = checkTotalHours(timeEntries, leaves);
if (totalHourError) {
errors[date] = totalHourError;
}
});

return Object.keys(errors).length > 0 ? errors : undefined;
};

export const validateTimeLeaveRecord = (
records: RecordTimeLeaveInput,
companyHolidays: HolidaysResult[],
isFullTime?: boolean,
): { [date: string]: string } | undefined => {
const errors: { [date: string]: string } = {};

@@ -173,14 +93,18 @@ export const validateTimeLeaveRecord = (
);

Object.keys(records).forEach((date) => {
const dayJsObj = dayjs(date);
const isHoliday =
holidays.has(date) || dayJsObj.day() === 0 || dayJsObj.day() === 6;

const entries = records[date];
// Check each entry
for (const entry of entries) {
let entryError;
if (entry.type === "leaveEntry") {
entryError = validateLeaveEntry(entry, holidays.has(date));
entryError = validateLeaveEntry(entry, isHoliday);
} else {
entryError = validateTimeEntry(entry, holidays.has(date));
entryError = validateTimeEntry(entry, isHoliday);
}

if (entryError) {
@@ -191,8 +115,10 @@ export const validateTimeLeaveRecord = (

// Check total hours
const totalHourError = checkTotalHours(
entries.filter((e) => e.type === "timeEntry"),
entries.filter((e) => e.type === "leaveEntry"),
entries.filter((e) => e.type === "timeEntry") as TimeEntry[],
entries.filter((e) => e.type === "leaveEntry") as LeaveEntry[],
isHoliday,
isFullTime,
);

if (totalHourError) {
@@ -206,6 +132,8 @@ export const validateTimeLeaveRecord = (
export const checkTotalHours = (
timeEntries: TimeEntry[],
leaves: LeaveEntry[],
isHoliday?: boolean,
isFullTime?: boolean,
): string | undefined => {
const leaveHours = leaves.reduce((acc, entry) => acc + entry.inputHours, 0);

@@ -219,6 +147,12 @@ export const checkTotalHours = (

if (totalInputHours + leaveHours > DAILY_NORMAL_MAX_HOURS) {
return "The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours or decrease the leave hours.";
} else if (
isFullTime &&
!isHoliday &&
totalInputHours + leaveHours !== DAILY_NORMAL_MAX_HOURS
) {
return "The daily normal hours (timesheet hours + leave hours) for full-time staffs should be {{DAILY_NORMAL_MAX_HOURS}}.";
} else if (
totalInputHours + totalOtHours + leaveHours >
TIMESHEET_DAILY_MAX_HOURS


+ 2
- 2
src/app/utils/formatUtil.ts Voir le fichier

@@ -134,8 +134,8 @@ export function convertLocaleStringToNumber(numberString: string): number {
}

export function timestampToDateString(timestamp: string): string {
if (timestamp === "0" || timestamp === null) {
return "-";
if (timestamp === null){
return "-"
}
const date = new Date(timestamp);
const year = date.getFullYear();


+ 2
- 2
src/components/CreateProject/ProjectClientDetails.tsx Voir le fichier

@@ -344,7 +344,7 @@ const ProjectClientDetails: React.FC<Props> = ({
/>
</Grid>

<Grid item xs={6}>
{/* <Grid item xs={6}>
<Checkbox
{...register("isClpProject")}
checked={Boolean(watch("isClpProject"))}
@@ -353,7 +353,7 @@ const ProjectClientDetails: React.FC<Props> = ({
<Typography variant="overline" display="inline">
{t("CLP Project")}
</Typography>
</Grid>
</Grid> */}
</Grid>
</Box>



+ 0
- 109
src/components/EnterLeave/EnterLeaveModal.tsx Voir le fichier

@@ -1,109 +0,0 @@
"use client";

import { useState } from "react";
import Button from "@mui/material/Button";
import { Card, Modal } from "@mui/material";
import TimesheetInputGrid from "./LeaveInputGrid";

// import { fetchLeaves } from "@/app/api/leave";

interface EnterTimesheetModalProps {
isOpen: boolean;
onClose: () => void;
modalStyle?: any;
}

const EnterTimesheetModal: React.FC<EnterTimesheetModalProps> = ({
...props
}) => {
const [lockConfirm, setLockConfirm] = useState(false);
const columns = [
{
id: "projectCode",
field: "projectCode",
headerName: "Project Code and Name",
flex: 1,
},
{
id: "task",
field: "task",
headerName: "Task",
flex: 1,
},
];

const rows = [
{
id: 1,
projectCode: "M1001",
task: "1.2",
},
{
id: 2,
projectCode: "M1301",
task: "1.1",
},
];

const fetchTimesheet = async () => {
// fetchLeaves();
// const res = await fetch(`http://localhost:8090/api/timesheets`, {
// // const res = await fetch(`${BASE_API_URL}/timesheets`, {
// method: "GET",
// mode: 'no-cors',
// });

// console.log(res.json);
};

return (
<Modal open={props.isOpen} onClose={props.onClose}>
<div>
{/* <Typography variant="h5" id="modal-title" sx={{flex:1}}>
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
Record Leave
</div>
</Typography> */}

<Card style={{
flex: 10,
marginBottom: "20px",
width: "90%",
// height: "80%",
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}>
<TimesheetInputGrid setLockConfirm={setLockConfirm}/>
<div
style={{
display: "flex",
justifyContent: "space-between",
width: "100%",
flex: 1,
padding: "20px",
}}
>
<Button
disabled={lockConfirm}
variant="contained"
onClick={props.onClose}
>
Confirm
</Button>
<Button
variant="contained"
onClick={props.onClose}
sx={{ "background-color": "#F890A5" }}
>
Cancel
</Button>
</div>
</Card>
</div>
</Modal>
);
};

export default EnterTimesheetModal;

+ 0
- 548
src/components/EnterLeave/LeaveInputGrid.tsx Voir le fichier

@@ -1,548 +0,0 @@
"use client";
import Grid from "@mui/material/Grid";
import Paper from "@mui/material/Paper";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import PageTitle from "../PageTitle/PageTitle";
import { Suspense } from "react";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import Link from "next/link";
import { t } from "i18next";
import {
Box,
Container,
Modal,
Select,
SelectChangeEvent,
Typography,
} from "@mui/material";
import { Close } from "@mui/icons-material";
import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import SaveIcon from "@mui/icons-material/Save";
import CancelIcon from "@mui/icons-material/Close";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import Swal from "sweetalert2";
import { msg } from "../Swal/CustomAlerts";
import React from "react";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import {
GridRowsProp,
GridRowModesModel,
GridRowModes,
DataGrid,
GridColDef,
GridToolbarContainer,
GridFooterContainer,
GridActionsCellItem,
GridEventListener,
GridRowId,
GridRowModel,
GridRowEditStopReasons,
GridEditInputCell,
GridValueSetterParams,
} from "@mui/x-data-grid";
import { LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs from "dayjs";
import { Props } from "react-intl/src/components/relative";

const weekdays = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];

interface BottomBarProps {
getHoursTotal: (column: string) => number;
setLockConfirm: (newLock: (oldLock: boolean) => boolean) => void;
setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void;
setRowModesModel: (
newModel: (oldModel: GridRowModesModel) => GridRowModesModel,
) => void;
}

interface EditToolbarProps {
// setDay: (newDay : dayjs.Dayjs) => void;
setDay: (newDay: (oldDay: dayjs.Dayjs) => dayjs.Dayjs) => void;
setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void;
setRowModesModel: (
newModel: (oldModel: GridRowModesModel) => GridRowModesModel,
) => void;
}

interface EditFooterProps {
setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void;
setRowModesModel: (
newModel: (oldModel: GridRowModesModel) => GridRowModesModel,
) => void;
}

const EditToolbar = (props: EditToolbarProps) => {
const { setDay } = props;
const [selectedDate, setSelectedDate] = useState<dayjs.Dayjs>(dayjs());

const handleClickLeft = () => {
if (selectedDate) {
const newDate = selectedDate.add(-7, "day");
setSelectedDate(newDate);
}
};
const handleClickRight = () => {
if (selectedDate) {
const newDate =
selectedDate.add(7, "day") > dayjs()
? dayjs()
: selectedDate.add(7, "day");
setSelectedDate(newDate);
}
};

const handleDateChange = (date: dayjs.Dayjs | Date | null) => {
const newDate = dayjs(date);
setSelectedDate(newDate);
};

useEffect(() => {
setDay((oldDay) => selectedDate);
}, [selectedDate]);

return (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<div
style={{
display: "flex",
justifyContent: "flex-end",
width: "100%",
paddingBottom: "20px",
}}
>
<Typography variant="h5" id="modal-title" sx={{ flex: 1 }}>
Record Leave
</Typography>
<Button
sx={{ "border-radius": "30%", marginRight: "20px" }}
variant="contained"
onClick={handleClickLeft}
>
<ArrowBackIcon />
</Button>
<DatePicker
value={selectedDate}
onChange={handleDateChange}
disableFuture={true}
/>
<Button
sx={{ "border-radius": "30%", margin: "0px 20px 0px 20px" }}
variant="contained"
onClick={handleClickRight}
>
<ArrowForwardIcon />
</Button>
</div>
</LocalizationProvider>
);
};

const BottomBar = (props: BottomBarProps) => {
const { setRows, setRowModesModel, getHoursTotal, setLockConfirm } = props;
// const getHoursTotal = props.getHoursTotal;
const [newId, setNewId] = useState(-1);
const [invalidDays, setInvalidDays] = useState(0);

const handleAddClick = () => {
const id = newId;
setNewId(newId - 1);
setRows((oldRows) => [
...oldRows,
{ id, projectCode: "", task: "", isNew: true },
]);
setRowModesModel((oldModel) => ({
...oldModel,
[id]: { mode: GridRowModes.Edit, fieldToFocus: "projectCode" },
}));
};

const totalColDef = {
flex: 1,
// style: {color:getHoursTotal('mon')>24?"red":"black"}
};

const TotalCell = ({ value }: Props) => {
const [invalid, setInvalid] = useState(false);

useEffect(() => {
const newInvalid = (value ?? 0) > 24;
setInvalid(newInvalid);
}, [value]);

return (
<Box flex={1} style={{ color: invalid ? "red" : "black" }}>
{value}
</Box>
);
};

const checkUnlockConfirmBtn = () => {
// setLockConfirm((oldLock)=> valid);
setLockConfirm((oldLock) =>
weekdays.every((weekday) => {
getHoursTotal(weekday) <= 24;
}),
);
};

return (
<div>
<div style={{ display: "flex", justifyContent: "flex", width: "100%" }}>
<Box flex={5.7} textAlign={"right"} marginRight="4rem">
<b>Total:</b>
</Box>
<TotalCell value={getHoursTotal("mon")} />
<TotalCell value={getHoursTotal("tue")} />
<TotalCell value={getHoursTotal("wed")} />
<TotalCell value={getHoursTotal("thu")} />
<TotalCell value={getHoursTotal("fri")} />
<TotalCell value={getHoursTotal("sat")} />
<TotalCell value={getHoursTotal("sun")} />
</div>
<Button
variant="outlined"
color="primary"
startIcon={<AddIcon />}
onClick={handleAddClick}
>
Add record
</Button>
</div>
);
};

const EditFooter = (props: EditFooterProps) => {
return (
<div style={{ display: "flex", justifyContent: "flex", width: "100%" }}>
<Box flex={1}>
<b>Total: </b>
</Box>
<Box flex={2}>ssss</Box>
</div>
);
};

interface TimesheetInputGridProps {
setLockConfirm: (newLock: (oldLock: boolean) => boolean) => void;
onClose?: () => void;
}

const initialRows: GridRowsProp = [
{
id: 1,
projectCode: "M1001",
task: "1.2",
mon: 2.5,
},
{
id: 2,
projectCode: "M1002",
task: "1.3",
mon: 3.25,
},
];

const options = ["M1001", "M1301", "M1354", "M1973"];
const options2 = [
"1.1 - Preparation of preliminary Cost Estimate / Cost Plan",
"1.2 - Cash flow forecast",
"1.3 - Cost studies fo alterative design solutions",
"1.4 = Attend design co-ordination / project review meetings",
"1.5 - Prepare / Review RIC",
];

const getDateForHeader = (date: dayjs.Dayjs, weekday: number) => {
if (date.day() == 0) {
return date.add(weekday - date.day() - 7, "day").format("DD MMM");
} else {
return date.add(weekday - date.day(), "day").format("DD MMM");
}
};

const TimesheetInputGrid: React.FC<TimesheetInputGridProps> = ({
...props
}) => {
const [rows, setRows] = useState(initialRows);
const [day, setDay] = useState(dayjs());
const [rowModesModel, setRowModesModel] = React.useState<GridRowModesModel>(
{},
);
const { setLockConfirm } = props;

const handleRowEditStop: GridEventListener<"rowEditStop"> = (
params,
event,
) => {
if (params.reason === GridRowEditStopReasons.rowFocusOut) {
event.defaultMuiPrevented = true;
}
};

const handleEditClick = (id: GridRowId) => () => {
setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } });
};

const handleSaveClick = (id: GridRowId) => () => {
setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } });
};

const handleDeleteClick = (id: GridRowId) => () => {
setRows(rows.filter((row) => row.id !== id));
};

const handleCancelClick = (id: GridRowId) => () => {
setRowModesModel({
...rowModesModel,
[id]: { mode: GridRowModes.View, ignoreModifications: true },
});

const editedRow = rows.find((row) => row.id === id);
if (editedRow!.isNew) {
setRows(rows.filter((row) => row.id !== id));
}
};

const processRowUpdate = (newRow: GridRowModel) => {
const updatedRow = { ...newRow, isNew: false };
setRows(rows.map((row) => (row.id === newRow.id ? updatedRow : row)));
return updatedRow;
};

const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => {
setRowModesModel(newRowModesModel);
};

const getHoursTotal = (column: any) => {
let sum = 0;
rows.forEach((row) => {
sum += row[column] ?? 0;
});
return sum;
};

const weekdayColConfig: any = {
type: "number",
// sortable: false,
//width: 100,
flex: 1,
align: "left",
headerAlign: "left",
editable: true,
renderEditCell: (value: any) => (
<GridEditInputCell
{...value}
inputProps={{
max: 24,
min: 0,
step: 0.25,
}}
/>
),
};

const columns: GridColDef[] = [
{
field: "actions",
type: "actions",
headerName: "Actions",
width: 100,
cellClassName: "actions",
getActions: ({ id }) => {
const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit;

if (isInEditMode) {
return [
<GridActionsCellItem
key={`actions-${id}-save`}
icon={<SaveIcon />}
title="Save"
label="Save"
sx={{
color: "primary.main",
}}
onClick={handleSaveClick(id)}
/>,
<GridActionsCellItem
key={`actions-${id}-cancel`}
icon={<CancelIcon />}
title="Cancel"
label="Cancel"
className="textPrimary"
onClick={handleCancelClick(id)}
color="inherit"
/>,
];
}

return [
<GridActionsCellItem
key={`actions-${id}-edit`}
icon={<EditIcon />}
title="Edit"
label="Edit"
className="textPrimary"
onClick={handleEditClick(id)}
color="inherit"
/>,
<GridActionsCellItem
key={`actions-${id}-delete`}
title="Delete"
label="Delete"
icon={<DeleteIcon />}
onClick={handleDeleteClick(id)}
sx={{ color: "red" }}
/>,
];
},
},
{
field: "projectCode",
headerName: "Project Code",
// width: 220,
flex: 2,
editable: true,
type: "singleSelect",
valueOptions: options,
},
{
field: "task",
headerName: "Task",
// width: 220,
flex: 3,
editable: true,
type: "singleSelect",
valueOptions: options2,
},
{
// Mon
field: "mon",
...weekdayColConfig,
renderHeader: () => {
return <div>Mon - {getDateForHeader(day, 1)}</div>;
},
},
{
// Tue
field: "tue",
...weekdayColConfig,
renderHeader: () => {
return <div>Tue - {getDateForHeader(day, 2)}</div>;
},
},
{
// Wed
field: "wed",
...weekdayColConfig,
renderHeader: () => {
return <div>Wed - {getDateForHeader(day, 3)}</div>;
},
},
{
// Thu
field: "thu",
...weekdayColConfig,
renderHeader: () => {
return <div>Thu - {getDateForHeader(day, 4)}</div>;
},
},
{
// Fri
field: "fri",
...weekdayColConfig,
renderHeader: () => {
return <div>Fri - {getDateForHeader(day, 5)}</div>;
},
},
{
// Sat
field: "sat",
...weekdayColConfig,
renderHeader: () => {
return <div>Sat - {getDateForHeader(day, 6)}</div>;
},
},
{
// Sun
field: "sun",
...weekdayColConfig,
renderHeader: () => {
return (
<div style={{ color: "red" }}>Sun - {getDateForHeader(day, 7)}</div>
);
},
},
// {
// field: 'joinDate',
// headerName: 'Join date',
// type: 'date',
// width: 180,
// editable: true,
// },
];

return (
<Box
sx={{
// marginBottom: '-5px',
display: "flex",
"flex-direction": "column",
// 'justify-content': 'flex-end',
padding: "20px",
height: "100%", //'30rem',
width: "100%",
"& .actions": {
color: "text.secondary",
},
"& .header": {
// border: 1,
// 'border-width': '1px',
// 'border-color': 'grey',
},
"& .textPrimary": {
color: "text.primary",
},
}}
>
<DataGrid
rows={rows}
columns={columns}
editMode="row"
rowModesModel={rowModesModel}
onRowModesModelChange={handleRowModesModelChange}
onRowEditStop={handleRowEditStop}
processRowUpdate={processRowUpdate}
disableRowSelectionOnClick={true}
disableColumnMenu={true}
hideFooterPagination={true}
slots={{
toolbar: EditToolbar,
// footer: EditFooter,
}}
slotProps={{
toolbar: { setDay, setRows, setRowModesModel },
// footer: { setDay, setRows, setRowModesModel },
}}
initialState={{
pagination: { paginationModel: { pageSize: 100 } },
}}
sx={{ flex: 1 }}
/>

<BottomBar
getHoursTotal={getHoursTotal}
setRows={setRows}
setRowModesModel={setRowModesModel}
setLockConfirm={setLockConfirm}
// sx={{flex:3}}
/>
</Box>
);
};

export default TimesheetInputGrid;

+ 0
- 1
src/components/EnterLeave/index.ts Voir le fichier

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

+ 0
- 109
src/components/EnterTimesheet/EnterTimesheetModal.tsx Voir le fichier

@@ -1,109 +0,0 @@
"use client";

import { useState } from "react";
import Button from "@mui/material/Button";
import { Card, Modal } from "@mui/material";
import TimesheetInputGrid from "./TimesheetInputGrid";

// import { fetchTimesheets } from "@/app/api/timesheets";

interface EnterTimesheetModalProps {
isOpen: boolean;
onClose: () => void;
modalStyle?: any;
}

const EnterTimesheetModal: React.FC<EnterTimesheetModalProps> = ({
...props
}) => {
const [lockConfirm, setLockConfirm] = useState(false);
const columns = [
{
id: "projectCode",
field: "projectCode",
headerName: "Project Code and Name",
flex: 1,
},
{
id: "task",
field: "task",
headerName: "Task",
flex: 1,
},
];

const rows = [
{
id: 1,
projectCode: "M1001",
task: "1.2",
},
{
id: 2,
projectCode: "M1301",
task: "1.1",
},
];

const fetchTimesheet = async () => {
// fetchTimesheets();
// const res = await fetch(`http://localhost:8090/api/timesheets`, {
// // const res = await fetch(`${BASE_API_URL}/timesheets`, {
// method: "GET",
// mode: 'no-cors',
// });

// console.log(res.json);
};

return (
<Modal open={props.isOpen} onClose={props.onClose}>
<div>
{/* <Typography variant="h5" id="modal-title" sx={{flex:1}}>
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
Timesheet Input
</div>
</Typography> */}

<Card style={{
flex: 10,
marginBottom: "20px",
width: "90%",
// height: "80%",
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}>
<TimesheetInputGrid setLockConfirm={setLockConfirm}/>
<div
style={{
display: "flex",
justifyContent: "space-between",
width: "100%",
flex: 1,
padding: "20px",
}}
>
<Button
disabled={lockConfirm}
variant="contained"
onClick={props.onClose}
>
Confirm
</Button>
<Button
variant="contained"
onClick={props.onClose}
sx={{ "background-color": "#F890A5" }}
>
Cancel
</Button>
</div>
</Card>
</div>
</Modal>
);
};

export default EnterTimesheetModal;

+ 0
- 548
src/components/EnterTimesheet/TimesheetInputGrid.tsx Voir le fichier

@@ -1,548 +0,0 @@
"use client";
import Grid from "@mui/material/Grid";
import Paper from "@mui/material/Paper";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import PageTitle from "../PageTitle/PageTitle";
import { Suspense } from "react";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import Link from "next/link";
import { t } from "i18next";
import {
Box,
Container,
Modal,
Select,
SelectChangeEvent,
Typography,
} from "@mui/material";
import { Close } from "@mui/icons-material";
import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import SaveIcon from "@mui/icons-material/Save";
import CancelIcon from "@mui/icons-material/Close";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import Swal from "sweetalert2";
import { msg } from "../Swal/CustomAlerts";
import React from "react";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import {
GridRowsProp,
GridRowModesModel,
GridRowModes,
DataGrid,
GridColDef,
GridToolbarContainer,
GridFooterContainer,
GridActionsCellItem,
GridEventListener,
GridRowId,
GridRowModel,
GridRowEditStopReasons,
GridEditInputCell,
GridValueSetterParams,
} from "@mui/x-data-grid";
import { LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs from "dayjs";
import { Props } from "react-intl/src/components/relative";

const weekdays = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];

interface BottomBarProps {
getHoursTotal: (column: string) => number;
setLockConfirm: (newLock: (oldLock: boolean) => boolean) => void;
setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void;
setRowModesModel: (
newModel: (oldModel: GridRowModesModel) => GridRowModesModel,
) => void;
}

interface EditToolbarProps {
// setDay: (newDay : dayjs.Dayjs) => void;
setDay: (newDay: (oldDay: dayjs.Dayjs) => dayjs.Dayjs) => void;
setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void;
setRowModesModel: (
newModel: (oldModel: GridRowModesModel) => GridRowModesModel,
) => void;
}

interface EditFooterProps {
setRows: (newRows: (oldRows: GridRowsProp) => GridRowsProp) => void;
setRowModesModel: (
newModel: (oldModel: GridRowModesModel) => GridRowModesModel,
) => void;
}

const EditToolbar = (props: EditToolbarProps) => {
const { setDay } = props;
const [selectedDate, setSelectedDate] = useState<dayjs.Dayjs>(dayjs());

const handleClickLeft = () => {
if (selectedDate) {
const newDate = selectedDate.add(-7, "day");
setSelectedDate(newDate);
}
};
const handleClickRight = () => {
if (selectedDate) {
const newDate =
selectedDate.add(7, "day") > dayjs()
? dayjs()
: selectedDate.add(7, "day");
setSelectedDate(newDate);
}
};

const handleDateChange = (date: dayjs.Dayjs | Date | null) => {
const newDate = dayjs(date);
setSelectedDate(newDate);
};

useEffect(() => {
setDay((oldDay) => selectedDate);
}, [selectedDate]);

return (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<div
style={{
display: "flex",
justifyContent: "flex-end",
width: "100%",
paddingBottom: "20px",
}}
>
<Typography variant="h5" id="modal-title" sx={{ flex: 1 }}>
Timesheet Input
</Typography>
<Button
sx={{ "border-radius": "30%", marginRight: "20px" }}
variant="contained"
onClick={handleClickLeft}
>
<ArrowBackIcon />
</Button>
<DatePicker
value={selectedDate}
onChange={handleDateChange}
disableFuture={true}
/>
<Button
sx={{ "border-radius": "30%", margin: "0px 20px 0px 20px" }}
variant="contained"
onClick={handleClickRight}
>
<ArrowForwardIcon />
</Button>
</div>
</LocalizationProvider>
);
};

const BottomBar = (props: BottomBarProps) => {
const { setRows, setRowModesModel, getHoursTotal, setLockConfirm } = props;
// const getHoursTotal = props.getHoursTotal;
const [newId, setNewId] = useState(-1);
const [invalidDays, setInvalidDays] = useState(0);

const handleAddClick = () => {
const id = newId;
setNewId(newId - 1);
setRows((oldRows) => [
...oldRows,
{ id, projectCode: "", task: "", isNew: true },
]);
setRowModesModel((oldModel) => ({
...oldModel,
[id]: { mode: GridRowModes.Edit, fieldToFocus: "projectCode" },
}));
};

const totalColDef = {
flex: 1,
// style: {color:getHoursTotal('mon')>24?"red":"black"}
};

const TotalCell = ({ value }: Props) => {
const [invalid, setInvalid] = useState(false);

useEffect(() => {
const newInvalid = (value ?? 0) > 24;
setInvalid(newInvalid);
}, [value]);

return (
<Box flex={1} style={{ color: invalid ? "red" : "black" }}>
{value}
</Box>
);
};

const checkUnlockConfirmBtn = () => {
// setLockConfirm((oldLock)=> valid);
setLockConfirm((oldLock) =>
weekdays.every((weekday) => {
getHoursTotal(weekday) <= 24;
}),
);
};

return (
<div>
<div style={{ display: "flex", justifyContent: "flex", width: "100%" }}>
<Box flex={5.7} textAlign={"right"} marginRight="4rem">
<b>Total:</b>
</Box>
<TotalCell value={getHoursTotal("mon")} />
<TotalCell value={getHoursTotal("tue")} />
<TotalCell value={getHoursTotal("wed")} />
<TotalCell value={getHoursTotal("thu")} />
<TotalCell value={getHoursTotal("fri")} />
<TotalCell value={getHoursTotal("sat")} />
<TotalCell value={getHoursTotal("sun")} />
</div>
<Button
variant="outlined"
color="primary"
startIcon={<AddIcon />}
onClick={handleAddClick}
>
Add record
</Button>
</div>
);
};

const EditFooter = (props: EditFooterProps) => {
return (
<div style={{ display: "flex", justifyContent: "flex", width: "100%" }}>
<Box flex={1}>
<b>Total: </b>
</Box>
<Box flex={2}>ssss</Box>
</div>
);
};

interface TimesheetInputGridProps {
setLockConfirm: (newLock: (oldLock: boolean) => boolean) => void;
onClose?: () => void;
}

const initialRows: GridRowsProp = [
{
id: 1,
projectCode: "M1001",
task: "1.2",
mon: 2.5,
},
{
id: 2,
projectCode: "M1002",
task: "1.3",
mon: 3.25,
},
];

const options = ["M1001", "M1301", "M1354", "M1973"];
const options2 = [
"1.1 - Preparation of preliminary Cost Estimate / Cost Plan",
"1.2 - Cash flow forecast",
"1.3 - Cost studies fo alterative design solutions",
"1.4 = Attend design co-ordination / project review meetings",
"1.5 - Prepare / Review RIC",
];

const getDateForHeader = (date: dayjs.Dayjs, weekday: number) => {
if (date.day() == 0) {
return date.add(weekday - date.day() - 7, "day").format("DD MMM");
} else {
return date.add(weekday - date.day(), "day").format("DD MMM");
}
};

const TimesheetInputGrid: React.FC<TimesheetInputGridProps> = ({
...props
}) => {
const [rows, setRows] = useState(initialRows);
const [day, setDay] = useState(dayjs());
const [rowModesModel, setRowModesModel] = React.useState<GridRowModesModel>(
{},
);
const { setLockConfirm } = props;

const handleRowEditStop: GridEventListener<"rowEditStop"> = (
params,
event,
) => {
if (params.reason === GridRowEditStopReasons.rowFocusOut) {
event.defaultMuiPrevented = true;
}
};

const handleEditClick = (id: GridRowId) => () => {
setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } });
};

const handleSaveClick = (id: GridRowId) => () => {
setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } });
};

const handleDeleteClick = (id: GridRowId) => () => {
setRows(rows.filter((row) => row.id !== id));
};

const handleCancelClick = (id: GridRowId) => () => {
setRowModesModel({
...rowModesModel,
[id]: { mode: GridRowModes.View, ignoreModifications: true },
});

const editedRow = rows.find((row) => row.id === id);
if (editedRow!.isNew) {
setRows(rows.filter((row) => row.id !== id));
}
};

const processRowUpdate = (newRow: GridRowModel) => {
const updatedRow = { ...newRow, isNew: false };
setRows(rows.map((row) => (row.id === newRow.id ? updatedRow : row)));
return updatedRow;
};

const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => {
setRowModesModel(newRowModesModel);
};

const getHoursTotal = (column: any) => {
let sum = 0;
rows.forEach((row) => {
sum += row[column] ?? 0;
});
return sum;
};

const weekdayColConfig: any = {
type: "number",
// sortable: false,
//width: 100,
flex: 1,
align: "left",
headerAlign: "left",
editable: true,
renderEditCell: (value: any) => (
<GridEditInputCell
{...value}
inputProps={{
max: 24,
min: 0,
step: 0.25,
}}
/>
),
};

const columns: GridColDef[] = [
{
field: "actions",
type: "actions",
headerName: "Actions",
width: 100,
cellClassName: "actions",
getActions: ({ id }) => {
const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit;

if (isInEditMode) {
return [
<GridActionsCellItem
key={`actions-${id}-save`}
icon={<SaveIcon />}
title="Save"
label="Save"
sx={{
color: "primary.main",
}}
onClick={handleSaveClick(id)}
/>,
<GridActionsCellItem
key={`actions-${id}-cancel`}
icon={<CancelIcon />}
title="Cancel"
label="Cancel"
className="textPrimary"
onClick={handleCancelClick(id)}
color="inherit"
/>,
];
}

return [
<GridActionsCellItem
key={`actions-${id}-edit`}
icon={<EditIcon />}
title="Edit"
label="Edit"
className="textPrimary"
onClick={handleEditClick(id)}
color="inherit"
/>,
<GridActionsCellItem
key={`actions-${id}-delete`}
title="Delete"
label="Delete"
icon={<DeleteIcon />}
onClick={handleDeleteClick(id)}
sx={{ color: "red" }}
/>,
];
},
},
{
field: "projectCode",
headerName: "Project Code",
// width: 220,
flex: 2,
editable: true,
type: "singleSelect",
valueOptions: options,
},
{
field: "task",
headerName: "Task",
// width: 220,
flex: 3,
editable: true,
type: "singleSelect",
valueOptions: options2,
},
{
// Mon
field: "mon",
...weekdayColConfig,
renderHeader: () => {
return <div>Mon - {getDateForHeader(day, 1)}</div>;
},
},
{
// Tue
field: "tue",
...weekdayColConfig,
renderHeader: () => {
return <div>Tue - {getDateForHeader(day, 2)}</div>;
},
},
{
// Wed
field: "wed",
...weekdayColConfig,
renderHeader: () => {
return <div>Wed - {getDateForHeader(day, 3)}</div>;
},
},
{
// Thu
field: "thu",
...weekdayColConfig,
renderHeader: () => {
return <div>Thu - {getDateForHeader(day, 4)}</div>;
},
},
{
// Fri
field: "fri",
...weekdayColConfig,
renderHeader: () => {
return <div>Fri - {getDateForHeader(day, 5)}</div>;
},
},
{
// Sat
field: "sat",
...weekdayColConfig,
renderHeader: () => {
return <div>Sat - {getDateForHeader(day, 6)}</div>;
},
},
{
// Sun
field: "sun",
...weekdayColConfig,
renderHeader: () => {
return (
<div style={{ color: "red" }}>Sun - {getDateForHeader(day, 7)}</div>
);
},
},
// {
// field: 'joinDate',
// headerName: 'Join date',
// type: 'date',
// width: 180,
// editable: true,
// },
];

return (
<Box
sx={{
// marginBottom: '-5px',
display: "flex",
"flex-direction": "column",
// 'justify-content': 'flex-end',
padding: "20px",
height: "100%", //'30rem',
width: "100%",
"& .actions": {
color: "text.secondary",
},
"& .header": {
// border: 1,
// 'border-width': '1px',
// 'border-color': 'grey',
},
"& .textPrimary": {
color: "text.primary",
},
}}
>
<DataGrid
rows={rows}
columns={columns}
editMode="row"
rowModesModel={rowModesModel}
onRowModesModelChange={handleRowModesModelChange}
onRowEditStop={handleRowEditStop}
processRowUpdate={processRowUpdate}
disableRowSelectionOnClick={true}
disableColumnMenu={true}
hideFooterPagination={true}
slots={{
toolbar: EditToolbar,
// footer: EditFooter,
}}
slotProps={{
toolbar: { setDay, setRows, setRowModesModel },
// footer: { setDay, setRows, setRowModesModel },
}}
initialState={{
pagination: { paginationModel: { pageSize: 100 } },
}}
sx={{ flex: 1 }}
/>

<BottomBar
getHoursTotal={getHoursTotal}
setRows={setRows}
setRowModesModel={setRowModesModel}
setLockConfirm={setLockConfirm}
// sx={{flex:3}}
/>
</Box>
);
};

export default TimesheetInputGrid;

+ 0
- 1
src/components/EnterTimesheet/index.ts Voir le fichier

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

+ 1
- 1
src/components/InvoiceSearch/InvoiceSearch.tsx Voir le fichier

@@ -100,7 +100,7 @@ const InvoiceSearch: React.FC<Props> = ({ issuedInvoice, receivedInvoice, invoic
formData.append('multipartFileList', file);

const response = await importIssuedInovice(formData);
console.log(response);
// response: status, message, projectList, emptyRowList, invoiceList

if (response.status) {
successDialog(t("Import Success"), t).then(() => {


+ 285
- 0
src/components/LeaveModal/LeaveCalendar.tsx Voir le fichier

@@ -0,0 +1,285 @@
import React, { useCallback, useMemo, useState } from "react";

import { HolidaysResult } from "@/app/api/holidays";
import { LeaveType } from "@/app/api/timesheets";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import { Box, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
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 {
LeaveEntry,
RecordLeaveInput,
RecordTimesheetInput,
saveLeave,
} from "@/app/api/timesheets/actions";
import { Props as LeaveEditModalProps } from "../LeaveTable/LeaveEditModal";
import LeaveEditModal from "../LeaveTable/LeaveEditModal";
import dayjs from "dayjs";
import { checkTotalHours } from "@/app/api/timesheets/utils";
import unionBy from "lodash/unionBy";

export interface Props {
leaveTypes: LeaveType[];
companyHolidays: HolidaysResult[];
allProjects: ProjectWithTasks[];
leaveRecords: RecordLeaveInput;
timesheetRecords: RecordTimesheetInput;
isFullTime: boolean;
}

interface EventClickArg {
event: {
start: Date | null;
startStr: string;
extendedProps: {
calendar?: string;
entry?: LeaveEntry;
};
};
}

const LeaveCalendar: React.FC<Props> = ({
companyHolidays,
allProjects,
leaveTypes,
timesheetRecords,
leaveRecords,
isFullTime,
}) => {
const { t } = useTranslation(["home", "common"]);

const theme = useTheme();

const projectMap = useMemo(() => {
return allProjects.reduce<{
[id: ProjectWithTasks["id"]]: ProjectWithTasks;
}>((acc, project) => {
return { ...acc, [project.id]: project };
}, {});
}, [allProjects]);

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

const [localLeaveRecords, setLocalLeaveEntries] = useState(leaveRecords);

// 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: defaultValues ? { ...defaultValues } : undefined,
recordDate,
isHoliday,
onDelete: defaultValues
? async () => {
if (!recordDate || !leaveRecords[recordDate]) {
return;
}
const leaveEntriesAtDate = leaveRecords[recordDate];
const newLeaveRecords = {
...leaveRecords,
[recordDate!]: leaveEntriesAtDate.filter(
(e) => e.id !== defaultValues.id,
),
};
const savedLeaveRecords = await saveLeave(newLeaveRecords);
setLocalLeaveEntries(savedLeaveRecords);
setLeaveEditModalOpen(false);
}
: undefined,
});
setLeaveEditModalOpen(true);
},
[leaveRecords],
);

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

// calendar related
const holidays = useMemo(() => {
return [
...getPublicHolidaysForNYears(2),
...companyHolidays.map((h) => ({
title: h.name,
date: convertDateArrayToString(h.date, INPUT_DATE_FORMAT),
extendedProps: {
calender: "holiday",
},
})),
].map((e) => ({
...e,
backgroundColor: theme.palette.error.main,
borderColor: theme.palette.error.main,
}));
}, [companyHolidays, theme.palette.error.main]);

const leaveEntries = useMemo(
() =>
Object.keys(localLeaveRecords).flatMap((date, index) => {
return localLeaveRecords[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,
},
}));
}),
[leaveMap, localLeaveRecords, t, theme],
);

const timeEntries = useMemo(
() =>
Object.keys(timesheetRecords).flatMap((date, index) => {
return timesheetRecords[date].map((entry) => ({
id: `${date}-${index}-time-${entry.id}`,
date,
title: `${t("{{count}} hour", {
ns: "common",
count: (entry.inputHours || 0) + (entry.otHours || 0),
})} (${
entry.projectId
? projectMap[entry.projectId].code
: t("Non-billable task")
})`,
backgroundColor: theme.palette.info.main,
borderColor: theme.palette.info.main,
extendedProps: {
calendar: "timeEntry",
entry,
},
}));
}),
[projectMap, timesheetRecords, t, theme],
);

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 === "leaveEntry" &&
event.extendedProps.entry
) {
openLeaveEditModal(
event.extendedProps.entry as LeaveEntry,
event.startStr,
Boolean(isHoliday),
);
}
},
[companyHolidays, openLeaveEditModal],
);

const handleDateClick = useCallback(
(e: { dateStr: string; dayEl: HTMLElement }) => {
const dayJsObj = dayjs(e.dateStr);
const holiday = getHolidayForDate(e.dateStr, companyHolidays);
const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6;

openLeaveEditModal(undefined, e.dateStr, Boolean(isHoliday));
},
[companyHolidays, openLeaveEditModal],
);

const checkTotalHoursForDate = useCallback(
(newEntry: LeaveEntry, date?: string) => {
if (!date) {
throw Error("Invalid date");
}
const dayJsObj = dayjs(date);
const holiday = getHolidayForDate(date, companyHolidays);
const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6;

const leaves = localLeaveRecords[date] || [];
const timesheets = timesheetRecords[date] || [];

const leavesWithNewEntry = unionBy(
[newEntry as LeaveEntry],
leaves,
"id",
);

const totalHourError = checkTotalHours(
timesheets,
leavesWithNewEntry,
Boolean(isHoliday),
isFullTime,
);

if (totalHourError) throw Error(totalHourError);
},
[companyHolidays, isFullTime, localLeaveRecords, timesheetRecords],
);

const handleSaveLeave = useCallback(
async (leaveEntry: LeaveEntry, recordDate?: string) => {
checkTotalHoursForDate(leaveEntry, recordDate);
const leaveEntriesAtDate = leaveRecords[recordDate!] || [];
const newLeaveRecords = {
...leaveRecords,
[recordDate!]: [
...leaveEntriesAtDate.filter((e) => e.id !== leaveEntry.id),
leaveEntry,
],
};
const savedLeaveRecords = await saveLeave(newLeaveRecords);
setLocalLeaveEntries(savedLeaveRecords);
setLeaveEditModalOpen(false);
},
[checkTotalHoursForDate, leaveRecords],
);

return (
<Box>
<StyledFullCalendar
plugins={[dayGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
buttonText={{ today: t("Today") }}
events={[...holidays, ...timeEntries, ...leaveEntries]}
eventClick={handleEventClick}
dateClick={handleDateClick}
/>
<LeaveEditModal
modalSx={{ maxWidth: 400 }}
leaveTypes={leaveTypes}
open={leaveEditModalOpen}
onClose={closeLeaveEditModal}
onSave={handleSaveLeave}
{...leaveEditModalProps}
/>
</Box>
);
};

export default LeaveCalendar;

+ 45
- 184
src/components/LeaveModal/LeaveModal.tsx Voir le fichier

@@ -1,46 +1,16 @@
import React, { useCallback, useEffect, useMemo } from "react";
import useIsMobile from "@/app/utils/useIsMobile";
import React from "react";
import FullscreenModal from "../FullscreenModal";
import {
Box,
Button,
Card,
CardActions,
CardContent,
Modal,
ModalProps,
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,
RecordTimesheetInput,
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";
import FullscreenModal from "../FullscreenModal";
import MobileLeaveTable from "../LeaveTable/MobileLeaveTable";
import useIsMobile from "@/app/utils/useIsMobile";
import { HolidaysResult } from "@/app/api/holidays";
import {
DAILY_NORMAL_MAX_HOURS,
TIMESHEET_DAILY_MAX_HOURS,
validateLeaveRecord,
} from "@/app/api/timesheets/utils";
import ErrorAlert from "../ErrorAlert";

interface Props {
isOpen: boolean;
onClose: () => void;
defaultLeaveRecords?: RecordLeaveInput;
leaveTypes: LeaveType[];
timesheetRecords: RecordTimesheetInput;
companyHolidays: HolidaysResult[];
}
import LeaveCalendar, { Props as LeaveCalendarProps } from "./LeaveCalendar";

const modalSx: SxProps = {
position: "absolute",
@@ -52,167 +22,58 @@ const modalSx: SxProps = {
maxWidth: 1400,
};

interface Props extends LeaveCalendarProps {
open: boolean;
onClose: () => void;
}

const LeaveModal: React.FC<Props> = ({
isOpen,
open,
onClose,
defaultLeaveRecords,
timesheetRecords,
leaveTypes,
companyHolidays,
allProjects,
leaveRecords,
timesheetRecords,
isFullTime,
}) => {
const { t } = useTranslation("home");
const isMobile = useIsMobile();

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 });
useEffect(() => {
formProps.reset(defaultValues);
}, [defaultValues, formProps]);

const onSubmit = useCallback<SubmitHandler<RecordLeaveInput>>(
async (data) => {
const errors = validateLeaveRecord(
data,
timesheetRecords,
companyHolidays,
);
if (errors) {
Object.keys(errors).forEach((date) =>
formProps.setError(date, {
message: errors[date],
}),
);
return;
}
const savedRecords = await saveLeave(data);

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();
},
[companyHolidays, formProps, onClose, timesheetRecords],
);

const onCancel = useCallback(() => {
formProps.reset(defaultValues);
onClose();
}, [defaultValues, formProps, onClose]);

const onModalClose = useCallback<NonNullable<ModalProps["onClose"]>>(
(_, reason) => {
if (reason !== "backdropClick") {
onCancel();
}
},
[onCancel],
);

const errorComponent = (
<ErrorAlert
errors={Object.keys(formProps.formState.errors).map((date) => {
const error = formProps.formState.errors[date]?.message;
return error
? `${date}: ${t(error, {
TIMESHEET_DAILY_MAX_HOURS,
DAILY_NORMAL_MAX_HOURS,
})}`
: undefined;
})}
const title = t("Record leave");
const content = (
<LeaveCalendar
isFullTime={isFullTime}
leaveTypes={leaveTypes}
companyHolidays={companyHolidays}
allProjects={allProjects}
leaveRecords={leaveRecords}
timesheetRecords={timesheetRecords}
/>
);

const matches = useIsMobile();

return (
<FormProvider {...formProps}>
{!matches ? (
// Desktop version
<Modal open={isOpen} onClose={onModalClose}>
<Card sx={modalSx}>
<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
companyHolidays={companyHolidays}
leaveTypes={leaveTypes}
timesheetRecords={timesheetRecords}
/>
</Box>
{errorComponent}
<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>
</Card>
</Modal>
) : (
// Mobile version
<FullscreenModal
open={isOpen}
onClose={onModalClose}
closeModal={onCancel}
>
<Box
display="flex"
flexDirection="column"
gap={2}
height="100%"
component="form"
onSubmit={formProps.handleSubmit(onSubmit)}
>
<Typography variant="h6" padding={2} flex="none">
{t("Record Leave")}
</Typography>
<MobileLeaveTable
companyHolidays={companyHolidays}
leaveTypes={leaveTypes}
timesheetRecords={timesheetRecords}
errorComponent={errorComponent}
/>
return isMobile ? (
<FullscreenModal open={open} onClose={onClose} closeModal={onClose}>
<Box display="flex" flexDirection="column" gap={2} height="100%">
<Typography variant="h6" flex="none" padding={2}>
{title}
</Typography>
<Box paddingInline={2}>{content}</Box>
</Box>
</FullscreenModal>
) : (
<Modal open={open} onClose={onClose}>
<Card sx={modalSx}>
<CardContent>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{title}
</Typography>
<Box maxHeight={900} overflow="scroll">
{content}
</Box>
</FullscreenModal>
)}
</FormProvider>
</CardContent>
</Card>
</Modal>
);
};



+ 2
- 4
src/components/Logo/Logo.tsx Voir le fichier

@@ -1,5 +1,3 @@
import { Grid } from "@mui/material";

interface Props {
width?: number;
height?: number;
@@ -16,7 +14,7 @@ const Logo: React.FC<Props> = ({ width, height }) => {
<g clipPath="url(#a)">

<path id="logo"
fill="#89ba17" stroke="#89ba17" stroke-width="1"
fill="#89ba17" stroke="#89ba17" strokeWidth="1"
d="M 98.00,125.00
C 92.11,126.67 84.23,126.00 78.00,126.00
68.19,126.00 48.68,126.75 40.00,125.00
@@ -66,7 +64,7 @@ const Logo: React.FC<Props> = ({ width, height }) => {
41.00,156.00 39.00,156.00 39.00,156.00 Z" />

<path id="word"
fill="#111927" stroke="#111927" stroke-width="1"
fill="#111927" stroke="#111927" strokeWidth="1"
d="M 273.00,64.00
C 273.00,64.00 279.96,66.35 279.96,66.35
283.26,67.45 289.15,67.63 290.83,63.79


+ 8
- 8
src/components/NavigationContent/NavigationContent.tsx Voir le fichier

@@ -67,16 +67,16 @@ import {
MAINTAIN_GROUP,
MAINTAIN_HOLIDAY,
VIEW_PROJECT_RESOURCE_CONSUMPTION_RANKING,
GENERATE_LATE_START_REPORTS,
GENERATE_LATE_START_REPORT,
GENERATE_PROJECT_POTENTIAL_DELAY_REPORT,
GENERATE_RESOURCE_OVERCONSUMPTION_REPORT,
GENERATE_COST_ANT_EXPENSE_REPORT,
GENERATE_COST_AND_EXPENSE_REPORT,
GENERATE_PROJECT_COMPLETION_REPORT,
GENERATE_PROJECT_PANDL_REPORT,
GENERATE_FINANCIAL_STATUS_REPORT,
GENERATE_PROJECT_CASH_FLOW_REPORT,
GENERATE_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT,
GENERATE_CROSS_TEAM_CHARGE_REPORT,
GENERATE_CROSS_TEAM_CHARGE_REPORT
} from "@/middleware";
import { SessionWithAbilities } from "../AppBar/NavigationToggle";
import { authOptions } from "@/config/authConfig";
@@ -190,16 +190,16 @@ const NavigationContent: React.FC<Props> = ({ abilities, username }) => {
label: "Analysis Report",
path: "",
isHidden: ![
GENERATE_LATE_START_REPORTS,
GENERATE_LATE_START_REPORT,
GENERATE_PROJECT_POTENTIAL_DELAY_REPORT,
GENERATE_RESOURCE_OVERCONSUMPTION_REPORT,
GENERATE_COST_ANT_EXPENSE_REPORT,
GENERATE_COST_AND_EXPENSE_REPORT,
GENERATE_PROJECT_COMPLETION_REPORT,
GENERATE_PROJECT_PANDL_REPORT,
GENERATE_FINANCIAL_STATUS_REPORT,
GENERATE_PROJECT_CASH_FLOW_REPORT,
GENERATE_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT,
GENERATE_CROSS_TEAM_CHARGE_REPORT,
GENERATE_CROSS_TEAM_CHARGE_REPORT
].some((ability) =>
abilities!.includes(ability),
),
@@ -208,7 +208,7 @@ const NavigationContent: React.FC<Props> = ({ abilities, username }) => {
icon: <Analytics />,
label: "Late Start Report",
path: "/analytics/LateStartReport",
isHidden: ![GENERATE_LATE_START_REPORTS].some((ability) =>
isHidden: ![GENERATE_LATE_START_REPORT].some((ability) =>
abilities!.includes(ability),
),
},
@@ -232,7 +232,7 @@ const NavigationContent: React.FC<Props> = ({ abilities, username }) => {
icon: <Analytics />,
label: "Cost and Expense Report",
path: "/analytics/CostandExpenseReport",
isHidden: ![GENERATE_COST_ANT_EXPENSE_REPORT].some((ability) =>
isHidden: ![GENERATE_COST_AND_EXPENSE_REPORT].some((ability) =>
abilities!.includes(ability),
),
},


+ 196
- 0
src/components/PastEntryCalendar/MonthlySummary.tsx Voir le fichier

@@ -0,0 +1,196 @@
import {
RecordLeaveInput,
RecordTimesheetInput,
} from "@/app/api/timesheets/actions";
import {
Box,
Card,
CardActionArea,
CardContent,
Stack,
Typography,
} from "@mui/material";
import union from "lodash/union";
import { useCallback, useMemo } from "react";
import dayjs, { Dayjs } from "dayjs";
import { getHolidayForDate } from "@/app/utils/holidayUtils";
import { HolidaysResult } from "@/app/api/holidays";
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil";
import { useTranslation } from "react-i18next";
import pickBy from "lodash/pickBy";

interface Props {
currentMonth: Dayjs;
timesheet: RecordTimesheetInput;
leaves: RecordLeaveInput;
companyHolidays: HolidaysResult[];
onDateSelect: (date: string) => void;
}

const MonthlySummary: React.FC<Props> = ({
timesheet,
leaves,
currentMonth,
companyHolidays,
onDateSelect,
}) => {
const {
t,
i18n: { language },
} = useTranslation("home");

const timesheetForCurrentMonth = useMemo(() => {
return pickBy(timesheet, (_, date) => {
return currentMonth.isSame(dayjs(date), "month");
});
}, [currentMonth, timesheet]);

const leavesForCurrentMonth = useMemo(() => {
return pickBy(leaves, (_, date) => {
return currentMonth.isSame(dayjs(date), "month");
});
}, [currentMonth, leaves]);

const days = useMemo(() => {
return union(
Object.keys(timesheetForCurrentMonth),
Object.keys(leavesForCurrentMonth),
);
}, [timesheetForCurrentMonth, leavesForCurrentMonth]).sort();

const makeSelectDate = useCallback(
(date: string) => () => {
onDateSelect(date);
},
[onDateSelect],
);

return (
<Stack
gap={2}
marginBlockEnd={2}
minWidth={{ sm: 375 }}
maxHeight={{ sm: 500 }}
>
<Typography variant="overline">{t("Monthly Summary")}</Typography>
<Box sx={{ overflowY: "scroll" }} flex={1}>
{days.map((day, index) => {
const dayJsObj = dayjs(day);

const holiday = getHolidayForDate(day, companyHolidays);
const isHoliday =
holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6;

const ls = leavesForCurrentMonth[day];
const leaveHours =
ls?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0;

const ts = timesheetForCurrentMonth[day];
const timesheetNormalHours =
ts?.reduce((acc, entry) => acc + (entry.inputHours || 0), 0) || 0;
const timesheetOtHours =
ts?.reduce((acc, entry) => acc + (entry.otHours || 0), 0) || 0;

const timesheetHours = timesheetNormalHours + timesheetOtHours;

return (
<Card
key={`${day}-${index}`}
sx={{ marginBlockEnd: 2, marginInline: 2 }}
>
<CardActionArea onClick={makeSelectDate(day)}>
<CardContent sx={{ padding: 3 }}>
<Typography
variant="overline"
component="div"
sx={{
color: isHoliday ? "error.main" : undefined,
}}
>
{shortDateFormatter(language).format(dayJsObj.toDate())}
{holiday && (
<Typography
marginInlineStart={1}
variant="caption"
>{`(${holiday.title})`}</Typography>
)}
</Typography>
<Stack spacing={1}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
flexWrap: "wrap",
alignItems: "baseline",
}}
>
<Typography variant="body2">
{t("Timesheet Hours")}
</Typography>
<Typography>
{manhourFormatter.format(timesheetHours)}
</Typography>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
flexWrap: "wrap",
alignItems: "baseline",
}}
>
<Typography variant="body2">
{t("Leave Hours")}
</Typography>
<Typography>
{manhourFormatter.format(leaveHours)}
</Typography>
</Box>

<Box
sx={{
display: "flex",
justifyContent: "space-between",
flexWrap: "wrap",
alignItems: "baseline",
}}
>
<Typography variant="body2">
{t("Daily Total Hours")}
</Typography>
<Typography>
{manhourFormatter.format(timesheetHours + leaveHours)}
</Typography>
</Box>
</Stack>
</CardContent>
</CardActionArea>
</Card>
);
})}
</Box>
<Typography variant="overline">
{`${t("Total Monthly Work Hours")}: ${manhourFormatter.format(
Object.values(timesheetForCurrentMonth)
.flatMap((entries) => entries)
.map((entry) => (entry.inputHours ?? 0) + (entry.otHours ?? 0))
.reduce((acc, cur) => {
return acc + cur;
}, 0),
)}`}
</Typography>
<Typography variant="overline">
{`${t("Total Monthly Leave Hours")}: ${manhourFormatter.format(
Object.values(leavesForCurrentMonth)
.flatMap((entries) => entries)
.map((entry) => entry.inputHours)
.reduce((acc, cur) => {
return acc + cur;
}, 0),
)}`}
</Typography>
</Stack>
);
};

export default MonthlySummary;

+ 3
- 0
src/components/PastEntryCalendar/PastEntryCalendar.tsx Voir le fichier

@@ -26,6 +26,7 @@ export interface Props {
timesheet: RecordTimesheetInput;
leaves: RecordLeaveInput;
onDateSelect: (date: string) => void;
onMonthChange: (day: Dayjs) => void;
}

const getColor = (
@@ -72,6 +73,7 @@ const PastEntryCalendar: React.FC<Props> = ({
timesheet,
leaves,
onDateSelect,
onMonthChange,
}) => {
const {
i18n: { language },
@@ -88,6 +90,7 @@ const PastEntryCalendar: React.FC<Props> = ({
>
<DateCalendar
onChange={onChange}
onMonthChange={onMonthChange}
disableFuture
// eslint-disable-next-line @typescript-eslint/no-explicit-any
slots={{ day: EntryDay as any }}


+ 56
- 38
src/components/PastEntryCalendar/PastEntryCalendarModal.tsx Voir le fichier

@@ -20,12 +20,17 @@ import { ProjectWithTasks } from "@/app/api/projects";
import { LeaveType } from "@/app/api/timesheets";
import useIsMobile from "@/app/utils/useIsMobile";
import FullscreenModal from "../FullscreenModal";
import MonthlySummary from "./MonthlySummary";
import { HolidaysResult } from "@/app/api/holidays";
import dayjs from "dayjs";

interface Props extends Omit<PastEntryCalendarProps, "onDateSelect"> {
interface Props
extends Omit<PastEntryCalendarProps, "onDateSelect" | "onMonthChange"> {
open: boolean;
handleClose: () => void;
leaveTypes: LeaveType[];
allProjects: ProjectWithTasks[];
companyHolidays: HolidaysResult[];
}

const Indicator = styled(Box)(() => ({
@@ -45,6 +50,7 @@ const PastEntryCalendarModal: React.FC<Props> = ({
const { t } = useTranslation("home");

const [selectedDate, setSelectedDate] = useState("");
const [currentMonth, setMonthChange] = useState(dayjs());

const clearDate = useCallback(() => {
setSelectedDate("");
@@ -54,40 +60,52 @@ const PastEntryCalendarModal: React.FC<Props> = ({
handleClose();
}, [handleClose]);

const content = selectedDate ? (
<>
<PastEntryList
date={selectedDate}
timesheet={timesheet}
leaves={leaves}
allProjects={allProjects}
leaveTypes={leaveTypes}
/>
</>
) : (
<>
<Stack marginBlockEnd={2}>
<Box display="flex" alignItems="center" gap={1}>
<Indicator sx={{ backgroundColor: "info.light" }} />
<Typography variant="caption">{t("Has timesheet entry")}</Typography>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<Indicator sx={{ backgroundColor: "warning.light" }} />
<Typography variant="caption">{t("Has leave entry")}</Typography>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<Indicator sx={{ backgroundColor: "success.light" }} />
<Typography variant="caption">
{t("Has both timesheet and leave entry")}
</Typography>
</Box>
</Stack>
<PastEntryCalendar
timesheet={timesheet}
leaves={leaves}
onDateSelect={setSelectedDate}
/>
</>
const content = (
<Box sx={{ display: "flex", flexDirection: { xs: "column", sm: "row" } }}>
<Box>
<Stack marginBlockEnd={2}>
<Box display="flex" alignItems="center" gap={1}>
<Indicator sx={{ backgroundColor: "info.light" }} />
<Typography variant="caption">
{t("Has timesheet entry")}
</Typography>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<Indicator sx={{ backgroundColor: "warning.light" }} />
<Typography variant="caption">{t("Has leave entry")}</Typography>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<Indicator sx={{ backgroundColor: "success.light" }} />
<Typography variant="caption">
{t("Has both timesheet and leave entry")}
</Typography>
</Box>
</Stack>
<PastEntryCalendar
timesheet={timesheet}
leaves={leaves}
onDateSelect={setSelectedDate}
onMonthChange={setMonthChange}
/>
</Box>
{selectedDate ? (
<PastEntryList
date={selectedDate}
timesheet={timesheet}
leaves={leaves}
allProjects={allProjects}
leaveTypes={leaveTypes}
/>
) : (
<MonthlySummary
currentMonth={currentMonth}
timesheet={timesheet}
leaves={leaves}
companyHolidays={[]}
onDateSelect={setSelectedDate}
/>
)}
</Box>
);

const isMobile = useIsMobile();
@@ -115,14 +133,14 @@ const PastEntryCalendarModal: React.FC<Props> = ({
startIcon={<ArrowBack />}
onClick={clearDate}
>
{t("Back")}
{t("Back to Monthly Summary")}
</Button>
)}
</Box>
</Box>
</FullscreenModal>
) : (
<Dialog onClose={onClose} open={open}>
<Dialog onClose={onClose} open={open} maxWidth="md">
<DialogTitle>{t("Past Entries")}</DialogTitle>
<DialogContent>{content}</DialogContent>
{selectedDate && (
@@ -132,7 +150,7 @@ const PastEntryCalendarModal: React.FC<Props> = ({
startIcon={<ArrowBack />}
onClick={clearDate}
>
{t("Back")}
{t("Back to Monthly Summary")}
</Button>
</DialogActions>
)}


+ 24
- 11
src/components/PastEntryCalendar/PastEntryList.tsx Voir le fichier

@@ -57,7 +57,12 @@ const PastEntryList: React.FC<Props> = ({
const dayJsObj = dayjs(date);

return (
<Stack gap={2} marginBlockEnd={2} minWidth={{ sm: 375 }}>
<Stack
gap={2}
marginBlockEnd={2}
minWidth={{ sm: 375 }}
maxHeight={{ sm: 500 }}
>
<Typography
variant="overline"
color={dayJsObj.day() === 0 ? "error.main" : undefined}
@@ -94,17 +99,25 @@ const PastEntryList: React.FC<Props> = ({
leaveTypeMap={leaveTypeMap}
/>
))}
<Typography
variant="overline"
>
{t("Total Work Hours")}: {manhourFormatter.format(timeEntries.map(entry => (entry.inputHours ?? 0) + (entry.otHours ?? 0)).reduce((acc, cur) => { return acc + cur }, 0))}
</Typography>
<Typography
variant="overline"
>
{t("Total Leave Hours")}: {manhourFormatter.format(leaveEntries.map(entry => entry.inputHours).reduce((acc, cur) => { return acc + cur }, 0))}
</Typography>
</Box>
<Typography variant="overline">
{`${t("Total Work Hours")}: ${manhourFormatter.format(
timeEntries
.map((entry) => (entry.inputHours ?? 0) + (entry.otHours ?? 0))
.reduce((acc, cur) => {
return acc + cur;
}, 0),
)}`}
</Typography>
<Typography variant="overline">
{`${t("Total Leave Hours")}: ${manhourFormatter.format(
leaveEntries
.map((entry) => entry.inputHours)
.reduce((acc, cur) => {
return acc + cur;
}, 0),
)}`}
</Typography>
</Stack>
);
};


+ 1
- 1
src/components/ProgressByClient/ProgressByClient.tsx Voir le fichier

@@ -409,7 +409,7 @@ const ProgressByClient: React.FC<Props> = () => {
const spentManhours = chartProjectSpentHour[dataPointIndex];
const value = series[seriesIndex][dataPointIndex];
const tooltipContent = `
<div style="width: 250px;">
<div style="width: auto;">
<span style="font-weight: bold;">${projectCode} - ${projectName}</span>
<br>
Budget Manhours: ${budgetManhours} hours


+ 1
- 1
src/components/ProgressByTeam/ProgressByTeam.tsx Voir le fichier

@@ -492,7 +492,7 @@ const ProgressByTeam: React.FC = () => {
const spentManhours = currentPageProjectSpentManhourList[dataPointIndex];
const value = series[seriesIndex][dataPointIndex];
const tooltipContent = `
<div style="width: 100%;">
<div style="width: auto;">
<span style="font-weight: bold;">${projectCode} - ${projectName}</span>
<br>
Budget Manhours: ${budgetManhours} hours


+ 1
- 1
src/components/ProjectCashFlow/ProjectCashFlow.tsx Voir le fichier

@@ -954,7 +954,7 @@ const ProjectCashFlow: React.FC = () => {
className="text-sm font-medium ml-5"
style={{ color: "#898d8d" }}
>
Accounts Receivable
Remaining Budget
</div>
<div
className="text-lg font-medium ml-5 mb-2"


+ 3
- 2
src/components/StaffUtilization/StaffUtilization.tsx Voir le fichier

@@ -362,7 +362,7 @@ const StaffUtilization: React.FC<Props> = ({ abilities, staff }) => {
const startCount = weeklyPlanned[i].startCount
const endCount = weeklyPlanned[i].endCount
for (var j = 0; j < weeklyPlanned[i].searchDuration; j++) {
if (j >= startCount && j < endCount) {
if (j >= startCount && j <= endCount) {
weeklyPlannedSubList.push(weeklyPlanned[i].AverageManhours)
} else {
weeklyPlannedSubList.push(0)
@@ -503,7 +503,8 @@ const StaffUtilization: React.FC<Props> = ({ abilities, staff }) => {


const fetchMonthlyUnsubmittedData = async () => {
const fetchResult = await fetchMonthlyUnsubmit(teamUnsubmitTeamId, unsubmitMonthlyFromValue.format('YYYY-MM-DD'), unsubmitMonthlyToValue.endOf('month').format('YYYY-MM-DD'), holidayDates);
const fetchResult = await fetchMonthlyUnsubmit(teamUnsubmitTeamId, unsubmitMonthlyFromValue.startOf('month').format('YYYY-MM-DD'), unsubmitMonthlyToValue.endOf('month').format('YYYY-MM-DD'), holidayDates);

const result = []
const staffList = []
var maxValue = 5


+ 14
- 5
src/components/TimeLeaveModal/TimeLeaveInputTable.tsx Voir le fichier

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

const { getValues, setValue, clearErrors } =
const { getValues, setValue, clearErrors, setError } =
useFormContext<RecordTimeLeaveInput>();
const currentEntries = getValues(day);

@@ -393,7 +393,9 @@ const TimeLeaveInputTable: React.FC<Props> = ({
params.row._error?.[
params.field as keyof Omit<TimeLeaveEntry, "type">
];
const content = <GridEditInputCell {...params} />;
const content = (
<GridEditInputCell {...params} inputProps={{ min: 0 }} />
);
return errorMessage ? (
<Tooltip title={t(errorMessage, { DAILY_NORMAL_MAX_HOURS })}>
<Box width="100%">{content}</Box>
@@ -423,7 +425,9 @@ const TimeLeaveInputTable: React.FC<Props> = ({
params.row._error?.[
params.field as keyof Omit<TimeLeaveEntry, "type">
];
const content = <GridEditInputCell {...params} />;
const content = (
<GridEditInputCell {...params} inputProps={{ min: 0 }} />
);
return errorMessage ? (
<Tooltip title={t(errorMessage)}>
<Box width="100%">{content}</Box>
@@ -486,8 +490,13 @@ const TimeLeaveInputTable: React.FC<Props> = ({
.filter((e): e is TimeLeaveEntry => Boolean(e));

setValue(day, newEntries);
clearErrors(day);
}, [getValues, entries, setValue, day, clearErrors]);

if (entries.some((e) => e._isNew)) {
setError(day, { message: "There are some unsaved entries." });
} else {
clearErrors(day);
}
}, [getValues, entries, setValue, day, clearErrors, setError]);

const hasOutOfPlannedStages = entries.some(
(entry) => entry._isPlanned !== undefined && !entry._isPlanned,


+ 4
- 2
src/components/TimeLeaveModal/TimeLeaveModal.tsx Voir le fichier

@@ -49,6 +49,7 @@ interface Props {
companyHolidays: HolidaysResult[];
fastEntryEnabled?: boolean;
leaveTypes: LeaveType[];
isFullTime: boolean;
}

const modalSx: SxProps = {
@@ -71,6 +72,7 @@ const TimeLeaveModal: React.FC<Props> = ({
companyHolidays,
fastEntryEnabled,
leaveTypes,
isFullTime
}) => {
const { t } = useTranslation("home");

@@ -106,7 +108,7 @@ const TimeLeaveModal: React.FC<Props> = ({

const onSubmit = useCallback<SubmitHandler<RecordTimeLeaveInput>>(
async (data) => {
const errors = validateTimeLeaveRecord(data, companyHolidays);
const errors = validateTimeLeaveRecord(data, companyHolidays, isFullTime);
if (errors) {
Object.keys(errors).forEach((date) =>
formProps.setError(date, {
@@ -131,7 +133,7 @@ const TimeLeaveModal: React.FC<Props> = ({
formProps.reset(newFormValues);
onClose();
},
[companyHolidays, formProps, onClose],
[companyHolidays, formProps, onClose, isFullTime],
);

const onCancel = useCallback(() => {


+ 15
- 3
src/components/TimesheetAmendment/TimesheetAmendment.tsx Voir le fichier

@@ -347,6 +347,10 @@ const TimesheetAmendment: React.FC<Props> = ({
if (!date) {
throw Error("Invalid date");
}
const dayJsObj = dayjs(date);
const holiday = getHolidayForDate(date, companyHolidays);
const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6;

const intStaffId = parseInt(selectedStaff.id);
const leaves = localTeamLeaves[intStaffId].leaveEntries[date] || [];
const timesheets =
@@ -360,7 +364,11 @@ const TimesheetAmendment: React.FC<Props> = ({
leaves,
"id",
);
totalHourError = checkTotalHours(timesheets, leavesWithNewEntry);
totalHourError = checkTotalHours(
timesheets,
leavesWithNewEntry,
Boolean(isHoliday),
);
} else {
// newEntry is a timesheet entry
const timesheetsWithNewEntry = unionBy(
@@ -368,11 +376,15 @@ const TimesheetAmendment: React.FC<Props> = ({
timesheets,
"id",
);
totalHourError = checkTotalHours(timesheetsWithNewEntry, leaves);
totalHourError = checkTotalHours(
timesheetsWithNewEntry,
leaves,
Boolean(isHoliday),
);
}
if (totalHourError) throw Error(totalHourError);
},
[localTeamLeaves, localTeamTimesheets, selectedStaff.id],
[localTeamLeaves, localTeamTimesheets, selectedStaff, companyHolidays],
);

const handleSave = useCallback(


+ 0
- 223
src/components/TimesheetModal/TimesheetModal.tsx Voir le fichier

@@ -1,223 +0,0 @@
import React, { useCallback, useEffect, useMemo } from "react";
import {
Box,
Button,
Card,
CardActions,
CardContent,
Modal,
ModalProps,
SxProps,
Typography,
} from "@mui/material";
import TimesheetTable from "../TimesheetTable";
import { useTranslation } from "react-i18next";
import { Check, Close } from "@mui/icons-material";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import {
RecordLeaveInput,
RecordTimesheetInput,
saveTimesheet,
} from "@/app/api/timesheets/actions";
import dayjs from "dayjs";
import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
import FullscreenModal from "../FullscreenModal";
import MobileTimesheetTable from "../TimesheetTable/MobileTimesheetTable";
import useIsMobile from "@/app/utils/useIsMobile";
import { HolidaysResult } from "@/app/api/holidays";
import {
DAILY_NORMAL_MAX_HOURS,
TIMESHEET_DAILY_MAX_HOURS,
validateTimesheet,
} from "@/app/api/timesheets/utils";
import ErrorAlert from "../ErrorAlert";

interface Props {
isOpen: boolean;
onClose: () => void;
allProjects: ProjectWithTasks[];
assignedProjects: AssignedProject[];
defaultTimesheets?: RecordTimesheetInput;
leaveRecords: RecordLeaveInput;
companyHolidays: HolidaysResult[];
fastEntryEnabled?: boolean;
}

const modalSx: SxProps = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: { xs: "calc(100% - 2rem)", sm: "90%" },
maxHeight: "90%",
maxWidth: 1400,
};

const TimesheetModal: React.FC<Props> = ({
isOpen,
onClose,
allProjects,
assignedProjects,
defaultTimesheets,
leaveRecords,
companyHolidays,
fastEntryEnabled,
}) => {
const { t } = useTranslation("home");

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

const formProps = useForm<RecordTimesheetInput>({ defaultValues });
useEffect(() => {
formProps.reset(defaultValues);
}, [defaultValues, formProps]);

const onSubmit = useCallback<SubmitHandler<RecordTimesheetInput>>(
async (data) => {
const errors = validateTimesheet(data, leaveRecords, companyHolidays);
if (errors) {
Object.keys(errors).forEach((date) =>
formProps.setError(date, {
message: errors[date],
}),
);
return;
}
const savedRecords = await saveTimesheet(data);

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

formProps.reset(newFormValues);
onClose();
},
[companyHolidays, formProps, leaveRecords, onClose],
);

const onCancel = useCallback(() => {
formProps.reset(defaultValues);
onClose();
}, [defaultValues, formProps, onClose]);

const onModalClose = useCallback<NonNullable<ModalProps["onClose"]>>(
(_, reason) => {
if (reason !== "backdropClick") {
onClose();
}
},
[onClose],
);

const errorComponent = (
<ErrorAlert
errors={Object.keys(formProps.formState.errors).map((date) => {
const error = formProps.formState.errors[date]?.message;
return error
? `${date}: ${t(error, {
TIMESHEET_DAILY_MAX_HOURS,
DAILY_NORMAL_MAX_HOURS,
})}`
: undefined;
})}
/>
);

const matches = useIsMobile();

return (
<FormProvider {...formProps}>
{!matches ? (
// Desktop version
<Modal open={isOpen} onClose={onModalClose}>
<Card sx={modalSx}>
<CardContent
component="form"
onSubmit={formProps.handleSubmit(onSubmit)}
>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Timesheet Input")}
</Typography>
<Box
sx={{
marginInline: -3,
marginBlock: 4,
}}
>
<TimesheetTable
companyHolidays={companyHolidays}
assignedProjects={assignedProjects}
allProjects={allProjects}
leaveRecords={leaveRecords}
fastEntryEnabled={fastEntryEnabled}
/>
</Box>
{errorComponent}
<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>
</Card>
</Modal>
) : (
// Mobile version
<FullscreenModal
open={isOpen}
onClose={onModalClose}
closeModal={onCancel}
>
<Box
display="flex"
flexDirection="column"
gap={2}
height="100%"
component="form"
onSubmit={formProps.handleSubmit(onSubmit)}
>
<Typography variant="h6" padding={2} flex="none">
{t("Timesheet Input")}
</Typography>
<MobileTimesheetTable
fastEntryEnabled={fastEntryEnabled}
companyHolidays={companyHolidays}
assignedProjects={assignedProjects}
allProjects={allProjects}
leaveRecords={leaveRecords}
errorComponent={errorComponent}
/>
</Box>
</FullscreenModal>
)}
</FormProvider>
);
};

export default TimesheetModal;

+ 0
- 1
src/components/TimesheetModal/index.ts Voir le fichier

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

+ 1
- 0
src/components/TimesheetTable/FastTimeEntryModal.tsx Voir le fichier

@@ -174,6 +174,7 @@ const FastTimeEntryModal: React.FC<Props> = ({
name="projectIds"
render={({ field }) => (
<ProjectSelect
includeLeaves={false}
error={Boolean(formState.errors.projectIds)}
multiple
allProjects={allProjectsWithFastEntry}


+ 151
- 62
src/components/UserWorkspacePage/ProjectGrid.tsx Voir le fichier

@@ -1,82 +1,171 @@
import React from "react";
import { Box, Card, CardContent, Grid, Typography } from "@mui/material";
import React, { useCallback, useState } from "react";
import {
Box,
Card,
CardContent,
Grid,
ToggleButton,
ToggleButtonGroup,
ToggleButtonProps,
Tooltip,
Typography,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { manhourFormatter } from "@/app/utils/formatUtil";
import { AssignedProject } from "@/app/api/projects";
import { TableRows, ViewModule, TableChart } from "@mui/icons-material";
import ProjectTable from "./ProjectTable";

interface Props {
export interface Props {
projects: AssignedProject[];
maintainNormalStaffWorkspaceAbility?: boolean;
maintainManagementStaffWorkspaceAbility?: boolean;
}

const ProjectGrid: React.FC<Props> = ({ projects, maintainNormalStaffWorkspaceAbility, maintainManagementStaffWorkspaceAbility }) => {
const ProjectGrid: React.FC<Props> = ({
projects,
maintainNormalStaffWorkspaceAbility,
maintainManagementStaffWorkspaceAbility,
}) => {
const { t } = useTranslation("home");
const [view, setView] = useState<"grid" | "list" | "table">("grid");

const handleViewChange = useCallback<
NonNullable<ToggleButtonProps["onChange"]>
>((e, value) => {
if (value) {
setView(value);
}
}, []);

return (
<Box>
<Grid container columns={{ xs: 4, sm: 8, md: 12, lg: 16 }} spacing={2}>
{projects.map((project, idx) => (
<Grid key={`${project.code}${idx}`} item xs={4}>
<Card>
<CardContent>
<Typography variant="overline">{project.code}</Typography>
<Typography
variant="h6"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
marginBlockEnd: 3,
}}
>
{project.name}
</Typography>
{/* Hours Spent */}
{(Boolean(maintainNormalStaffWorkspaceAbility) || Boolean(maintainManagementStaffWorkspaceAbility)) && <><Typography variant="subtitle2">{t("Hours Spent:")}</Typography>
<Box
<ToggleButtonGroup
color="primary"
value={view}
exclusive
onChange={handleViewChange}
sx={{ marginBlockEnd: 2 }}
>
<ToggleButton value="grid">
<Tooltip title={t("Grid view")}>
<ViewModule />
</Tooltip>
</ToggleButton>
<ToggleButton value="list">
<Tooltip title={t("List view")}>
<TableRows />
</Tooltip>
</ToggleButton>
<ToggleButton value="table">
<Tooltip title={t("Table view")}>
<TableChart />
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
{view === "table" ? (
<ProjectTable
projects={projects}
maintainManagementStaffWorkspaceAbility={
maintainManagementStaffWorkspaceAbility
}
maintainNormalStaffWorkspaceAbility={
maintainNormalStaffWorkspaceAbility
}
/>
) : (
<Grid
container
columns={view === "list" ? 4 : { xs: 4, sm: 8, md: 12, lg: 16 }}
spacing={2}
alignItems="stretch"
>
{projects.map((project, idx) => (
<Grid key={`${project.code}${idx}`} item xs={4}>
<Card sx={{ height: "100%" }}>
<CardContent
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
flexDirection: "column",
height: "100%",
}}
>
<Typography variant="caption">{t("Normal")}</Typography>
<Typography>
{manhourFormatter.format(Boolean(maintainManagementStaffWorkspaceAbility) ? project.hoursSpent : project.currentStaffHoursSpent)}
</Typography>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<Typography variant="caption">{t("Others")}</Typography>
<Typography>{`${manhourFormatter.format(
Boolean(maintainManagementStaffWorkspaceAbility) ? project.hoursSpentOther : project.currentStaffHoursSpentOther,
)}`}</Typography>
</Box></>}
{/* Hours Allocated */}
{Boolean(maintainManagementStaffWorkspaceAbility) && <Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<Typography variant="subtitle2" sx={{ marginBlockStart: 2 }}>
{t("Hours Allocated:")}
</Typography>
<Typography>
{manhourFormatter.format(project.hoursAllocated)}
<Typography variant="overline">{project.code}</Typography>
<Typography
variant="h6"
sx={{
marginBlockEnd: 3,
}}
>
{project.name}
</Typography>
</Box>}
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* Spacer */}
<Box sx={{ flex: 1 }} />
{/* Hours Spent */}
{(Boolean(maintainNormalStaffWorkspaceAbility) ||
Boolean(maintainManagementStaffWorkspaceAbility)) && (
<>
<Typography variant="subtitle2">
{t("Hours Spent:")}
</Typography>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<Typography variant="caption">{t("Normal")}</Typography>
<Typography>
{manhourFormatter.format(
Boolean(maintainManagementStaffWorkspaceAbility)
? project.hoursSpent
: project.currentStaffHoursSpent,
)}
</Typography>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<Typography variant="caption">{t("Others")}</Typography>
<Typography>{`${manhourFormatter.format(
Boolean(maintainManagementStaffWorkspaceAbility)
? project.hoursSpentOther
: project.currentStaffHoursSpentOther,
)}`}</Typography>
</Box>
</>
)}
{/* Hours Allocated */}
{Boolean(maintainManagementStaffWorkspaceAbility) && (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<Typography
variant="subtitle2"
sx={{ marginBlockStart: 2 }}
>
{t("Hours Allocated:")}
</Typography>
<Typography>
{manhourFormatter.format(project.hoursAllocated)}
</Typography>
</Box>
)}
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
</Box>
);
};


+ 106
- 0
src/components/UserWorkspacePage/ProjectTable.tsx Voir le fichier

@@ -0,0 +1,106 @@
import { useTranslation } from "react-i18next";
import { Props } from "./ProjectGrid";
import {
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material";
import { useMemo } from "react";
import { AssignedProject } from "@/app/api/projects";
import { manhourFormatter } from "@/app/utils/formatUtil";

interface Column {
name: keyof AssignedProject;
label: string;
}

const hourColumns: Array<keyof AssignedProject> = [
"currentStaffHoursSpent",
"currentStaffHoursSpentOther",
"hoursAllocated",
"hoursSpent",
"hoursSpentOther",
];

const ProjectTable: React.FC<Props> = ({
projects,
maintainManagementStaffWorkspaceAbility,
maintainNormalStaffWorkspaceAbility,
}) => {
const { t } = useTranslation("home");
const columns = useMemo<Column[]>(() => {
return [
{ name: "code", label: t("Project Code") },
{ name: "name", label: t("Project Name") },
...(maintainManagementStaffWorkspaceAbility ||
maintainNormalStaffWorkspaceAbility
? maintainManagementStaffWorkspaceAbility
? ([
{ name: "hoursSpent", label: t("Total Normal Hours Spent") },
{ name: "hoursSpentOther", label: t("Total Other Hours Spent") },
{ name: "hoursAllocated", label: t("Hours Allocated") },
] satisfies Column[])
: ([
{
name: "currentStaffHoursSpent",
label: t("Normal Hours Spent"),
},
{
name: "currentStaffHoursSpentOther",
label: t("Other Hours Spent"),
},
] satisfies Column[])
: []),
];
}, [
maintainManagementStaffWorkspaceAbility,
maintainNormalStaffWorkspaceAbility,
t,
]);

return (
<Paper sx={{ overflow: "hidden" }}>
<TableContainer>
<Table>
<TableHead>
<TableRow>
{columns.map((column, idx) => (
<TableCell key={`${column.name.toString()}-${idx}`}>
{column.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{projects.map((project) => {
return (
<TableRow hover tabIndex={-1} key={project.id}>
{columns.map((column, idx) => {
const columnName = column.name;
const needsFormatting = hourColumns.includes(columnName);

return (
<TableCell key={`${columnName.toString()}-${idx}`}>
{needsFormatting
? manhourFormatter.format(
project[columnName] as number,
)
: project[columnName]?.toString()}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Paper>
);
};

export default ProjectTable;

+ 35
- 1
src/components/UserWorkspacePage/UserWorkspacePage.tsx Voir le fichier

@@ -4,7 +4,12 @@ import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import { CalendarMonth, EditCalendar, MoreTime } from "@mui/icons-material";
import {
CalendarMonth,
EditCalendar,
Luggage,
MoreTime,
} from "@mui/icons-material";
import { Menu, MenuItem, SxProps, Typography } from "@mui/material";
import AssignedProjects from "./AssignedProjects";
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
@@ -19,6 +24,7 @@ import PastEntryCalendarModal from "../PastEntryCalendar/PastEntryCalendarModal"
import { HolidaysResult } from "@/app/api/holidays";
import { TimesheetAmendmentModal } from "../TimesheetAmendment/TimesheetAmendmentModal";
import TimeLeaveModal from "../TimeLeaveModal/TimeLeaveModal";
import LeaveModal from "../LeaveModal";

export interface Props {
leaveTypes: LeaveType[];
@@ -32,6 +38,7 @@ export interface Props {
fastEntryEnabled: boolean;
maintainNormalStaffWorkspaceAbility: boolean;
maintainManagementStaffWorkspaceAbility: boolean;
isFullTime: boolean;
}

const menuItemSx: SxProps = {
@@ -51,10 +58,12 @@ const UserWorkspacePage: React.FC<Props> = ({
fastEntryEnabled,
maintainNormalStaffWorkspaceAbility,
maintainManagementStaffWorkspaceAbility,
isFullTime,
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);

const [isTimeLeaveModalVisible, setTimeLeaveModalVisible] = useState(false);
const [isLeaveCalendarVisible, setLeaveCalendarVisible] = useState(false);
const [isPastEventModalVisible, setPastEventModalVisible] = useState(false);
const [isTimesheetAmendmentVisible, setisTimesheetAmendmentVisible] =
useState(false);
@@ -81,6 +90,15 @@ const UserWorkspacePage: React.FC<Props> = ({
setTimeLeaveModalVisible(false);
}, []);

const handleOpenLeaveCalendarButton = useCallback(() => {
setAnchorEl(null);
setLeaveCalendarVisible(true);
}, []);

const handleCloseLeaveCalendarButton = useCallback(() => {
setLeaveCalendarVisible(false);
}, []);

const handlePastEventClick = useCallback(() => {
setAnchorEl(null);
setPastEventModalVisible(true);
@@ -136,6 +154,10 @@ const UserWorkspacePage: React.FC<Props> = ({
<MoreTime />
{t("Enter Timesheet")}
</MenuItem>
<MenuItem onClick={handleOpenLeaveCalendarButton} sx={menuItemSx}>
<Luggage />
{t("Record Leave")}
</MenuItem>
<MenuItem onClick={handlePastEventClick} sx={menuItemSx}>
<CalendarMonth />
{t("View Past Entries")}
@@ -154,6 +176,7 @@ const UserWorkspacePage: React.FC<Props> = ({
leaves={defaultLeaveRecords}
allProjects={allProjects}
leaveTypes={leaveTypes}
companyHolidays={holidays}
/>
<TimeLeaveModal
fastEntryEnabled={fastEntryEnabled}
@@ -165,6 +188,17 @@ const UserWorkspacePage: React.FC<Props> = ({
assignedProjects={assignedProjects}
timesheetRecords={defaultTimesheets}
leaveRecords={defaultLeaveRecords}
isFullTime={isFullTime}
/>
<LeaveModal
open={isLeaveCalendarVisible}
onClose={handleCloseLeaveCalendarButton}
leaveTypes={leaveTypes}
companyHolidays={holidays}
allProjects={allProjects}
leaveRecords={defaultLeaveRecords}
timesheetRecords={defaultTimesheets}
isFullTime={isFullTime}
/>
{assignedProjects.length > 0 ? (
<AssignedProjects


+ 23
- 7
src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx Voir le fichier

@@ -11,8 +11,12 @@ import {
fetchTimesheets,
} from "@/app/api/timesheets";
import { fetchHolidays } from "@/app/api/holidays";
import { getUserAbilities } from "@/app/utils/commonUtil";
import { MAINTAIN_TIMESHEET_FAST_TIME_ENTRY, MAINTAIN_NORMAL_STAFF_WORKSPACE, MAINTAIN_MANAGEMENT_STAFF_WORKSPACE } from "@/middleware";
import { getUserAbilities, getUserStaff } from "@/app/utils/commonUtil";
import {
MAINTAIN_TIMESHEET_FAST_TIME_ENTRY,
MAINTAIN_NORMAL_STAFF_WORKSPACE,
MAINTAIN_MANAGEMENT_STAFF_WORKSPACE,
} from "@/middleware";

const UserWorkspaceWrapper: React.FC = async () => {
const [
@@ -25,6 +29,7 @@ const UserWorkspaceWrapper: React.FC = async () => {
leaveTypes,
holidays,
abilities,
userStaff,
] = await Promise.all([
fetchTeamMemberLeaves(),
fetchTeamMemberTimesheets(),
@@ -34,15 +39,24 @@ const UserWorkspaceWrapper: React.FC = async () => {
fetchLeaves(),
fetchLeaveTypes(),
fetchHolidays(),
getUserAbilities()
getUserAbilities(),
getUserStaff(),
]);

const fastEntryEnabled = abilities.includes(MAINTAIN_TIMESHEET_FAST_TIME_ENTRY)
const maintainNormalStaffWorkspaceAbility = abilities.includes(MAINTAIN_NORMAL_STAFF_WORKSPACE)
const maintainManagementStaffWorkspaceAbility = abilities.includes(MAINTAIN_MANAGEMENT_STAFF_WORKSPACE)
const fastEntryEnabled = abilities.includes(
MAINTAIN_TIMESHEET_FAST_TIME_ENTRY,
);
const maintainNormalStaffWorkspaceAbility = abilities.includes(
MAINTAIN_NORMAL_STAFF_WORKSPACE,
);
const maintainManagementStaffWorkspaceAbility = abilities.includes(
MAINTAIN_MANAGEMENT_STAFF_WORKSPACE,
);
const isFullTime = userStaff?.employType === "FT";

return (
<UserWorkspacePage
isFullTime={isFullTime}
teamLeaves={teamLeaves}
teamTimesheets={teamTimesheets}
allProjects={allProjects}
@@ -54,7 +68,9 @@ const UserWorkspaceWrapper: React.FC = async () => {
// Change to access check
fastEntryEnabled={fastEntryEnabled}
maintainNormalStaffWorkspaceAbility={maintainNormalStaffWorkspaceAbility}
maintainManagementStaffWorkspaceAbility={maintainManagementStaffWorkspaceAbility}
maintainManagementStaffWorkspaceAbility={
maintainManagementStaffWorkspaceAbility
}
/>
);
};


+ 4
- 3
src/config/authConfig.ts Voir le fichier

@@ -6,10 +6,11 @@ export interface SessionStaff {
id: number;
teamId: number;
isTeamLead: boolean;
employType: string | null;
}
export interface SessionWithTokens extends Session {
staff?: SessionStaff;
role?: String;
role?: string;
abilities?: string[];
accessToken?: string;
refreshToken?: string;
@@ -60,14 +61,14 @@ export const authOptions: AuthOptions = {
session({ session, token }) {
const sessionWithToken: SessionWithTokens = {
...session,
role: token.role as String,
role: token.role as string,
// Add the data from the token to the session
abilities: (token.abilities as ability[]).map(
(item: ability) => item.actionSubjectCombo,
) as string[],
accessToken: token.accessToken as string | undefined,
refreshToken: token.refreshToken as string | undefined,
staff: token.staff as SessionStaff
staff: token.staff as SessionStaff,
};
// console.log(sessionWithToken)
return sessionWithToken;


+ 19
- 18
src/middleware.ts Voir le fichier

@@ -59,16 +59,16 @@ export const [
VIEW_PROJECT_RESOURCE_CONSUMPTION_RANKING,
MAINTAIN_NORMAL_STAFF_WORKSPACE,
MAINTAIN_MANAGEMENT_STAFF_WORKSPACE,
GENERATE_LATE_START_REPORTS,
GENERATE_LATE_START_REPORT,
GENERATE_PROJECT_POTENTIAL_DELAY_REPORT,
GENERATE_RESOURCE_OVERCONSUMPTION_REPORT,
GENERATE_COST_ANT_EXPENSE_REPORT,
GENERATE_COST_AND_EXPENSE_REPORT,
GENERATE_PROJECT_COMPLETION_REPORT,
GENERATE_PROJECT_PANDL_REPORT,
GENERATE_FINANCIAL_STATUS_REPORT,
GENERATE_PROJECT_CASH_FLOW_REPORT,
GENERATE_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT,
GENERATE_CROSS_TEAM_CHARGE_REPORT,
GENERATE_CROSS_TEAM_CHARGE_REPORT
] = [
'MAINTAIN_USER',
'MAINTAIN_TIMESHEET',
@@ -109,16 +109,16 @@ export const [
'VIEW_PROJECT_RESOURCE_CONSUMPTION_RANKING',
'MAINTAIN_NORMAL_STAFF_WORKSPACE',
'MAINTAIN_MANAGEMENT_STAFF_WORKSPACE',
'GENERATE_LATE_START_REPORTS',
'GENERATE_PROJECT_POTENTIAL_DELAY_REPORT',
'GENERATE_RESOURCE_OVERCONSUMPTION_REPORT',
'GENERATE_COST_ANT_EXPENSE_REPORT',
'GENERATE_PROJECT_COMPLETION_REPORT',
'GENERATE_PROJECT_P&L_REPORT',
'GENERATE_FINANCIAL_STATUS_REPORT',
'GENERATE_PROJECT_CASH_FLOW_REPORT',
'GENERATE_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT',
'GENERATE_CROSS_TEAM_CHARGE_REPORT',
'G_LATE_START_REPORT',
'G_PROJECT_POTENTIAL_DELAY_REPORT',
'G_RESOURCE_OVERCONSUMPTION_REPORT',
'G_COST_AND_EXPENSE_REPORT',
'G_PROJECT_COMPLETION_REPORT',
'G_PROJECT_P&L_REPORT',
'G_FINANCIAL_STATUS_REPORT',
'G_PROJECT_CASH_FLOW_REPORT',
'G_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT',
'G_CROSS_TEAM_CHARGE_REPORT'
]

const PRIVATE_ROUTES = [
@@ -243,20 +243,21 @@ export default async function middleware(

if (req.nextUrl.pathname.startsWith('/analytics')) {
isAuth = [
GENERATE_LATE_START_REPORTS,
GENERATE_LATE_START_REPORT,
GENERATE_PROJECT_POTENTIAL_DELAY_REPORT,
GENERATE_RESOURCE_OVERCONSUMPTION_REPORT,
GENERATE_COST_ANT_EXPENSE_REPORT,
GENERATE_COST_AND_EXPENSE_REPORT,
GENERATE_PROJECT_COMPLETION_REPORT,
GENERATE_PROJECT_PANDL_REPORT,
GENERATE_FINANCIAL_STATUS_REPORT,
GENERATE_PROJECT_CASH_FLOW_REPORT,
GENERATE_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT,
GENERATE_CROSS_TEAM_CHARGE_REPORT,].some((ability) => abilities.includes(ability));
GENERATE_CROSS_TEAM_CHARGE_REPORT
].some((ability) => abilities.includes(ability));
}

if (req.nextUrl.pathname.startsWith('/analytics/LateStartReport')) {
isAuth = [GENERATE_LATE_START_REPORTS].some((ability) => abilities.includes(ability));
isAuth = [GENERATE_LATE_START_REPORT].some((ability) => abilities.includes(ability));
}

if (req.nextUrl.pathname.startsWith('/analytics/ProjectPotentialDelayReport')) {
@@ -268,7 +269,7 @@ export default async function middleware(
}

if (req.nextUrl.pathname.startsWith('/analytics/CostandExpenseReport')) {
isAuth = [GENERATE_COST_ANT_EXPENSE_REPORT].some((ability) => abilities.includes(ability));
isAuth = [GENERATE_COST_AND_EXPENSE_REPORT].some((ability) => abilities.includes(ability));
}

if (req.nextUrl.pathname.startsWith('/analytics/ProjectCompletionReport')) {


Chargement…
Annuler
Enregistrer