diff --git a/src/app/(main)/projects/create/not-found.tsx b/src/app/(main)/projects/create/not-found.tsx new file mode 100644 index 0000000..002e9f5 --- /dev/null +++ b/src/app/(main)/projects/create/not-found.tsx @@ -0,0 +1,17 @@ +import { getServerI18n } from "@/i18n"; +import { Stack, Typography, Link } from "@mui/material"; +import NextLink from "next/link"; + +export default async function NotFound() { + const { t } = await getServerI18n("projects", "common"); + + return ( + + {t("Not Found")} + {t("The create project page was not found!")} + + {t("Return to all projects")} + + + ); +} diff --git a/src/app/(main)/projects/create/page.tsx b/src/app/(main)/projects/create/page.tsx index 1671262..62f2492 100644 --- a/src/app/(main)/projects/create/page.tsx +++ b/src/app/(main)/projects/create/page.tsx @@ -11,10 +11,13 @@ import { } from "@/app/api/projects"; import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; +import { getUserAbilities } from "@/app/utils/commonUtil"; import CreateProject from "@/components/CreateProject"; import { I18nProvider, getServerI18n } from "@/i18n"; +import { MAINTAIN_PROJECT } from "@/middleware"; import Typography from "@mui/material/Typography"; import { Metadata } from "next"; +import { notFound } from "next/navigation"; export const metadata: Metadata = { title: "Create Project", @@ -23,6 +26,12 @@ export const metadata: Metadata = { const Projects: React.FC = async () => { const { t } = await getServerI18n("projects"); + const abilities = await getUserAbilities() + + if (!abilities.includes(MAINTAIN_PROJECT)) { + notFound(); + } + // Preload necessary dependencies fetchAllTasks(); fetchTaskTemplates(); diff --git a/src/app/(main)/projects/createSub/page.tsx b/src/app/(main)/projects/createSub/page.tsx index 3f474de..cc5be9c 100644 --- a/src/app/(main)/projects/createSub/page.tsx +++ b/src/app/(main)/projects/createSub/page.tsx @@ -13,9 +13,11 @@ import { } from "@/app/api/projects"; import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; +import { getUserAbilities } from "@/app/utils/commonUtil"; import { ServerFetchError } from "@/app/utils/fetchUtil"; import CreateProject from "@/components/CreateProject"; import { I18nProvider, getServerI18n } from "@/i18n"; +import { MAINTAIN_PROJECT } from "@/middleware"; import Typography from "@mui/material/Typography"; import { Metadata } from "next"; import { notFound } from "next/navigation"; @@ -26,6 +28,11 @@ export const metadata: Metadata = { const Projects: React.FC = async () => { const { t } = await getServerI18n("projects"); + const abilities = await getUserAbilities() + + if (!abilities.includes(MAINTAIN_PROJECT)) { + notFound(); + } // Preload necessary dependencies fetchAllTasks(); diff --git a/src/app/(main)/projects/edit/page.tsx b/src/app/(main)/projects/edit/page.tsx index 78e0ed1..55f401a 100644 --- a/src/app/(main)/projects/edit/page.tsx +++ b/src/app/(main)/projects/edit/page.tsx @@ -12,9 +12,11 @@ import { } from "@/app/api/projects"; import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; +import { getUserAbilities } from "@/app/utils/commonUtil"; import { ServerFetchError } from "@/app/utils/fetchUtil"; import CreateProject from "@/components/CreateProject"; import { I18nProvider, getServerI18n } from "@/i18n"; +import { MAINTAIN_PROJECT } from "@/middleware"; import Typography from "@mui/material/Typography"; import { isArray } from "lodash"; import { Metadata } from "next"; @@ -32,8 +34,9 @@ const Projects: React.FC = async ({ searchParams }) => { const { t } = await getServerI18n("projects"); // Assume projectId is string here const projectId = searchParams["id"]; + const abilities = await getUserAbilities() - if (!projectId || isArray(projectId)) { + if (!projectId || isArray(projectId) || abilities.includes(MAINTAIN_PROJECT)) { notFound(); } diff --git a/src/app/(main)/projects/editSub/page.tsx b/src/app/(main)/projects/editSub/page.tsx index eb4f5c6..37279a1 100644 --- a/src/app/(main)/projects/editSub/page.tsx +++ b/src/app/(main)/projects/editSub/page.tsx @@ -13,8 +13,10 @@ import { } from "@/app/api/projects"; import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; +import { getUserAbilities } from "@/app/utils/commonUtil"; import CreateProject from "@/components/CreateProject"; import { I18nProvider, getServerI18n } from "@/i18n"; +import { MAINTAIN_PROJECT } from "@/middleware"; import Typography from "@mui/material/Typography"; import { isArray } from "lodash"; import { Metadata } from "next"; @@ -32,7 +34,8 @@ const Projects: React.FC = async ({ searchParams }) => { const { t } = await getServerI18n("projects"); const projectId = searchParams["id"]; - if (!projectId || isArray(projectId)) { + const abilities = await getUserAbilities() + if (!projectId || isArray(projectId) || !abilities.includes(MAINTAIN_PROJECT)) { notFound(); } diff --git a/src/app/(main)/projects/not-found.tsx b/src/app/(main)/projects/not-found.tsx new file mode 100644 index 0000000..6464b10 --- /dev/null +++ b/src/app/(main)/projects/not-found.tsx @@ -0,0 +1,17 @@ +import { getServerI18n } from "@/i18n"; +import { Stack, Typography, Link } from "@mui/material"; +import NextLink from "next/link"; + +export default async function NotFound() { + const { t } = await getServerI18n("projects", "common"); + + return ( + + {t("Not Found")} + {t("The project page was not found!")} + + {t("Return to home")} + + + ); +} diff --git a/src/app/(main)/projects/page.tsx b/src/app/(main)/projects/page.tsx index 7cf44d0..837be0a 100644 --- a/src/app/(main)/projects/page.tsx +++ b/src/app/(main)/projects/page.tsx @@ -1,13 +1,15 @@ import { fetchProjectCategories, fetchProjects, preloadProjects } from "@/app/api/projects"; +import { getUserAbilities } from "@/app/utils/commonUtil"; import ProjectSearch from "@/components/ProjectSearch"; import { getServerI18n } from "@/i18n"; +import { MAINTAIN_PROJECT, VIEW_PROJECT } from "@/middleware"; import Add from "@mui/icons-material/Add"; -import { ButtonGroup } from "@mui/material"; import Button from "@mui/material/Button"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { Metadata } from "next"; import Link from "next/link"; +import { notFound } from "next/navigation"; import { Suspense } from "react"; export const metadata: Metadata = { @@ -19,6 +21,10 @@ const Projects: React.FC = async () => { // preloadProjects(); fetchProjectCategories(); const projects = await fetchProjects(); + const abilities = await getUserAbilities() + if (![MAINTAIN_PROJECT].some(ability => abilities.includes(ability))) { + notFound(); + } return ( <> @@ -31,7 +37,7 @@ const Projects: React.FC = async () => { {t("Projects")} - { > {t("Create Project")} - + } }> diff --git a/src/app/api/reports/index.ts b/src/app/api/reports/index.ts index 7202d2e..29f1797 100644 --- a/src/app/api/reports/index.ts +++ b/src/app/api/reports/index.ts @@ -1,3 +1,4 @@ +import { WildCard } from "@/app/utils/commonUtil"; import { records } from "../staff/actions"; export interface FinancialStatusReportFilter { @@ -36,17 +37,17 @@ export interface ProjectCashFlowReportRequest { // - Project Potential Delay Report export interface ProjectPotentialDelayReportFilter { - team: AutocompleteOptions[]; + team: string[]; client: AutocompleteOptions[]; - numberOfDays: number; - projectCompletion: number; + daysUntilCurrentStageEnd: number; + resourceUtilizationPercentage: number; } export interface ProjectPotentialDelayReportRequest { teamId: number | "All"; clientId: number | "All"; - numberOfDays: number; - projectCompletion: number; + daysUntilCurrentStageEnd: number; + resourceUtilizationPercentage: number; type: string; } @@ -68,9 +69,10 @@ export interface ProjectResourceOverconsumptionReportFilter { lowerLimit: number; } -export interface ProjectResourceOverconsumptionReportRequest { +export interface ProjectResourceOverconsumptionReportRequest extends WildCard { teamId?: number custId?: number + subsidiaryId?: number status: "All" | "Within Budget" | "Potential Overconsumption" | "Overconsumption" lowerLimit: number } @@ -99,6 +101,7 @@ export interface ProjectCompletionReportRequest { startDate: String; endDate: String; outstanding: Boolean; + teamId?: number } export interface CostAndExpenseReportFilter { team: string[]; diff --git a/src/app/api/resourcesummary/index.ts b/src/app/api/resourcesummary/index.ts index e2e062e..666849d 100644 --- a/src/app/api/resourcesummary/index.ts +++ b/src/app/api/resourcesummary/index.ts @@ -10,6 +10,7 @@ export interface ResourceSummaryResult { customerCode: string; customerName: string; customerCodeAndName: string; + subsidiaryCodeAndName: string; } export const preloadProjects = () => { diff --git a/src/app/api/timesheets/actions.ts b/src/app/api/timesheets/actions.ts index 5d4ecb0..d362264 100644 --- a/src/app/api/timesheets/actions.ts +++ b/src/app/api/timesheets/actions.ts @@ -94,6 +94,34 @@ export const saveMemberLeave = async (data: { ); }; +export const deleteMemberEntry = async (data: { + staffId: number; + entryId: number; +}) => { + return serverFetchJson( + `${BASE_API_URL}/timesheets/deleteMemberEntry`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); +}; + +export const deleteMemberLeave = async (data: { + staffId: number; + entryId: number; +}) => { + return serverFetchJson( + `${BASE_API_URL}/timesheets/deleteMemberLeave`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); +}; + export const revalidateCacheAfterAmendment = () => { revalidatePath("/(main)/home"); }; diff --git a/src/app/api/timesheets/utils.ts b/src/app/api/timesheets/utils.ts index 673a03d..bbb13c2 100644 --- a/src/app/api/timesheets/utils.ts +++ b/src/app/api/timesheets/utils.ts @@ -28,7 +28,7 @@ export const validateTimeEntry = ( if (!entry.inputHours && !entry.otHours) { error[isHoliday ? "otHours" : "inputHours"] = "Required"; } else if (entry.inputHours && isHoliday) { - error.inputHours = "Cannot input normal hours for holidays"; + error.inputHours = "Cannot input normal hours on holidays"; } else if (entry.inputHours && entry.inputHours <= 0) { error.inputHours = "Input hours should be between 0 and {{DAILY_NORMAL_MAX_HOURS}}"; @@ -55,16 +55,31 @@ export const validateTimeEntry = ( return Object.keys(error).length > 0 ? error : undefined; }; -export const isValidLeaveEntry = (entry: Partial): string => { +export type LeaveEntryError = { + [field in keyof LeaveEntry]?: string; +}; + +export const validateLeaveEntry = ( + entry: Partial, + isHoliday: boolean, +): LeaveEntryError | undefined => { // Test for errrors - let error: keyof LeaveEntry | "" = ""; + const error: LeaveEntryError = {}; if (!entry.leaveTypeId) { - error = "leaveTypeId"; - } else if (!entry.inputHours || !(entry.inputHours >= 0)) { - error = "inputHours"; + error.leaveTypeId = "Required"; + } else if (entry.inputHours && isHoliday) { + error.inputHours = "Cannot input normal hours on holidays"; + } else if (!entry.inputHours) { + error.inputHours = "Required"; + } else if ( + entry.inputHours && + (entry.inputHours <= 0 || entry.inputHours > DAILY_NORMAL_MAX_HOURS) + ) { + error.inputHours = + "Input hours should be between 0 and {{DAILY_NORMAL_MAX_HOURS}}"; } - return error; + return Object.keys(error).length > 0 ? error : undefined; }; export const validateTimesheet = ( @@ -95,27 +110,10 @@ export const validateTimesheet = ( } // Check total hours - const leaves = leaveRecords[date]; - const leaveHours = - leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; - - const totalInputHours = timeEntries.reduce((acc, entry) => { - return acc + (entry.inputHours || 0); - }, 0); - - const totalOtHours = timeEntries.reduce((acc, entry) => { - return acc + (entry.otHours || 0); - }, 0); - - if (totalInputHours + leaveHours > DAILY_NORMAL_MAX_HOURS) { - errors[date] = - "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 ( - totalInputHours + totalOtHours + leaveHours > - TIMESHEET_DAILY_MAX_HOURS - ) { - errors[date] = - "The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}"; + const leaves = leaveRecords[date] || []; + const totalHourError = checkTotalHours(timeEntries, leaves); + if (totalHourError) { + errors[date] = totalHourError; } }); @@ -125,48 +123,64 @@ export const validateTimesheet = ( 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 = isValidLeaveEntry(entry); + 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 leaveHours = leaves.reduce((acc, entry) => acc + entry.inputHours, 0); - - const totalInputHours = timeEntries.reduce((acc, entry) => { - return acc + (entry.inputHours || 0); - }, 0); - - const totalOtHours = timeEntries.reduce((acc, entry) => { - return acc + (entry.otHours || 0); - }, 0); - - if (totalInputHours + leaveHours > DAILY_NORMAL_MAX_HOURS) { - errors[date] = - "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 ( - totalInputHours + totalOtHours + leaveHours > - TIMESHEET_DAILY_MAX_HOURS - ) { - errors[date] = - "The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}"; + const totalHourError = checkTotalHours(timeEntries, leaves); + if (totalHourError) { + errors[date] = totalHourError; } }); return Object.keys(errors).length > 0 ? errors : undefined; }; +export const checkTotalHours = ( + timeEntries: TimeEntry[], + leaves: LeaveEntry[], +): string | undefined => { + const leaveHours = leaves.reduce((acc, entry) => acc + entry.inputHours, 0); + + const totalInputHours = timeEntries.reduce((acc, entry) => { + return acc + (entry.inputHours || 0); + }, 0); + + const totalOtHours = timeEntries.reduce((acc, entry) => { + return acc + (entry.otHours || 0); + }, 0); + + 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 ( + totalInputHours + totalOtHours + leaveHours > + TIMESHEET_DAILY_MAX_HOURS + ) { + return "The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}"; + } +}; + export const DAILY_NORMAL_MAX_HOURS = 8; -export const LEAVE_DAILY_MAX_HOURS = 8; export const TIMESHEET_DAILY_MAX_HOURS = 20; diff --git a/src/app/utils/commonUtil.ts b/src/app/utils/commonUtil.ts index 72d4a56..fa68bda 100644 --- a/src/app/utils/commonUtil.ts +++ b/src/app/utils/commonUtil.ts @@ -1,3 +1,9 @@ +import { SessionWithTokens, authOptions } from "@/config/authConfig" +import { getServerSession } from "next-auth" +export interface WildCard { + [key: string]: any; +} + export const dateInRange = (currentDate: string, startDate: string, endDate: string) => { if (currentDate === undefined) { @@ -28,4 +34,24 @@ export const downloadFile = (blobData: Uint8Array, filename: string) => { link.href = url; link.setAttribute("download", filename); link.click(); +} + +export function readIntFromString(input: string): [string, number | null] | string { + // Split the input string by the "-" character + if (!input.includes("-")) { + return [input, null] + } + const parts = input.split("-"); + + // Extract the string part and the integer part (if available) + const stringPart = parts.slice(0, parts.length - 1).join("-"); + const intPartStr = parts[parts.length - 1]; + const intPart = intPartStr ? parseInt(intPartStr, 10) : null; + + return [stringPart, intPart]; +} + +export const getUserAbilities = async () => { + const session = await getServerSession(authOptions) as SessionWithTokens; + return session?.abilities ?? [] as string[] } \ No newline at end of file diff --git a/src/app/utils/fetchUtil.ts b/src/app/utils/fetchUtil.ts index a97a09c..9d9a63d 100644 --- a/src/app/utils/fetchUtil.ts +++ b/src/app/utils/fetchUtil.ts @@ -22,7 +22,7 @@ export const serverFetch: typeof fetch = async (input, init) => { const session = await getServerSession(authOptions); const accessToken = session?.accessToken; - console.log(accessToken); + // console.log(accessToken); return fetch(input, { ...init, headers: { diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index 57c190a..10cf3fe 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -16,7 +16,7 @@ export interface AppBarProps { const AppBar: React.FC = async ({ avatarImageSrc, profileName }) => { const session = await getServerSession(authOptions) as any; const abilities: string[] = session.abilities - console.log(abilities) + // console.log(abilities) return ( diff --git a/src/components/CostAndExpenseReport/CostAndExpenseReport.tsx b/src/components/CostAndExpenseReport/CostAndExpenseReport.tsx index da15baf..1b837ee 100644 --- a/src/components/CostAndExpenseReport/CostAndExpenseReport.tsx +++ b/src/components/CostAndExpenseReport/CostAndExpenseReport.tsx @@ -11,12 +11,13 @@ import { downloadFile } from "@/app/utils/commonUtil"; interface Props { team: TeamResult[]; customer: Customer[]; + needAll: boolean | undefined; } type SearchQuery = Partial>; type SearchParamNames = keyof SearchQuery; -const CostAndExpenseReport: React.FC = ({ team, customer }) => { +const CostAndExpenseReport: React.FC = ({ team, customer, needAll }) => { const { t } = useTranslation("report"); const teamCombo = team.map((t) => `${t.name} - ${t.code}`); const custCombo = customer.map(c => ({label: `${c.name} - ${c.code}`, value: c.id})) @@ -28,7 +29,7 @@ const CostAndExpenseReport: React.FC = ({ team, customer }) => { paramName: "team", type: "select", options: teamCombo, - needAll: true, + needAll: needAll, }, { label: t("Client"), diff --git a/src/components/CostAndExpenseReport/CostAndExpenseReportWrapper.tsx b/src/components/CostAndExpenseReport/CostAndExpenseReportWrapper.tsx index 2b32c99..3c2a5d9 100644 --- a/src/components/CostAndExpenseReport/CostAndExpenseReportWrapper.tsx +++ b/src/components/CostAndExpenseReport/CostAndExpenseReportWrapper.tsx @@ -1,18 +1,30 @@ import React from "react"; import { fetchAllCustomers } from "@/app/api/customer"; -import { fetchTeam } from "@/app/api/team"; +import { fetchIndivTeam, fetchTeam } from "@/app/api/team"; import CostAndExpenseReport from "./CostAndExpenseReport"; import CostAndExpenseReportLoading from "./CostAndExpenseReportLoading"; - +import { headers, cookies } from 'next/headers'; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/config/authConfig"; +import { TEAM_LEAD } from "@/middleware"; interface SubComponents { Loading: typeof CostAndExpenseReportLoading; } const CostAndExpenseReportWrapper: React.FC & SubComponents = async () => { - const customers = await fetchAllCustomers() - const teams = await fetchTeam () + const session: any = await getServerSession(authOptions) + const teamId = session.staff?.team.id + const role = session.role + let customers = await fetchAllCustomers() + let teams = await fetchTeam() + let needAll = true + + if (role === TEAM_LEAD) { + needAll = false + teams = teams.filter((team) => team.id === teamId); + } - return + return }; CostAndExpenseReportWrapper.Loading = CostAndExpenseReportLoading; diff --git a/src/components/CreateProject/CreateProject.tsx b/src/components/CreateProject/CreateProject.tsx index 0161188..31ea689 100644 --- a/src/components/CreateProject/CreateProject.tsx +++ b/src/components/CreateProject/CreateProject.tsx @@ -50,6 +50,7 @@ import { successDialog, } from "../Swal/CustomAlerts"; import dayjs from "dayjs"; +import { DELETE_PROJECT } from "@/middleware"; export interface Props { isEditMode: boolean; @@ -70,6 +71,7 @@ export interface Props { workNatures: WorkNature[]; allStaffs: StaffResult[]; grades: Grade[]; + abilities: string[]; } const hasErrorsInTab = ( @@ -113,6 +115,7 @@ const CreateProject: React.FC = ({ buildingTypes, workNatures, allStaffs, + abilities, }) => { const [serverError, setServerError] = useState(""); const [tabIndex, setTabIndex] = useState(0); @@ -302,7 +305,7 @@ const CreateProject: React.FC = ({ {isEditMode && !(formProps.getValues("projectDeleted") === true) && ( {/* {!formProps.getValues("projectActualStart") && ( */} - {formProps.getValues("projectStatus").toLowerCase() === "pending to start" && ( + {formProps.getValues("projectStatus")?.toLowerCase() === "pending to start" && (