From 293dfb7bb464b03ac34401db37a3418605eac974 Mon Sep 17 00:00:00 2001 From: Wayne Date: Tue, 30 Apr 2024 23:55:11 +0900 Subject: [PATCH 01/37] Add edit project page --- .../(main)/projects/edit/[projectId]/page.tsx | 62 +++++++ src/app/api/projects/actions.ts | 6 +- src/app/api/projects/index.ts | 14 +- .../CreateProject/CreateProject.tsx | 163 ++++++++++-------- .../CreateProject/CreateProjectWrapper.tsx | 17 +- .../ProjectSearch/ProjectSearch.tsx | 11 +- .../TableCellEdit/TableCellEdit.tsx | 8 +- .../TimesheetTable/EntryInputTable.tsx | 10 +- 8 files changed, 209 insertions(+), 82 deletions(-) create mode 100644 src/app/(main)/projects/edit/[projectId]/page.tsx diff --git a/src/app/(main)/projects/edit/[projectId]/page.tsx b/src/app/(main)/projects/edit/[projectId]/page.tsx new file mode 100644 index 0000000..dbad026 --- /dev/null +++ b/src/app/(main)/projects/edit/[projectId]/page.tsx @@ -0,0 +1,62 @@ +import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; +import { fetchGrades } from "@/app/api/grades"; +import { + fetchProjectBuildingTypes, + fetchProjectCategories, + fetchProjectContractTypes, + fetchProjectDetails, + fetchProjectFundingTypes, + fetchProjectLocationTypes, + fetchProjectServiceTypes, + fetchProjectWorkNatures, +} from "@/app/api/projects"; +import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; +import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; +import CreateProject from "@/components/CreateProject"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import Typography from "@mui/material/Typography"; +import { Metadata } from "next"; + +interface Props { + params: { + projectId: string; + }; +} + +export const metadata: Metadata = { + title: "Edit Project", +}; + +const Projects: React.FC = async ({ params }) => { + const { t } = await getServerI18n("projects"); + + // Preload necessary dependencies + fetchAllTasks(); + fetchTaskTemplates(); + fetchProjectCategories(); + fetchProjectContractTypes(); + fetchProjectFundingTypes(); + fetchProjectLocationTypes(); + fetchProjectServiceTypes(); + fetchProjectBuildingTypes(); + fetchProjectWorkNatures(); + fetchAllCustomers(); + fetchAllSubsidiaries(); + fetchGrades(); + preloadTeamLeads(); + preloadStaff(); + + // TODO: Handle not found + const fetchedProject = await fetchProjectDetails(params.projectId); + + return ( + <> + {t("Edit Project")} + + + + + ); +}; + +export default Projects; diff --git a/src/app/api/projects/actions.ts b/src/app/api/projects/actions.ts index 232f863..121111a 100644 --- a/src/app/api/projects/actions.ts +++ b/src/app/api/projects/actions.ts @@ -4,6 +4,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; import { Task, TaskGroup } from "../tasks"; import { Customer } from "../customer"; +import { revalidateTag } from "next/cache"; export interface CreateProjectInputs { // Project details @@ -62,9 +63,12 @@ export interface PaymentInputs { } export const saveProject = async (data: CreateProjectInputs) => { - return serverFetchJson(`${BASE_API_URL}/projects/new`, { + const newProject = await serverFetchJson(`${BASE_API_URL}/projects/new`, { method: "POST", body: JSON.stringify(data), headers: { "Content-Type": "application/json" }, }); + + revalidateTag("projects"); + return newProject; }; diff --git a/src/app/api/projects/index.ts b/src/app/api/projects/index.ts index 7bfb067..90b0e10 100644 --- a/src/app/api/projects/index.ts +++ b/src/app/api/projects/index.ts @@ -3,6 +3,7 @@ import { BASE_API_URL } from "@/config/api"; import { cache } from "react"; import "server-only"; import { Task, TaskGroup } from "../tasks"; +import { CreateProjectInputs } from "./actions"; export interface ProjectResult { id: number; @@ -55,8 +56,8 @@ export interface AssignedProject { tasks: Task[]; milestones: { [taskGroupId: TaskGroup["id"]]: { - startDate: string; - endDate: string; + startDate?: string; + endDate?: string; }; }; // Manhour info @@ -145,3 +146,12 @@ export const fetchAssignedProjects = cache(async () => { }, ); }); + +export const fetchProjectDetails = cache(async (projectId: string) => { + return serverFetchJson( + `${BASE_API_URL}/projects/projectDetails/${projectId}`, + { + next: { tags: [`projectDetails_${projectId}`] }, + }, + ); +}); diff --git a/src/components/CreateProject/CreateProject.tsx b/src/components/CreateProject/CreateProject.tsx index 3e1a3f0..175d712 100644 --- a/src/components/CreateProject/CreateProject.tsx +++ b/src/components/CreateProject/CreateProject.tsx @@ -22,7 +22,7 @@ import { useForm, } from "react-hook-form"; import { CreateProjectInputs, saveProject } from "@/app/api/projects/actions"; -import { Error } from "@mui/icons-material"; +import { Delete, Error, PlayArrow } from "@mui/icons-material"; import { BuildingType, ContractType, @@ -38,6 +38,8 @@ import { Grade } from "@/app/api/grades"; import { Customer, Subsidiary } from "@/app/api/customer"; export interface Props { + isEditMode: boolean; + defaultInputs?: CreateProjectInputs; allTasks: Task[]; projectCategories: ProjectCategory[]; taskTemplates: TaskTemplate[]; @@ -69,6 +71,8 @@ const hasErrorsInTab = ( }; const CreateProject: React.FC = ({ + isEditMode, + defaultInputs, allTasks, projectCategories, taskTemplates, @@ -90,7 +94,7 @@ const CreateProject: React.FC = ({ const router = useRouter(); const handleCancel = () => { - router.back(); + router.replace("/projects"); }; const handleTabChange = useCallback>( @@ -128,7 +132,7 @@ const CreateProject: React.FC = ({ ); const formProps = useForm({ - defaultValues: { + defaultValues: defaultInputs ?? { taskGroups: {}, allocatedStaffIds: [], milestones: {}, @@ -142,76 +146,95 @@ const CreateProject: React.FC = ({ const errors = formProps.formState.errors; return ( - - - - - ) : undefined - } - iconPosition="end" - /> - - - - - { - - } - { - - } - { - - } - {} - {serverError && ( - - {serverError} - - )} - - - - - + )} + + + + + ) : undefined + } + iconPosition="end" + /> + + + + + { + + } + { + + } + { + + } + {} + {serverError && ( + + {serverError} + + )} + + + + + + + ); }; diff --git a/src/components/CreateProject/CreateProjectWrapper.tsx b/src/components/CreateProject/CreateProjectWrapper.tsx index 3ca2fae..ab9c830 100644 --- a/src/components/CreateProject/CreateProjectWrapper.tsx +++ b/src/components/CreateProject/CreateProjectWrapper.tsx @@ -4,6 +4,7 @@ import { fetchProjectBuildingTypes, fetchProjectCategories, fetchProjectContractTypes, + fetchProjectDetails, fetchProjectFundingTypes, fetchProjectLocationTypes, fetchProjectServiceTypes, @@ -13,7 +14,15 @@ import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; import { fetchGrades } from "@/app/api/grades"; -const CreateProjectWrapper: React.FC = async () => { +type CreateProjectProps = { isEditMode: false }; +interface EditProjectProps { + isEditMode: true; + projectId: string; +} + +type Props = CreateProjectProps | EditProjectProps; + +const CreateProjectWrapper: React.FC = async (props) => { const [ tasks, taskTemplates, @@ -46,8 +55,14 @@ const CreateProjectWrapper: React.FC = async () => { fetchGrades(), ]); + const projectInfo = props.isEditMode + ? await fetchProjectDetails(props.projectId) + : undefined; + return ( >; type SearchParamNames = keyof SearchQuery; const ProjectSearch: React.FC = ({ projects, projectCategories }) => { + const router = useRouter(); const { t } = useTranslation("projects"); const [filteredProjects, setFilteredProjects] = useState(projects); @@ -51,9 +53,12 @@ const ProjectSearch: React.FC = ({ projects, projectCategories }) => { setFilteredProjects(projects); }, [projects]); - const onProjectClick = useCallback((project: ProjectResult) => { - console.log(project); - }, []); + const onProjectClick = useCallback( + (project: ProjectResult) => { + router.push(`/projects/edit/${project.id}`); + }, + [router], + ); const columns = useMemo[]>( () => [ diff --git a/src/components/TableCellEdit/TableCellEdit.tsx b/src/components/TableCellEdit/TableCellEdit.tsx index a96f0b7..0a366fc 100644 --- a/src/components/TableCellEdit/TableCellEdit.tsx +++ b/src/components/TableCellEdit/TableCellEdit.tsx @@ -9,9 +9,9 @@ import { Box, Input, SxProps, TableCell } from "@mui/material"; interface Props { value: T; - onChange: (newValue?: T) => void; + onChange: (newValue: T) => void; renderValue?: (value: T) => string; - convertValue: (inputValue?: string) => T; + convertValue: (inputValue: string) => T; cellSx?: SxProps; inputSx?: SxProps; } @@ -25,7 +25,7 @@ const TableCellEdit = ({ inputSx, }: Props) => { const [editMode, setEditMode] = useState(false); - const [input, setInput] = useState(); + const [input, setInput] = useState(""); const inputRef = useRef(null); const onClick = useCallback(() => { @@ -41,7 +41,7 @@ const TableCellEdit = ({ const onBlur = useCallback(() => { setEditMode(false); onChange(convertValue(input)); - setInput(undefined); + setInput(""); }, [convertValue, input, onChange]); useEffect(() => { diff --git a/src/components/TimesheetTable/EntryInputTable.tsx b/src/components/TimesheetTable/EntryInputTable.tsx index 15dce8d..87eac77 100644 --- a/src/components/TimesheetTable/EntryInputTable.tsx +++ b/src/components/TimesheetTable/EntryInputTable.tsx @@ -37,7 +37,6 @@ type TimeEntryRow = Partial< _error: string; isPlanned: boolean; id: string; - taskGroupId: number; } >; @@ -221,6 +220,9 @@ const EntryInputTable: React.FC = ({ day, assignedProjects }) => { valueOptions() { return assignedProjects.map((p) => ({ value: p.id, label: p.name })); }, + valueGetter({ value }) { + return value ?? ""; + }, }, { field: "taskGroupId", @@ -228,6 +230,9 @@ const EntryInputTable: React.FC = ({ day, assignedProjects }) => { width: 200, editable: true, type: "singleSelect", + valueGetter({ value }) { + return value ?? ""; + }, valueOptions(params) { const updatedRow = params.id ? apiRef.current.getRowWithUpdatedValues(params.id, "") @@ -253,6 +258,9 @@ const EntryInputTable: React.FC = ({ day, assignedProjects }) => { width: 200, editable: true, type: "singleSelect", + valueGetter({ value }) { + return value ?? ""; + }, valueOptions(params) { const updatedRow = params.id ? apiRef.current.getRowWithUpdatedValues(params.id, "") From 966d7d0c65f37a801cd70b1aeb4d5342bb647356 Mon Sep 17 00:00:00 2001 From: Wayne Date: Wed, 1 May 2024 23:30:37 +0900 Subject: [PATCH 02/37] Add not-found page for edit project --- src/app/(main)/projects/edit/not-found.tsx | 17 ++++++++++++ .../projects/edit/{[projectId] => }/page.tsx | 26 ++++++++++++++----- src/app/utils/fetchUtil.ts | 15 ++++++++++- src/components/Breadcrumb/Breadcrumb.tsx | 1 + .../CreateProject/CreateProject.tsx | 22 +++++++++++----- .../ProjectSearch/ProjectSearch.tsx | 2 +- 6 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 src/app/(main)/projects/edit/not-found.tsx rename src/app/(main)/projects/edit/{[projectId] => }/page.tsx (69%) diff --git a/src/app/(main)/projects/edit/not-found.tsx b/src/app/(main)/projects/edit/not-found.tsx new file mode 100644 index 0000000..14e0e6d --- /dev/null +++ b/src/app/(main)/projects/edit/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 was not found!")} + + {t("Return to all projects")} + + + ); +} diff --git a/src/app/(main)/projects/edit/[projectId]/page.tsx b/src/app/(main)/projects/edit/page.tsx similarity index 69% rename from src/app/(main)/projects/edit/[projectId]/page.tsx rename to src/app/(main)/projects/edit/page.tsx index dbad026..78e0ed1 100644 --- a/src/app/(main)/projects/edit/[projectId]/page.tsx +++ b/src/app/(main)/projects/edit/page.tsx @@ -12,23 +12,30 @@ import { } from "@/app/api/projects"; import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; +import { ServerFetchError } from "@/app/utils/fetchUtil"; import CreateProject from "@/components/CreateProject"; import { I18nProvider, getServerI18n } from "@/i18n"; import Typography from "@mui/material/Typography"; +import { isArray } from "lodash"; import { Metadata } from "next"; +import { notFound } from "next/navigation"; interface Props { - params: { - projectId: string; - }; + searchParams: { [key: string]: string | string[] | undefined }; } export const metadata: Metadata = { title: "Edit Project", }; -const Projects: React.FC = async ({ params }) => { +const Projects: React.FC = async ({ searchParams }) => { const { t } = await getServerI18n("projects"); + // Assume projectId is string here + const projectId = searchParams["id"]; + + if (!projectId || isArray(projectId)) { + notFound(); + } // Preload necessary dependencies fetchAllTasks(); @@ -46,14 +53,19 @@ const Projects: React.FC = async ({ params }) => { preloadTeamLeads(); preloadStaff(); - // TODO: Handle not found - const fetchedProject = await fetchProjectDetails(params.projectId); + try { + await fetchProjectDetails(projectId); + } catch (e) { + if (e instanceof ServerFetchError && e.response?.status === 404) { + notFound(); + } + } return ( <> {t("Edit Project")} - + ); diff --git a/src/app/utils/fetchUtil.ts b/src/app/utils/fetchUtil.ts index 5060991..a519164 100644 --- a/src/app/utils/fetchUtil.ts +++ b/src/app/utils/fetchUtil.ts @@ -3,6 +3,16 @@ import { getServerSession } from "next-auth"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; +export class ServerFetchError extends Error { + public readonly response: Response | undefined; + constructor(message?: string, response?: Response) { + super(message); + this.response = response; + + Object.setPrototypeOf(this, ServerFetchError.prototype); + } +} + export const serverFetch: typeof fetch = async (input, init) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const session = await getServerSession(authOptions); @@ -37,7 +47,10 @@ export async function serverFetchJson(...args: FetchParams) { signOutUser(); default: console.error(await response.text()); - throw Error("Something went wrong fetching data in server."); + throw new ServerFetchError( + "Something went wrong fetching data in server.", + response, + ); } } } diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 3d6123a..a94670a 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -12,6 +12,7 @@ const pathToLabelMap: { [path: string]: string } = { "/home": "User Workspace", "/projects": "Projects", "/projects/create": "Create Project", + "/projects/edit": "Edit Project", "/tasks": "Task Template", "/tasks/create": "Create Task Template", "/staffReimbursement": "Staff Reimbursement", diff --git a/src/components/CreateProject/CreateProject.tsx b/src/components/CreateProject/CreateProject.tsx index 175d712..3a91a8f 100644 --- a/src/components/CreateProject/CreateProject.tsx +++ b/src/components/CreateProject/CreateProject.tsx @@ -36,6 +36,7 @@ import { StaffResult } from "@/app/api/staff"; import { Typography } from "@mui/material"; import { Grade } from "@/app/api/grades"; import { Customer, Subsidiary } from "@/app/api/customer"; +import { isEmpty } from "lodash"; export interface Props { isEditMode: boolean; @@ -108,13 +109,17 @@ const CreateProject: React.FC = ({ async (data) => { try { setServerError(""); - await saveProject(data); + if (isEditMode) { + console.log("edit project", data); + } else { + await saveProject(data); + } router.replace("/projects"); } catch (e) { setServerError(t("An error has occurred. Please try again later.")); } }, - [router, t], + [router, t, isEditMode], ); const onSubmitError = useCallback>( @@ -132,14 +137,19 @@ const CreateProject: React.FC = ({ ); const formProps = useForm({ - defaultValues: defaultInputs ?? { + defaultValues: { taskGroups: {}, allocatedStaffIds: [], milestones: {}, totalManhour: 0, - manhourPercentageByGrade: grades.reduce((acc, grade) => { - return { ...acc, [grade.id]: 1 / grades.length }; - }, {}), + ...defaultInputs, + + // manhourPercentageByGrade should have a sensible default + manhourPercentageByGrade: isEmpty(defaultInputs?.manhourPercentageByGrade) + ? grades.reduce((acc, grade) => { + return { ...acc, [grade.id]: 1 / grades.length }; + }, {}) + : defaultInputs.manhourPercentageByGrade, }, }); diff --git a/src/components/ProjectSearch/ProjectSearch.tsx b/src/components/ProjectSearch/ProjectSearch.tsx index d33ce14..1937fe2 100644 --- a/src/components/ProjectSearch/ProjectSearch.tsx +++ b/src/components/ProjectSearch/ProjectSearch.tsx @@ -55,7 +55,7 @@ const ProjectSearch: React.FC = ({ projects, projectCategories }) => { const onProjectClick = useCallback( (project: ProjectResult) => { - router.push(`/projects/edit/${project.id}`); + router.push(`/projects/edit?id=${project.id}`); }, [router], ); From ea4617a2dd316ef60b2bab4aac0eae1b79e47da7 Mon Sep 17 00:00:00 2001 From: "MSI\\derek" Date: Thu, 2 May 2024 14:22:26 +0800 Subject: [PATCH 03/37] update --- src/app/api/team/index.ts | 1 + src/components/EditTeam/Allocation.tsx | 18 +++++++--- src/components/EditTeam/EditTeam.tsx | 39 +++++++++++++++++---- src/components/EditTeam/EditTeamWrapper.tsx | 30 +++++++++++----- 4 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/app/api/team/index.ts b/src/app/api/team/index.ts index 3aa872d..d9d3b27 100644 --- a/src/app/api/team/index.ts +++ b/src/app/api/team/index.ts @@ -15,6 +15,7 @@ export interface TeamResult { staffName: string; posLabel: string; posCode: string; + teamLead: number; } diff --git a/src/components/EditTeam/Allocation.tsx b/src/components/EditTeam/Allocation.tsx index 44c89b1..2599867 100644 --- a/src/components/EditTeam/Allocation.tsx +++ b/src/components/EditTeam/Allocation.tsx @@ -35,9 +35,10 @@ import StarsIcon from "@mui/icons-material/Stars"; export interface Props { allStaffs: StaffResult[]; + teamLead: number; } -const Allocation: React.FC = ({ allStaffs: staff }) => { +const Allocation: React.FC = ({ allStaffs: staff, teamLead }) => { const { t } = useTranslation(); const searchParams = useSearchParams(); const idString = searchParams.get("id"); @@ -53,9 +54,16 @@ const Allocation: React.FC = ({ allStaffs: staff }) => { const initialStaffs = staff.map((s) => ({ ...s })); const [filteredStaff, setFilteredStaff] = useState(initialStaffs); - const [selectedStaff, setSelectedStaff] = useState( - filteredStaff.filter((s) => getValues("addStaffIds")?.includes(s.id)) + const [selectedStaff, setSelectedStaff] = useState(() => { + const rearrangedStaff = filteredStaff.sort((a, b) => { + if (a.id === teamLead) return -1; + if (b.id === teamLead) return 1; + return 0; + }); + return rearrangedStaff.filter((s) => getValues("addStaffIds")?.includes(s.id)) + } ); + console.log(filteredStaff.filter((s) => getValues("addStaffIds")?.includes(s.id))) const [seletedTeamLead, setSeletedTeamLead] = useState(); const [deletedStaffIds, setDeletedStaffIds] = useState([]); @@ -84,8 +92,8 @@ const Allocation: React.FC = ({ allStaffs: staff }) => { }, getValues("addStaffIds") ); - console.log(rearrangedList); - console.log(selectedStaff); + // console.log(rearrangedList); + // console.log(selectedStaff); const rearrangedStaff = rearrangedList.map((id) => { return selectedStaff.find((staff) => staff.id === id); diff --git a/src/components/EditTeam/EditTeam.tsx b/src/components/EditTeam/EditTeam.tsx index 095559b..432bc0e 100644 --- a/src/components/EditTeam/EditTeam.tsx +++ b/src/components/EditTeam/EditTeam.tsx @@ -20,12 +20,15 @@ import { StaffResult } from "@/app/api/staff"; interface desc { id: number; + name: string; description: string; + teamLead: number; } interface Props { staff: StaffResult[]; desc: desc[]; + // teamLead: StaffResult[] } const EditTeam: React.FC = async ({ staff, desc }) => { @@ -37,6 +40,8 @@ const EditTeam: React.FC = async ({ staff, desc }) => { const [filteredItems, setFilteredItems] = useState(); const [allStaffs, setAllStaffs] = useState(); const [filteredDesc, setFilteredDesc] = useState(); + const [filteredName, setFilteredName] = useState(); + const [teamLead, setTeamLead] = useState(); const [tabIndex, setTabIndex] = useState(0); const router = useRouter(); // const [selectedStaff, setSelectedStaff] = useState( @@ -63,25 +68,47 @@ const EditTeam: React.FC = async ({ staff, desc }) => { ); useEffect(() => { let idList: number[] = [] + console.log(desc) if (idString) { const filteredTeam = staff.filter( - (item) => item.teamId === parseInt(idString) + (item) => { + console.log(item) + console.log(parseInt(idString)) + return (item.teamId === parseInt(idString))} ); + console.log(filteredTeam) const tempDesc = desc.filter( (item) => item.id === parseInt(idString) ) - + // const leader = teamLead.filter( + // (staff) => staff.teamId === parseInt(idString) + // ) + // console.log(leader) + console.log(tempDesc[0].teamLead) + setTeamLead(tempDesc[0].teamLead) if (filteredTeam.length > 0) { const filteredIds: number[] = filteredTeam.map((i) => ( i.id - )) + )) + + // const teamLead = tempDesc[0].teamLead + // const index = filteredIds.indexOf(teamLead); + + // if (index !== -1) { + // filteredIds.splice(index, 1); + // filteredIds.unshift(teamLead); + // } + idList = filteredIds + console.log(filteredIds) } - // console.log(filteredIds) + console.log(idList) setFilteredItems(filteredTeam); formProps.reset({description: tempDesc[0].description, addStaffIds: idList}) setFilteredDesc(tempDesc[0].description) + setFilteredName(tempDesc[0].name) } + console.log(staff) setAllStaffs(staff) @@ -139,7 +166,7 @@ const EditTeam: React.FC = async ({ staff, desc }) => { > - {t("Edit Team")} + {t("Edit Team")} - {filteredName} = async ({ staff, desc }) => { {tabIndex === 0 && } - {tabIndex === 1 && } + {tabIndex === 1 && } - - - )} + {isEditMode && !(formProps.getValues("projectDeleted") === true) && ( + + {!formProps.getValues("projectActualStart") && } + {formProps.getValues("projectActualStart") && !formProps.getValues("projectActualEnd") && } + {!(formProps.getValues("projectActualStart") && formProps.getValues("projectActualEnd")) && } + + )} = ({ > {t("Cancel")} - diff --git a/src/components/CustomerSave/CustomerSave.tsx b/src/components/CustomerSave/CustomerSave.tsx index fc2469e..acb4ecf 100644 --- a/src/components/CustomerSave/CustomerSave.tsx +++ b/src/components/CustomerSave/CustomerSave.tsx @@ -199,20 +199,20 @@ const CustomerSave: React.FC = ({ setServerError(""); submitDialog(async () => { - const response = await saveCustomer(data); - - if (response.message === "Success") { - successDialog(t("Submit Success"), t).then(() => { - router.replace("/settings/customer"); - }) - } else { - errorDialog(t("Submit Fail"), t).then(() => { - formProps.setError("code", { message: response.message, type: "custom" }) - setTabIndex(0) - return false - }) - } - }, t) + const response = await saveCustomer(data); + + if (response.message === "Success") { + successDialog(t("Submit Success"), t).then(() => { + router.replace("/settings/customer"); + }) + } else { + errorDialog(t("Submit Fail"), t).then(() => { + formProps.setError("code", { message: response.message, type: "custom" }) + setTabIndex(0) + return false + }) + } + }, t) } catch (e) { console.log(e) setServerError(t("An error has occurred. Please try again later.")); diff --git a/src/components/Swal/CustomAlerts.js b/src/components/Swal/CustomAlerts.js index 6eddfb2..668502c 100644 --- a/src/components/Swal/CustomAlerts.js +++ b/src/components/Swal/CustomAlerts.js @@ -50,13 +50,13 @@ export const warningDialog = (text, t) => { }) } -export const submitDialog = async (confirmAction, t) => { +export const submitDialog = async (confirmAction, t, {...props}) => { // const { t } = useTranslation("common") const result = await Swal.fire({ icon: "question", - title: t("Do you want to submit?"), + title: props.title ?? t("Do you want to submit?"), cancelButtonText: t("Cancel"), - confirmButtonText: t("Submit"), + confirmButtonText: props.confirmButtonText ?? t("Submit"), showCancelButton: true, showConfirmButton: true, }); From dd9ded893c28aea048da7c53fd4b7f19f95458ad Mon Sep 17 00:00:00 2001 From: "MSI\\2Fi" Date: Fri, 3 May 2024 15:18:28 +0800 Subject: [PATCH 05/37] Add company holiday --- src/components/NavigationContent/NavigationContent.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 405f454..ab2e167 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -23,6 +23,7 @@ import Department from '@mui/icons-material/Diversity3'; import Position from '@mui/icons-material/Paragliding'; import Salary from '@mui/icons-material/AttachMoney'; import Team from '@mui/icons-material/Paragliding'; +import Holiday from '@mui/icons-material/CalendarMonth'; import { useTranslation } from "react-i18next"; import Typography from "@mui/material/Typography"; import { usePathname } from "next/navigation"; @@ -119,6 +120,7 @@ const navigationItems: NavigationItem[] = [ { icon: , label: "Position", path: "/settings/position" }, { icon: , label: "Salary", path: "/settings/salary" }, { icon: , label: "Team", path: "/settings/team" }, + { icon: , label: "Holiday", path: "/settings/holiday" }, ], }, ]; From d0faa602dd587d015fd52ae47036a11a2fd7c9ac Mon Sep 17 00:00:00 2001 From: "MSI\\2Fi" Date: Fri, 3 May 2024 15:19:18 +0800 Subject: [PATCH 06/37] Add Full Calander Library --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2578a2f..2ffb000 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,12 @@ "@unly/universal-language-detector": "^2.0.3", "apexcharts": "^3.45.2", "dayjs": "^1.11.10", + "fullcalendar": "^6.1.11", "i18next": "^23.7.11", "i18next-resources-to-backend": "^1.2.0", "lodash": "^4.17.21", "next": "14.0.4", - "next-auth": "^4.24.5", + "next-auth": "^4.24.7", "next-pwa": "^5.6.0", "react": "^18", "react-apexcharts": "^1.4.1", From 9593c1a7300945915947389d0855763fe9361263 Mon Sep 17 00:00:00 2001 From: "MSI\\derek" Date: Fri, 3 May 2024 16:25:10 +0800 Subject: [PATCH 07/37] auth update --- src/components/EditStaff/EditStaff.tsx | 2 +- .../StaffSearch/StaffSearchWrapper.tsx | 9 +- src/config/authConfig.ts | 11 +- src/middleware.ts | 115 +++++++++++++++++- 4 files changed, 129 insertions(+), 8 deletions(-) diff --git a/src/components/EditStaff/EditStaff.tsx b/src/components/EditStaff/EditStaff.tsx index 0d62e28..4c032cc 100644 --- a/src/components/EditStaff/EditStaff.tsx +++ b/src/components/EditStaff/EditStaff.tsx @@ -311,7 +311,7 @@ const EditStaff: React.FC = async () => { label: t(`Join Position`), type: "combo-Obj", options: positionCombo, - value: data[key].id ?? "", + value: data[key]?.id ?? "", required: true, } as Field; case "departDate": diff --git a/src/components/StaffSearch/StaffSearchWrapper.tsx b/src/components/StaffSearch/StaffSearchWrapper.tsx index c581ca8..59f9dc1 100644 --- a/src/components/StaffSearch/StaffSearchWrapper.tsx +++ b/src/components/StaffSearch/StaffSearchWrapper.tsx @@ -9,15 +9,22 @@ import { fetchPositionCombo } from "@/app/api/positions/actions"; import { fetchGradeCombo } from "@/app/api/grades/actions"; import { fetchSkillCombo } from "@/app/api/skill/actions"; import { fetchSalaryCombo } from "@/app/api/salarys/actions"; +import { Session, getServerSession } from "next-auth"; +import { authOptions } from "@/config/authConfig"; // import { preloadStaff } from "@/app/api/staff"; interface SubComponents { Loading: typeof StaffSearchLoading; } +interface SessionWithAbilities extends Session { + abilities?: string[] +} + const StaffSearchWrapper: React.FC & SubComponents = async () => { const staff = await fetchStaff(); - console.log(staff); + const session = await getServerSession(authOptions) as SessionWithAbilities; + console.log(session.abilities); return ; }; diff --git a/src/config/authConfig.ts b/src/config/authConfig.ts index e0c2860..e10f60d 100644 --- a/src/config/authConfig.ts +++ b/src/config/authConfig.ts @@ -3,10 +3,17 @@ import CredentialsProvider from "next-auth/providers/credentials"; import { LOGIN_API_PATH } from "./api"; export interface SessionWithTokens extends Session { + abilities?: any[]; accessToken?: string; refreshToken?: string; } + +export interface ability { + actionSubjectCombo: string; +} + + export const authOptions: AuthOptions = { debug: process.env.NODE_ENV === "development", providers: [ @@ -48,10 +55,12 @@ export const authOptions: AuthOptions = { const sessionWithToken: SessionWithTokens = { ...session, // 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, }; - + + // console.log(sessionWithToken) return sessionWithToken; }, }, diff --git a/src/middleware.ts b/src/middleware.ts index 85204dd..adf79a4 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,50 @@ import { NextRequestWithAuth, withAuth } from "next-auth/middleware"; -import { authOptions } from "@/config/authConfig"; +import { ability, authOptions } from "@/config/authConfig"; import { NextFetchEvent, NextResponse } from "next/server"; +import { getToken } from "next-auth/jwt"; +import { ConnectingAirportsOutlined } from "@mui/icons-material"; +import { getServerSession } from "next-auth"; + +// abilities +export const [ + VIEW_USER, + MAINTAIN_USER, + MAINTAIN_TIMESHEET, + VIEW_TASK_TEMPLATE, + VIEW_GROUP, + VIEW_MASTERDATA, + MAINTAIN_MASTERDATA, + VIEW_DASHBOARD_SELF, + VIEW_DASHBOARD_ALL, + IMPORT_INVOICE, + MAINTAIN_GROUP, + GENERATE_REPORTS, + VIEW_STAFF_PROFILE, + IMPORT_RECEIPT, + MAINTAIN_TASK_TEMPLATE, + MAINTAIN_TIMESHEET_7DAYS, + VIEW_PROJECT, + MAINTAIN_PROJECT, +] = [ + 'VIEW_USER', + 'MAINTAIN_USER', + 'MAINTAIN_TIMESHEET', + 'VIEW_TASK_TEMPLATE', + 'VIEW_GROUP', + 'VIEW_MASTERDATA', + 'MAINTAIN_MASTERDATA', + 'VIEW_DASHBOARD_SELF', + 'VIEW_DASHBOARD_ALL', + 'IMPORT_INVOICE', + 'MAINTAIN_GROUP', + 'GENERATE_REPORTS', + 'VIEW_STAFF_PROFILE', + 'IMPORT_RECEIPT', + 'MAINTAIN_TASK_TEMPLATE', + 'MAINTAIN_TIMESHEET_7DAYS', + 'VIEW_PROJECT', + 'MAINTAIN_PROJECT' +] const PRIVATE_ROUTES = [ "/analytics", @@ -14,15 +58,12 @@ const PRIVATE_ROUTES = [ ]; const LANG_QUERY_PARAM = "lang"; -const authMiddleware = withAuth({ - pages: authOptions.pages, -}); - export default async function middleware( req: NextRequestWithAuth, event: NextFetchEvent, ) { const langPref = req.nextUrl.searchParams.get(LANG_QUERY_PARAM); + const token = await getToken({ req: req, secret: process.env.SECRET }); if (langPref) { // Redirect to same url without the lang query param + set cookies const newUrl = new URL(req.nextUrl); @@ -31,6 +72,70 @@ export default async function middleware( response.cookies.set("i18next", langPref); return response; } + + // const session = await getServerSession(authOptions); + // console.log(session); + + let abilities: string[] = [] + if (token) { + abilities = (token.abilities as ability[]).map((item: ability) => item.actionSubjectCombo); + } + + const authMiddleware = withAuth({ + pages: authOptions.pages, + callbacks: { + authorized: ({req, token}) => { + let isAuth = Boolean(token); + if (req.nextUrl.pathname.startsWith('/settings')) { + isAuth = [VIEW_MASTERDATA, MAINTAIN_MASTERDATA].some((ability) => abilities.includes(ability)); + } + if (req.nextUrl.pathname.startsWith('/settings/user')) { + isAuth = [MAINTAIN_USER, VIEW_USER].some((ability) => abilities.includes(ability)); + } + if (req.nextUrl.pathname.startsWith('/analytics')) { + isAuth = [GENERATE_REPORTS].some((ability) => abilities.includes(ability)); + } + if (req.nextUrl.pathname.startsWith('/settings/staff/edit')) { + isAuth = [VIEW_STAFF_PROFILE].some((ability) => abilities.includes(ability)); + } + return isAuth + } + } + }); + + + // for (const obj of abilities) { + // switch (obj.actionSubjectCombo.toLowerCase()) { + // case "maintain_user": + // // appendRoutes(settings) + // break; + // case "maintain_group": + // // appendRoutes("/testing-maintain_user") + // break; + // case "view_user": + // // appendRoutes("/testing-maintain_user") + // break; + // case "view_group": + // // appendRoutes("/testing-maintain_user") + // break; + // } + // } + +// console.log("TESTING_ROUTES: ") +// console.log(TESTING_ROUTES) + +// TESTING_ROUTES.some((route) => { +// if (req.nextUrl.pathname.startsWith(route)) { +// console.log("////////////////start//////////////// ") +// console.log("TESTING_ROUTES:") +// console.log("route:") +// console.log(route) +// console.log("pathname:") +// console.log(req.nextUrl.pathname) +// console.log("////////////////end////////////////") +// } +// return (req.nextUrl.pathname.startsWith(route)) +// }) // Matcher for using the auth middleware return PRIVATE_ROUTES.some((route) => req.nextUrl.pathname.startsWith(route)) From 831fe7ee316d0b216384c383b98dcf49156d73ce Mon Sep 17 00:00:00 2001 From: "MSI\\derek" Date: Fri, 3 May 2024 17:06:18 +0800 Subject: [PATCH 08/37] hide menu item based on user --- src/components/AppBar/NavigationToggle.tsx | 15 +- .../NavigationContent/NavigationContent.tsx | 199 +++++++++--------- .../StaffSearch/StaffSearchWrapper.tsx | 7 +- 3 files changed, 116 insertions(+), 105 deletions(-) diff --git a/src/components/AppBar/NavigationToggle.tsx b/src/components/AppBar/NavigationToggle.tsx index 9f61753..23551b7 100644 --- a/src/components/AppBar/NavigationToggle.tsx +++ b/src/components/AppBar/NavigationToggle.tsx @@ -4,10 +4,17 @@ import MenuIcon from "@mui/icons-material/Menu"; import NavigationContent from "../NavigationContent"; import React from "react"; import Drawer from "@mui/material/Drawer"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/config/authConfig"; +import { Session } from "inspector"; +export interface SessionWithAbilities extends Session { + abilities?: string[] +} -const NavigationToggle: React.FC = () => { +const NavigationToggle: React.FC = async () => { const [isOpened, setIsOpened] = React.useState(false); - + const session = await getServerSession(authOptions) as SessionWithAbilities; + const abilities: string[] = session.abilities || [] const openNavigation = () => { setIsOpened(true); }; @@ -18,7 +25,7 @@ const NavigationToggle: React.FC = () => { return ( <> - + { keepMounted: true, }} > - + , label: "User Workspace", path: "/home" }, - { - icon: , - label: "Dashboard", - path: "", - children: [ - { - icon: , - label: "Financial Summary", - path: "/dashboard/ProjectFinancialSummary", - }, - { - icon: , - label: "Company / Team Cash Flow", - path: "/dashboard/CompanyTeamCashFlow", - }, - { - icon: , - label: "Project Cash Flow", - path: "/dashboard/ProjectCashFlow", - }, - { - icon: , - label: "Project Status by Client", - path: "/dashboard/ProjectStatusByClient", - }, - { - icon: , - label: "Project Status by Team", - path: "/dashboard/ProjectStatusByTeam", - }, - { - icon: , - label: "Staff Utilization", - path: "/dashboard/StaffUtilization", - }, - { - icon: , - label: "Project Resource Summary", - path: "/dashboard/ProjectResourceSummary", - } - ], - }, - { - icon: , - label: "Staff Reimbursement", - path: "/staffReimbursement", - children: [ - { - icon: , - label: "Claim Approval", - path: "/staffReimbursement/ClaimApproval", - }, - { - icon: , - label: "Claim Summary", - path: "/staffReimbursement/ClaimSummary", - }, - ], - }, - { icon: , label: "Project Management", path: "/projects" }, - { icon: , label: "Task Template", path: "/tasks" }, - { icon: , label: "Invoice", path: "/invoice" }, - { icon: , label: "Analysis Report", path: "", - children: [ - {icon: , label:"Late Start Report", path: "/analytics/LateStartReport"}, - {icon: , label:"Delay Report", path: "/analytics/DelayReport"}, - {icon: , label:"Resource Overconsumption Report", path: "/analytics/ResourceOverconsumptionReport"}, - {icon: , label:"Cost and Expense Report", path: "/analytics/CostandExpenseReport"}, - {icon: , label:"Completion Report", path: "/analytics/ProjectCompletionReport"}, - {icon: , label:"Completion Report with Outstanding Un-billed Hours Report", path: "/analytics/ProjectCompletionReportWO"}, - {icon: , label:"Project Claims Report", path: "/analytics/ProjectClaimsReport"}, - {icon: , label:"Project P&L Report", path: "/analytics/ProjectPLReport"}, - {icon: , label:"Financial Status Report", path: "/analytics/FinancialStatusReport"}, - {icon: , label:"EX02 - Project Cash Flow Report", path: "/analytics/EX02ProjectCashFlowReport"}, - ], -}, - { - icon: , label: "Setting", path: "", - children: [ - { icon: , label: "Client", path: "/settings/customer" }, - { icon: , label: "Subsidiary", path: "/settings/subsidiary" }, - { icon: , label: "Staff", path: "/settings/staff" }, - { icon: , label: "Company", path: "/settings/company" }, - { icon: , label: "Skill", path: "/settings/skill" }, - { icon: , label: "Department", path: "/settings/department" }, - { icon: , label: "Position", path: "/settings/position" }, - { icon: , label: "Salary", path: "/settings/salary" }, - { icon: , label: "Team", path: "/settings/team" }, - { icon: , label: "User", path: "/settings/user" }, - { icon: , label: "Holiday", path: "/settings/holiday" }, +interface Props { + abilities: string[] +} +// console.log(getUserData()) + + +const NavigationContent: React.FC = async ({abilities}) => { + const navigationItems: NavigationItem[] = [ + { icon: , label: "User Workspace", path: "/home" }, + { + icon: , + label: "Dashboard", + path: "", + children: [ + { + icon: , + label: "Financial Summary", + path: "/dashboard/ProjectFinancialSummary", + }, + { + icon: , + label: "Company / Team Cash Flow", + path: "/dashboard/CompanyTeamCashFlow", + }, + { + icon: , + label: "Project Cash Flow", + path: "/dashboard/ProjectCashFlow", + }, + { + icon: , + label: "Project Status by Client", + path: "/dashboard/ProjectStatusByClient", + }, + { + icon: , + label: "Project Status by Team", + path: "/dashboard/ProjectStatusByTeam", + }, + { + icon: , + label: "Staff Utilization", + path: "/dashboard/StaffUtilization", + }, + { + icon: , + label: "Project Resource Summary", + path: "/dashboard/ProjectResourceSummary", + } + ], + }, + { + icon: , + label: "Staff Reimbursement", + path: "/staffReimbursement", + children: [ + { + icon: , + label: "Claim Approval", + path: "/staffReimbursement/ClaimApproval", + }, + { + icon: , + label: "Claim Summary", + path: "/staffReimbursement/ClaimSummary", + }, + ], + }, + { icon: , label: "Project Management", path: "/projects" }, + { icon: , label: "Task Template", path: "/tasks" }, + { icon: , label: "Invoice", path: "/invoice" }, + { icon: , label: "Analysis Report", path: "", isHidden: ![GENERATE_REPORTS].some((ability) => abilities.includes(ability)), + children: [ + {icon: , label:"Late Start Report", path: "/analytics/LateStartReport"}, + {icon: , label:"Delay Report", path: "/analytics/DelayReport"}, + {icon: , label:"Resource Overconsumption Report", path: "/analytics/ResourceOverconsumptionReport"}, + {icon: , label:"Cost and Expense Report", path: "/analytics/CostandExpenseReport"}, + {icon: , label:"Completion Report", path: "/analytics/ProjectCompletionReport"}, + {icon: , label:"Completion Report with Outstanding Un-billed Hours Report", path: "/analytics/ProjectCompletionReportWO"}, + {icon: , label:"Project Claims Report", path: "/analytics/ProjectClaimsReport"}, + {icon: , label:"Project P&L Report", path: "/analytics/ProjectPLReport"}, + {icon: , label:"Financial Status Report", path: "/analytics/FinancialStatusReport"}, + {icon: , label:"EX02 - Project Cash Flow Report", path: "/analytics/EX02ProjectCashFlowReport"}, ], }, -]; - -const NavigationContent: React.FC = () => { + { + icon: , label: "Setting", path: "", isHidden: ![VIEW_MASTERDATA, MAINTAIN_MASTERDATA].some((ability) => abilities.includes(ability)), + children: [ + { icon: , label: "Client", path: "/settings/customer" }, + { icon: , label: "Subsidiary", path: "/settings/subsidiary" }, + { icon: , label: "Staff", path: "/settings/staff" }, + { icon: , label: "Company", path: "/settings/company" }, + { icon: , label: "Skill", path: "/settings/skill" }, + { icon: , label: "Department", path: "/settings/department" }, + { icon: , label: "Position", path: "/settings/position" }, + { icon: , label: "Salary", path: "/settings/salary" }, + { icon: , label: "Team", path: "/settings/team" }, + { icon: , label: "User", path: "/settings/user", isHidden: ![MAINTAIN_USER, VIEW_USER].some((ability) => abilities.includes(ability))}, + { icon: , label: "Holiday", path: "/settings/holiday" }, + ], + }, + ]; const { t } = useTranslation("common"); const pathname = usePathname(); @@ -188,7 +195,7 @@ const NavigationContent: React.FC = () => { - {navigationItems.map((item) => renderNavigationItem(item))} + {navigationItems.filter(item => item.isHidden !== true).map((item) => renderNavigationItem(item))} {/* {navigationItems.map(({ icon, label, path }, index) => { return ( { const staff = await fetchStaff(); const session = await getServerSession(authOptions) as SessionWithAbilities; From 9e3f73f098316e0413c3d133a3e8f3e1a2db08b8 Mon Sep 17 00:00:00 2001 From: "MSI\\derek" Date: Fri, 3 May 2024 17:13:55 +0800 Subject: [PATCH 09/37] Revert "hide menu item based on user" This reverts commit 831fe7ee316d0b216384c383b98dcf49156d73ce. --- src/components/AppBar/NavigationToggle.tsx | 15 +- .../NavigationContent/NavigationContent.tsx | 199 +++++++++--------- .../StaffSearch/StaffSearchWrapper.tsx | 7 +- 3 files changed, 105 insertions(+), 116 deletions(-) diff --git a/src/components/AppBar/NavigationToggle.tsx b/src/components/AppBar/NavigationToggle.tsx index 23551b7..9f61753 100644 --- a/src/components/AppBar/NavigationToggle.tsx +++ b/src/components/AppBar/NavigationToggle.tsx @@ -4,17 +4,10 @@ import MenuIcon from "@mui/icons-material/Menu"; import NavigationContent from "../NavigationContent"; import React from "react"; import Drawer from "@mui/material/Drawer"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/config/authConfig"; -import { Session } from "inspector"; -export interface SessionWithAbilities extends Session { - abilities?: string[] -} -const NavigationToggle: React.FC = async () => { +const NavigationToggle: React.FC = () => { const [isOpened, setIsOpened] = React.useState(false); - const session = await getServerSession(authOptions) as SessionWithAbilities; - const abilities: string[] = session.abilities || [] + const openNavigation = () => { setIsOpened(true); }; @@ -25,7 +18,7 @@ const NavigationToggle: React.FC = async () => { return ( <> - + { keepMounted: true, }} > - + = async ({abilities}) => { - const navigationItems: NavigationItem[] = [ - { icon: , label: "User Workspace", path: "/home" }, - { - icon: , - label: "Dashboard", - path: "", - children: [ - { - icon: , - label: "Financial Summary", - path: "/dashboard/ProjectFinancialSummary", - }, - { - icon: , - label: "Company / Team Cash Flow", - path: "/dashboard/CompanyTeamCashFlow", - }, - { - icon: , - label: "Project Cash Flow", - path: "/dashboard/ProjectCashFlow", - }, - { - icon: , - label: "Project Status by Client", - path: "/dashboard/ProjectStatusByClient", - }, - { - icon: , - label: "Project Status by Team", - path: "/dashboard/ProjectStatusByTeam", - }, - { - icon: , - label: "Staff Utilization", - path: "/dashboard/StaffUtilization", - }, - { - icon: , - label: "Project Resource Summary", - path: "/dashboard/ProjectResourceSummary", - } - ], - }, - { - icon: , - label: "Staff Reimbursement", - path: "/staffReimbursement", - children: [ - { - icon: , - label: "Claim Approval", - path: "/staffReimbursement/ClaimApproval", - }, - { - icon: , - label: "Claim Summary", - path: "/staffReimbursement/ClaimSummary", - }, - ], - }, - { icon: , label: "Project Management", path: "/projects" }, - { icon: , label: "Task Template", path: "/tasks" }, - { icon: , label: "Invoice", path: "/invoice" }, - { icon: , label: "Analysis Report", path: "", isHidden: ![GENERATE_REPORTS].some((ability) => abilities.includes(ability)), - children: [ - {icon: , label:"Late Start Report", path: "/analytics/LateStartReport"}, - {icon: , label:"Delay Report", path: "/analytics/DelayReport"}, - {icon: , label:"Resource Overconsumption Report", path: "/analytics/ResourceOverconsumptionReport"}, - {icon: , label:"Cost and Expense Report", path: "/analytics/CostandExpenseReport"}, - {icon: , label:"Completion Report", path: "/analytics/ProjectCompletionReport"}, - {icon: , label:"Completion Report with Outstanding Un-billed Hours Report", path: "/analytics/ProjectCompletionReportWO"}, - {icon: , label:"Project Claims Report", path: "/analytics/ProjectClaimsReport"}, - {icon: , label:"Project P&L Report", path: "/analytics/ProjectPLReport"}, - {icon: , label:"Financial Status Report", path: "/analytics/FinancialStatusReport"}, - {icon: , label:"EX02 - Project Cash Flow Report", path: "/analytics/EX02ProjectCashFlowReport"}, +const navigationItems: NavigationItem[] = [ + { icon: , label: "User Workspace", path: "/home" }, + { + icon: , + label: "Dashboard", + path: "", + children: [ + { + icon: , + label: "Financial Summary", + path: "/dashboard/ProjectFinancialSummary", + }, + { + icon: , + label: "Company / Team Cash Flow", + path: "/dashboard/CompanyTeamCashFlow", + }, + { + icon: , + label: "Project Cash Flow", + path: "/dashboard/ProjectCashFlow", + }, + { + icon: , + label: "Project Status by Client", + path: "/dashboard/ProjectStatusByClient", + }, + { + icon: , + label: "Project Status by Team", + path: "/dashboard/ProjectStatusByTeam", + }, + { + icon: , + label: "Staff Utilization", + path: "/dashboard/StaffUtilization", + }, + { + icon: , + label: "Project Resource Summary", + path: "/dashboard/ProjectResourceSummary", + } + ], + }, + { + icon: , + label: "Staff Reimbursement", + path: "/staffReimbursement", + children: [ + { + icon: , + label: "Claim Approval", + path: "/staffReimbursement/ClaimApproval", + }, + { + icon: , + label: "Claim Summary", + path: "/staffReimbursement/ClaimSummary", + }, + ], + }, + { icon: , label: "Project Management", path: "/projects" }, + { icon: , label: "Task Template", path: "/tasks" }, + { icon: , label: "Invoice", path: "/invoice" }, + { icon: , label: "Analysis Report", path: "", + children: [ + {icon: , label:"Late Start Report", path: "/analytics/LateStartReport"}, + {icon: , label:"Delay Report", path: "/analytics/DelayReport"}, + {icon: , label:"Resource Overconsumption Report", path: "/analytics/ResourceOverconsumptionReport"}, + {icon: , label:"Cost and Expense Report", path: "/analytics/CostandExpenseReport"}, + {icon: , label:"Completion Report", path: "/analytics/ProjectCompletionReport"}, + {icon: , label:"Completion Report with Outstanding Un-billed Hours Report", path: "/analytics/ProjectCompletionReportWO"}, + {icon: , label:"Project Claims Report", path: "/analytics/ProjectClaimsReport"}, + {icon: , label:"Project P&L Report", path: "/analytics/ProjectPLReport"}, + {icon: , label:"Financial Status Report", path: "/analytics/FinancialStatusReport"}, + {icon: , label:"EX02 - Project Cash Flow Report", path: "/analytics/EX02ProjectCashFlowReport"}, + ], +}, + { + icon: , label: "Setting", path: "", + children: [ + { icon: , label: "Client", path: "/settings/customer" }, + { icon: , label: "Subsidiary", path: "/settings/subsidiary" }, + { icon: , label: "Staff", path: "/settings/staff" }, + { icon: , label: "Company", path: "/settings/company" }, + { icon: , label: "Skill", path: "/settings/skill" }, + { icon: , label: "Department", path: "/settings/department" }, + { icon: , label: "Position", path: "/settings/position" }, + { icon: , label: "Salary", path: "/settings/salary" }, + { icon: , label: "Team", path: "/settings/team" }, + { icon: , label: "User", path: "/settings/user" }, + { icon: , label: "Holiday", path: "/settings/holiday" }, ], }, - { - icon: , label: "Setting", path: "", isHidden: ![VIEW_MASTERDATA, MAINTAIN_MASTERDATA].some((ability) => abilities.includes(ability)), - children: [ - { icon: , label: "Client", path: "/settings/customer" }, - { icon: , label: "Subsidiary", path: "/settings/subsidiary" }, - { icon: , label: "Staff", path: "/settings/staff" }, - { icon: , label: "Company", path: "/settings/company" }, - { icon: , label: "Skill", path: "/settings/skill" }, - { icon: , label: "Department", path: "/settings/department" }, - { icon: , label: "Position", path: "/settings/position" }, - { icon: , label: "Salary", path: "/settings/salary" }, - { icon: , label: "Team", path: "/settings/team" }, - { icon: , label: "User", path: "/settings/user", isHidden: ![MAINTAIN_USER, VIEW_USER].some((ability) => abilities.includes(ability))}, - { icon: , label: "Holiday", path: "/settings/holiday" }, - ], - }, - ]; +]; + +const NavigationContent: React.FC = () => { const { t } = useTranslation("common"); const pathname = usePathname(); @@ -195,7 +188,7 @@ const NavigationContent: React.FC = async ({abilities}) => { - {navigationItems.filter(item => item.isHidden !== true).map((item) => renderNavigationItem(item))} + {navigationItems.map((item) => renderNavigationItem(item))} {/* {navigationItems.map(({ icon, label, path }, index) => { return ( { const staff = await fetchStaff(); const session = await getServerSession(authOptions) as SessionWithAbilities; From 12100698ab4ca863e6dcc86457647435f19c2f98 Mon Sep 17 00:00:00 2001 From: "MSI\\2Fi" Date: Fri, 3 May 2024 18:05:50 +0800 Subject: [PATCH 10/37] Company Holiday --- package.json | 2 + src/app/(main)/settings/holiday/page.tsx | 48 +++++ src/app/api/holidays/index.ts | 23 +++ .../CompanyHoliday/CompanyHoliday.tsx | 166 ++++++++++++++++++ .../CompanyHoliday/CompanyHolidayDialog.tsx | 79 +++++++++ .../CompanyHoliday/CompanyHolidayLoading.tsx | 40 +++++ .../CompanyHoliday/CompanyHolidayWrapper.tsx | 24 +++ src/components/CompanyHoliday/index.ts | 1 + 8 files changed, 383 insertions(+) create mode 100644 src/app/(main)/settings/holiday/page.tsx create mode 100644 src/app/api/holidays/index.ts create mode 100644 src/components/CompanyHoliday/CompanyHoliday.tsx create mode 100644 src/components/CompanyHoliday/CompanyHolidayDialog.tsx create mode 100644 src/components/CompanyHoliday/CompanyHolidayLoading.tsx create mode 100644 src/components/CompanyHoliday/CompanyHolidayWrapper.tsx create mode 100644 src/components/CompanyHoliday/index.ts diff --git a/package.json b/package.json index 2ffb000..14ae99a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@faker-js/faker": "^8.4.1", "@fontsource/inter": "^5.0.16", "@fontsource/plus-jakarta-sans": "^5.0.18", + "@fullcalendar/react": "^6.1.11", "@mui/icons-material": "^5.15.0", "@mui/material": "^5.15.0", "@mui/material-nextjs": "^5.15.0", @@ -22,6 +23,7 @@ "@mui/x-date-pickers": "^6.18.7", "@unly/universal-language-detector": "^2.0.3", "apexcharts": "^3.45.2", + "date-holidays": "^3.23.11", "dayjs": "^1.11.10", "fullcalendar": "^6.1.11", "i18next": "^23.7.11", diff --git a/src/app/(main)/settings/holiday/page.tsx b/src/app/(main)/settings/holiday/page.tsx new file mode 100644 index 0000000..16a5ac2 --- /dev/null +++ b/src/app/(main)/settings/holiday/page.tsx @@ -0,0 +1,48 @@ +import CompanyHoliday from "@/components/CompanyHoliday"; +import { Metadata } from "next"; +import { getServerI18n } from "@/i18n"; +import Add from "@mui/icons-material/Add"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Link from "next/link"; +import { Suspense } from "react"; +import { fetchCompanys, preloadCompanys } from "@/app/api/companys"; + +export const metadata: Metadata = { + title: "Holiday", +}; + +const Company: React.FC = async () => { + const { t } = await getServerI18n("holiday"); + + // Preload necessary dependencies + + return ( + <> + + + {t("Company Holiday")} + + {/* */} + + }> + + + + ) +}; + +export default Company; diff --git a/src/app/api/holidays/index.ts b/src/app/api/holidays/index.ts new file mode 100644 index 0000000..0a90ffc --- /dev/null +++ b/src/app/api/holidays/index.ts @@ -0,0 +1,23 @@ +import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { cache } from "react"; +import "server-only"; +import EventInput from '@fullcalendar/react'; + +export interface HolidaysResult extends EventInput { + title: string; + date: string; + extendedProps: { + calendar: string; + }; +} + +export const preloadCompanys = () => { + fetchHolidays(); +}; + +export const fetchHolidays = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/companys`, { + next: { tags: ["companys"] }, + }); +}); \ No newline at end of file diff --git a/src/components/CompanyHoliday/CompanyHoliday.tsx b/src/components/CompanyHoliday/CompanyHoliday.tsx new file mode 100644 index 0000000..bfa5ecb --- /dev/null +++ b/src/components/CompanyHoliday/CompanyHoliday.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { HolidaysResult } from "@/app/api/holidays"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Grid, Stack } from '@mui/material/'; +import { useTranslation } from "react-i18next"; +import FullCalendar from '@fullcalendar/react' +import dayGridPlugin from '@fullcalendar/daygrid' // a plugin! +import interactionPlugin from "@fullcalendar/interaction" // needed for dayClick +import Holidays from "date-holidays"; +import CompanyHolidayDialog from "./CompanyHolidayDialog"; +import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; +import { EventBusy } from "@mui/icons-material"; +interface Props { + holidays: HolidaysResult[]; +} + +const CompanyHoliday: React.FC = ({ holidays }) => { + const { t } = useTranslation("holidays"); + + const hd = new Holidays('HK') + console.log(holidays) + + const [companyHolidays, setCompanyHolidays] = useState([]) + const [dateContent, setDateContent] = useState<{ date: string }>({date: ''}) + const [open, setOpen] = useState(false); + + const handleClose = () => { + setOpen(false); + }; + + const getPublicHolidaysList = () => { + const currentYear = new Date().getFullYear() + const currentYearHolidays = hd.getHolidays(currentYear) + const nextYearHolidays = hd.getHolidays(currentYear + 1) + const events_cyhd = currentYearHolidays.map(ele => { + const tempDay = new Date(ele.date) + const tempYear = tempDay.getFullYear() + const tempMonth = tempDay.getMonth() + 1 < 10 ? `0${ tempDay.getMonth() + 1}` : tempDay.getMonth() + 1 + const tempDate = tempDay.getDate() < 10 ? `0${tempDay.getDate()}` : tempDay.getDate() + let tempName = "" + switch (ele.name) { + case "复活节": + tempName = "復活節" + break + case "劳动节": + tempName = "勞動節" + break + case "端午节": + tempName = "端午節" + break + case "重阳节": + tempName = "重陽節" + break + case "圣诞节后的第一个工作日": + tempName = "聖誕節後的第一个工作日" + break + default: + tempName = ele.name + break + } + + return {date: `${tempYear}-${tempMonth}-${tempDate}`, title: tempName, extendedProps: {calendar: 'holiday'}} + }) + + const events_nyhd = nextYearHolidays.map(ele => { + const tempDay = new Date(ele.date) + const tempYear = tempDay.getFullYear() + const tempMonth = tempDay.getMonth() + 1 < 10 ? `0${ tempDay.getMonth() + 1}` : tempDay.getMonth() + 1 + const tempDate = tempDay.getDate() < 10 ? `0${tempDay.getDate()}` : tempDay.getDate() + let tempName = "" + switch (ele.name) { + case "复活节": + tempName = "復活節" + break + case "劳动节": + tempName = "勞動節" + break + case "端午节": + tempName = "端午節" + break + case "重阳节": + tempName = "重陽節" + break + case "圣诞节后的第一个工作日": + tempName = "聖誕節後的第一个工作日" + break + default: + tempName = ele.name + break + } + return {date: `${tempYear}-${tempMonth}-${tempDate}`, title: tempName, extendedProps: {calendar: 'holiday'}} + }) + + setCompanyHolidays([...events_cyhd, ...events_nyhd] as HolidaysResult[]) + } + + useEffect(()=>{ + getPublicHolidaysList() + },[]) + + const handleDateClick = (event:any) => { + console.log(event.dateStr) + setDateContent({date: event.dateStr}) + setOpen(true); + } + + const handleEventClick = (event:any) => { + console.log(event) + } + + const onSubmit = useCallback>( + async (data) => { + try { + console.log(data); + // console.log(JSON.stringify(data)); + } catch (e) { + console.log(e); + } + }, + [t, ], + ); + + const onSubmitError = useCallback>( + (errors) => { + console.log(errors) + }, + [], + ); + + + const formProps = useForm({ + defaultValues: { + title: "" + }, + }); + + return ( + <> + + + + + + + } + /> + + + ); +}; + +export default CompanyHoliday; diff --git a/src/components/CompanyHoliday/CompanyHolidayDialog.tsx b/src/components/CompanyHoliday/CompanyHolidayDialog.tsx new file mode 100644 index 0000000..3b9419f --- /dev/null +++ b/src/components/CompanyHoliday/CompanyHolidayDialog.tsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Grid, FormControl } from '@mui/material/'; +import { useForm, useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; +import dayjs from 'dayjs'; +import { INPUT_DATE_FORMAT } from '@/app/utils/formatUtil'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +interface CompanyHolidayDialogProps { + open: boolean; + onClose: () => void; + title: string; + actions: React.ReactNode; + content: Content +} + +interface Content { + date: string +} + +const CompanyHolidayDialog: React.FC = ({ open, onClose, title, actions, content }) => { + const { + t, + i18n: { language }, + } = useTranslation(); + + const { + register, + formState: { errors }, + setValue, + getValues, + } = useFormContext(); + + return ( + + + {title} + + + + + + + + { + if (!date) return; + setValue("dueDate", date.format(INPUT_DATE_FORMAT)); + }} + slotProps={{ + textField: { + helperText: 'MM/DD/YYYY', + }, + }} + /> + + + + + {actions} + + + ); +}; + +export default CompanyHolidayDialog; \ No newline at end of file diff --git a/src/components/CompanyHoliday/CompanyHolidayLoading.tsx b/src/components/CompanyHoliday/CompanyHolidayLoading.tsx new file mode 100644 index 0000000..5b8c02d --- /dev/null +++ b/src/components/CompanyHoliday/CompanyHolidayLoading.tsx @@ -0,0 +1,40 @@ +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Skeleton from "@mui/material/Skeleton"; +import Stack from "@mui/material/Stack"; +import React from "react"; + +// Can make this nicer +export const CompanyHolidayLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CompanyHolidayLoading; diff --git a/src/components/CompanyHoliday/CompanyHolidayWrapper.tsx b/src/components/CompanyHoliday/CompanyHolidayWrapper.tsx new file mode 100644 index 0000000..0c21148 --- /dev/null +++ b/src/components/CompanyHoliday/CompanyHolidayWrapper.tsx @@ -0,0 +1,24 @@ +// import { fetchCompanyCategories, fetchCompanys } from "@/app/api/companys"; +import React, { useState, } from "react"; +import CompanyHoliday from "./CompanyHoliday"; +import CompanyHolidayLoading from "./CompanyHolidayLoading"; +import { fetchCompanys } from "@/app/api/companys"; +import Holidays from "date-holidays"; +import { HolidaysResult, fetchHolidays } from "@/app/api/holidays"; + +interface SubComponents { + Loading: typeof CompanyHolidayLoading; +} + +const CompanyHolidayWrapper: React.FC & SubComponents = async () => { + // const Companys = await fetchCompanys(); + + const companyHolidays: HolidaysResult[] = await fetchHolidays() + + + return ; +}; + +CompanyHolidayWrapper.Loading = CompanyHolidayLoading; + +export default CompanyHolidayWrapper; diff --git a/src/components/CompanyHoliday/index.ts b/src/components/CompanyHoliday/index.ts new file mode 100644 index 0000000..3dddafc --- /dev/null +++ b/src/components/CompanyHoliday/index.ts @@ -0,0 +1 @@ +export { default } from "./CompanyHolidayWrapper"; From 46300064abe67ae980819cbd21d72b64286ab3ca Mon Sep 17 00:00:00 2001 From: "MSI\\2Fi" Date: Fri, 3 May 2024 18:19:16 +0800 Subject: [PATCH 11/37] [Wayne] For building project, logic needs to be checked 1. Update CreateProjectProps: add to ? --- src/components/CreateProject/CreateProjectWrapper.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/CreateProject/CreateProjectWrapper.tsx b/src/components/CreateProject/CreateProjectWrapper.tsx index ab9c830..8f35f28 100644 --- a/src/components/CreateProject/CreateProjectWrapper.tsx +++ b/src/components/CreateProject/CreateProjectWrapper.tsx @@ -16,8 +16,8 @@ import { fetchGrades } from "@/app/api/grades"; type CreateProjectProps = { isEditMode: false }; interface EditProjectProps { - isEditMode: true; - projectId: string; + isEditMode?: true; + projectId?: string; } type Props = CreateProjectProps | EditProjectProps; From 6d8d1a807c04ac1800a21332bbf6d775238706eb Mon Sep 17 00:00:00 2001 From: "MSI\\2Fi" Date: Fri, 3 May 2024 18:23:33 +0800 Subject: [PATCH 12/37] [Wayne] Please check --- src/app/(main)/projects/create/page.tsx | 2 +- src/components/CreateProject/CreateProjectWrapper.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/(main)/projects/create/page.tsx b/src/app/(main)/projects/create/page.tsx index c652a70..1671262 100644 --- a/src/app/(main)/projects/create/page.tsx +++ b/src/app/(main)/projects/create/page.tsx @@ -43,7 +43,7 @@ const Projects: React.FC = async () => { <> {t("Create Project")} - + ); diff --git a/src/components/CreateProject/CreateProjectWrapper.tsx b/src/components/CreateProject/CreateProjectWrapper.tsx index 8f35f28..f380b2f 100644 --- a/src/components/CreateProject/CreateProjectWrapper.tsx +++ b/src/components/CreateProject/CreateProjectWrapper.tsx @@ -16,7 +16,7 @@ import { fetchGrades } from "@/app/api/grades"; type CreateProjectProps = { isEditMode: false }; interface EditProjectProps { - isEditMode?: true; + isEditMode: true; projectId?: string; } @@ -56,7 +56,7 @@ const CreateProjectWrapper: React.FC = async (props) => { ]); const projectInfo = props.isEditMode - ? await fetchProjectDetails(props.projectId) + ? await fetchProjectDetails(props.projectId!!) : undefined; return ( From 084ffa577a04630d091befc6f5811942af1ab0a5 Mon Sep 17 00:00:00 2001 From: "cyril.tsui" Date: Fri, 3 May 2024 18:36:50 +0800 Subject: [PATCH 13/37] update report --- .../page.tsx | 8 ++++---- src/app/api/reports/actions.ts | 6 +++--- src/app/api/reports/index.ts | 6 +++--- src/components/Breadcrumb/Breadcrumb.tsx | 2 +- ...enerateEX02ProjectCashFlowReportWrapper.tsx | 18 ------------------ .../GenerateEX02ProjectCashFlowReport/index.ts | 1 - .../GenerateProjectCashFlowReport.tsx} | 12 ++++++------ .../GenerateProjectCashFlowReportLoading.tsx} | 4 ++-- .../GenerateProjectCashFlowReportWrapper.tsx | 18 ++++++++++++++++++ .../GenerateProjectCashFlowReport/index.ts | 1 + .../NavigationContent/NavigationContent.tsx | 2 +- 11 files changed, 39 insertions(+), 39 deletions(-) rename src/app/(main)/analytics/{EX02ProjectCashFlowReport => ProjectCashFlowReport}/page.tsx (62%) delete mode 100644 src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReportWrapper.tsx delete mode 100644 src/components/GenerateEX02ProjectCashFlowReport/index.ts rename src/components/{GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReport.tsx => GenerateProjectCashFlowReport/GenerateProjectCashFlowReport.tsx} (77%) rename src/components/{GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReportLoading.tsx => GenerateProjectCashFlowReport/GenerateProjectCashFlowReportLoading.tsx} (90%) create mode 100644 src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReportWrapper.tsx create mode 100644 src/components/GenerateProjectCashFlowReport/index.ts diff --git a/src/app/(main)/analytics/EX02ProjectCashFlowReport/page.tsx b/src/app/(main)/analytics/ProjectCashFlowReport/page.tsx similarity index 62% rename from src/app/(main)/analytics/EX02ProjectCashFlowReport/page.tsx rename to src/app/(main)/analytics/ProjectCashFlowReport/page.tsx index d1d54d8..301ef12 100644 --- a/src/app/(main)/analytics/EX02ProjectCashFlowReport/page.tsx +++ b/src/app/(main)/analytics/ProjectCashFlowReport/page.tsx @@ -2,10 +2,10 @@ import { Metadata } from "next"; import { Suspense } from "react"; import { I18nProvider } from "@/i18n"; import { fetchProjects } from "@/app/api/projects"; -import GenerateEX02ProjectCashFlowReport from "@/components/GenerateEX02ProjectCashFlowReport"; +import GenerateProjectCashFlowReport from "@/components/GenerateProjectCashFlowReport"; export const metadata: Metadata = { - title: "EX02 - Project Cash Flow Report", + title: "Project Cash Flow Report", }; const ProjectCashFlowReport: React.FC = async () => { @@ -14,8 +14,8 @@ const ProjectCashFlowReport: React.FC = async () => { return ( <> - }> - + }> + diff --git a/src/app/api/reports/actions.ts b/src/app/api/reports/actions.ts index be74c6f..8f38af3 100644 --- a/src/app/api/reports/actions.ts +++ b/src/app/api/reports/actions.ts @@ -1,7 +1,7 @@ "use server"; import { serverFetchBlob, serverFetchJson } from "@/app/utils/fetchUtil"; -import { EX02ProjectCashFlowReportRequest } from "."; +import { ProjectCashFlowReportRequest } from "."; import { BASE_API_URL } from "@/config/api"; export interface FileResponse { @@ -9,9 +9,9 @@ export interface FileResponse { blobValue: Uint8Array; } -export const fetchEX02ProjectCashFlowReport = async (data: EX02ProjectCashFlowReportRequest) => { +export const fetchProjectCashFlowReport = async (data: ProjectCashFlowReportRequest) => { const reportBlob = await serverFetchBlob( - `${BASE_API_URL}/reports/EX02-ProjectCashFlowReport`, + `${BASE_API_URL}/reports/ProjectCashFlowReport`, { method: "POST", body: JSON.stringify(data), diff --git a/src/app/api/reports/index.ts b/src/app/api/reports/index.ts index 6baa7aa..9ab8f36 100644 --- a/src/app/api/reports/index.ts +++ b/src/app/api/reports/index.ts @@ -1,8 +1,8 @@ -// EX02 - Project Cash Flow Report -export interface EX02ProjectCashFlowReportFilter { +// - Project Cash Flow Report +export interface ProjectCashFlowReportFilter { project: string[]; } -export interface EX02ProjectCashFlowReportRequest { +export interface ProjectCashFlowReportRequest { projectId: number; } \ No newline at end of file diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index a94670a..72e8b35 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -29,7 +29,7 @@ const pathToLabelMap: { [path: string]: string } = { "/settings/position": "Position", "/settings/position/new": "Create Position", "/settings/salarys": "Salary", - "/analytics/EX02ProjectCashFlowReport": "EX02 - Project Cash Flow Report", + "/analytics/ProjectCashFlowReport": "Project Cash Flow Report", }; const Breadcrumb = () => { diff --git a/src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReportWrapper.tsx b/src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReportWrapper.tsx deleted file mode 100644 index 5bf1089..0000000 --- a/src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReportWrapper.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from "react"; -import GenerateEX02ProjectCashFlowReportLoading from "./GenerateEX02ProjectCashFlowReportLoading"; -import { fetchProjects } from "@/app/api/projects"; -import GenerateEX02ProjectCashFlowReport from "./GenerateEX02ProjectCashFlowReport"; - -interface SubComponents { - Loading: typeof GenerateEX02ProjectCashFlowReportLoading; -} - -const GenerateEX02ProjectCashFlowReportWrapper: React.FC & SubComponents = async () => { - const projects = await fetchProjects(); - - return ; -}; - -GenerateEX02ProjectCashFlowReportWrapper.Loading = GenerateEX02ProjectCashFlowReportLoading; - -export default GenerateEX02ProjectCashFlowReportWrapper; \ No newline at end of file diff --git a/src/components/GenerateEX02ProjectCashFlowReport/index.ts b/src/components/GenerateEX02ProjectCashFlowReport/index.ts deleted file mode 100644 index b547e33..0000000 --- a/src/components/GenerateEX02ProjectCashFlowReport/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./GenerateEX02ProjectCashFlowReportWrapper"; \ No newline at end of file diff --git a/src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReport.tsx b/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReport.tsx similarity index 77% rename from src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReport.tsx rename to src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReport.tsx index 7aec1c2..0b7d661 100644 --- a/src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReport.tsx +++ b/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReport.tsx @@ -4,8 +4,8 @@ import React, { useMemo } from "react"; import SearchBox, { Criterion } from "../SearchBox"; import { useTranslation } from "react-i18next"; import { ProjectResult } from "@/app/api/projects"; -import { EX02ProjectCashFlowReportFilter } from "@/app/api/reports"; -import { fetchEX02ProjectCashFlowReport } from "@/app/api/reports/actions"; +import { ProjectCashFlowReportFilter } from "@/app/api/reports"; +import { fetchProjectCashFlowReport } from "@/app/api/reports/actions"; import { downloadFile } from "@/app/utils/commonUtil"; import { BASE_API_URL } from "@/config/api"; @@ -13,10 +13,10 @@ interface Props { projects: ProjectResult[]; } -type SearchQuery = Partial>; +type SearchQuery = Partial>; type SearchParamNames = keyof SearchQuery; -const GenerateEX02ProjectCashFlowReport: React.FC = ({ projects }) => { +const GenerateProjectCashFlowReport: React.FC = ({ projects }) => { const { t } = useTranslation(); const projectCombo = projects.map(project => `${project.code} - ${project.name}`) @@ -35,7 +35,7 @@ const GenerateEX02ProjectCashFlowReport: React.FC = ({ projects }) => { if (query.project.length > 0 && query.project.toLocaleLowerCase() !== "all") { const projectIndex = projectCombo.findIndex(project => project === query.project) - const response = await fetchEX02ProjectCashFlowReport({ projectId: projects[projectIndex].id }) + const response = await fetchProjectCashFlowReport({ projectId: projects[projectIndex].id }) if (response) { downloadFile(new Uint8Array(response.blobValue), response.filename!!) } @@ -46,4 +46,4 @@ const GenerateEX02ProjectCashFlowReport: React.FC = ({ projects }) => { ); }; -export default GenerateEX02ProjectCashFlowReport; \ No newline at end of file +export default GenerateProjectCashFlowReport; \ No newline at end of file diff --git a/src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReportLoading.tsx b/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReportLoading.tsx similarity index 90% rename from src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReportLoading.tsx rename to src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReportLoading.tsx index 1792221..98514e0 100644 --- a/src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReportLoading.tsx +++ b/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReportLoading.tsx @@ -5,7 +5,7 @@ import Stack from "@mui/material/Stack"; import React from "react"; // Can make this nicer -export const GenerateEX02ProjectCashFlowReportLoading: React.FC = () => { +export const GenerateProjectCashFlowReportLoading: React.FC = () => { return ( <> @@ -35,4 +35,4 @@ export const GenerateEX02ProjectCashFlowReportLoading: React.FC = () => { ); }; -export default GenerateEX02ProjectCashFlowReportLoading; \ No newline at end of file +export default GenerateProjectCashFlowReportLoading; \ No newline at end of file diff --git a/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReportWrapper.tsx b/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReportWrapper.tsx new file mode 100644 index 0000000..218f43e --- /dev/null +++ b/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReportWrapper.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import GenerateProjectCashFlowReportLoading from "./GenerateProjectCashFlowReportLoading"; +import { fetchProjects } from "@/app/api/projects"; +import GenerateProjectCashFlowReport from "./GenerateProjectCashFlowReport"; + +interface SubComponents { + Loading: typeof GenerateProjectCashFlowReportLoading; +} + +const GenerateProjectCashFlowReportWrapper: React.FC & SubComponents = async () => { + const projects = await fetchProjects(); + + return ; +}; + +GenerateProjectCashFlowReportWrapper.Loading = GenerateProjectCashFlowReportLoading; + +export default GenerateProjectCashFlowReportWrapper; \ No newline at end of file diff --git a/src/components/GenerateProjectCashFlowReport/index.ts b/src/components/GenerateProjectCashFlowReport/index.ts new file mode 100644 index 0000000..5adef20 --- /dev/null +++ b/src/components/GenerateProjectCashFlowReport/index.ts @@ -0,0 +1 @@ +export { default } from "./GenerateProjectCashFlowReportWrapper"; \ No newline at end of file diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 9f0c3d7..5d79f0f 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -118,7 +118,7 @@ const navigationItems: NavigationItem[] = [ {icon: , label:"Project Claims Report", path: "/analytics/ProjectClaimsReport"}, {icon: , label:"Project P&L Report", path: "/analytics/ProjectPLReport"}, {icon: , label:"Financial Status Report", path: "/analytics/FinancialStatusReport"}, - {icon: , label:"EX02 - Project Cash Flow Report", path: "/analytics/EX02ProjectCashFlowReport"}, + {icon: , label:"Project Cash Flow Report", path: "/analytics/ProjectCashFlowReport"}, ], }, { From 795b67d68636bedee83375b6b6e8dc40c2ff35c9 Mon Sep 17 00:00:00 2001 From: Wayne Date: Sat, 4 May 2024 17:49:02 +0900 Subject: [PATCH 14/37] Add timehseet API --- src/app/(main)/home/page.tsx | 11 +- src/app/(main)/layout.tsx | 8 +- src/app/api/projects/actions.ts | 36 +++-- src/app/api/timesheets/actions.ts | 22 +++ src/app/api/timesheets/index.ts | 10 ++ .../CreateProject/CreateProject.tsx | 152 ++++++++++++------ .../CreateProject/CreateProjectWrapper.tsx | 2 +- .../TimesheetModal/TimesheetModal.tsx | 51 ++++-- .../TimesheetTable/EntryInputTable.tsx | 13 +- .../UserWorkspacePage/AssignedProjects.tsx | 8 +- .../UserWorkspacePage/UserWorkspacePage.tsx | 12 +- .../UserWorkspaceWrapper.tsx | 21 ++- src/config/authConfig.ts | 7 +- 13 files changed, 251 insertions(+), 102 deletions(-) create mode 100644 src/app/api/timesheets/index.ts diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx index 176c9a2..155388f 100644 --- a/src/app/(main)/home/page.tsx +++ b/src/app/(main)/home/page.tsx @@ -1,15 +1,24 @@ import { Metadata } from "next"; import { I18nProvider } from "@/i18n"; import UserWorkspacePage from "@/components/UserWorkspacePage"; +import { fetchTimesheets } from "@/app/api/timesheets"; +import { authOptions } from "@/config/authConfig"; +import { getServerSession } from "next-auth"; export const metadata: Metadata = { title: "User Workspace", }; const Home: React.FC = async () => { + const session = await getServerSession(authOptions); + // Get name for caching + const username = session!.user!.name!; + + await fetchTimesheets(username); + return ( - + ); }; diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index cfaa1a9..b93ed10 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -31,10 +31,10 @@ export default async function MainLayout({ padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" }, }} > - - - {children} - + + + {children} + ); diff --git a/src/app/api/projects/actions.ts b/src/app/api/projects/actions.ts index e383e89..6bb5596 100644 --- a/src/app/api/projects/actions.ts +++ b/src/app/api/projects/actions.ts @@ -1,13 +1,16 @@ "use server"; -import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; +import { + serverFetchJson, + serverFetchWithNoContent, +} from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; import { Task, TaskGroup } from "../tasks"; import { Customer } from "../customer"; import { revalidateTag } from "next/cache"; export interface CreateProjectInputs { - // Project + // Project projectId: number | null; projectDeleted: boolean | null; projectCode: string; @@ -67,19 +70,22 @@ export interface PaymentInputs { } export interface CreateProjectResponse { - id: number, - name: string, - code: string, - category: string, - team: string, - client: string, + id: number; + name: string; + code: string; + category: string; + team: string; + client: string; } export const saveProject = async (data: CreateProjectInputs) => { - const newProject = await serverFetchJson(`${BASE_API_URL}/projects/new`, { - method: "POST", - body: JSON.stringify(data), - headers: { "Content-Type": "application/json" }, - }); + const newProject = await serverFetchJson( + `${BASE_API_URL}/projects/new`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); revalidateTag("projects"); return newProject; @@ -94,5 +100,7 @@ export const deleteProject = async (id: number) => { }, ); - return project + revalidateTag("projects"); + revalidateTag("assignedProjects"); + return project; }; diff --git a/src/app/api/timesheets/actions.ts b/src/app/api/timesheets/actions.ts index 6b959ec..631c076 100644 --- a/src/app/api/timesheets/actions.ts +++ b/src/app/api/timesheets/actions.ts @@ -1,9 +1,13 @@ "use server"; +import { serverFetchJson } from "@/app/utils/fetchUtil"; import { ProjectResult } from "../projects"; import { Task, TaskGroup } from "../tasks"; +import { BASE_API_URL } from "@/config/api"; +import { revalidateTag } from "next/cache"; export interface TimeEntry { + id: number; projectId: ProjectResult["id"]; taskGroupId: TaskGroup["id"]; taskId: Task["id"]; @@ -13,3 +17,21 @@ export interface TimeEntry { export interface RecordTimesheetInput { [date: string]: TimeEntry[]; } + +export const saveTimesheet = async ( + data: RecordTimesheetInput, + username: string, +) => { + const savedRecords = await serverFetchJson( + `${BASE_API_URL}/timesheets/save`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); + + revalidateTag(`timesheets_${username}`); + + return savedRecords; +}; diff --git a/src/app/api/timesheets/index.ts b/src/app/api/timesheets/index.ts new file mode 100644 index 0000000..fd7d20d --- /dev/null +++ b/src/app/api/timesheets/index.ts @@ -0,0 +1,10 @@ +import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { cache } from "react"; +import { RecordTimesheetInput } from "./actions"; + +export const fetchTimesheets = cache(async (username: string) => { + return serverFetchJson(`${BASE_API_URL}/timesheets`, { + next: { tags: [`timesheets_${username}`] }, + }); +}); diff --git a/src/components/CreateProject/CreateProject.tsx b/src/components/CreateProject/CreateProject.tsx index fb57dd7..5511464 100644 --- a/src/components/CreateProject/CreateProject.tsx +++ b/src/components/CreateProject/CreateProject.tsx @@ -1,6 +1,6 @@ "use client"; -import DoneIcon from '@mui/icons-material/Done' +import DoneIcon from "@mui/icons-material/Done"; import Check from "@mui/icons-material/Check"; import Close from "@mui/icons-material/Close"; import Button from "@mui/material/Button"; @@ -22,7 +22,11 @@ import { SubmitHandler, useForm, } from "react-hook-form"; -import { CreateProjectInputs, deleteProject, saveProject } from "@/app/api/projects/actions"; +import { + CreateProjectInputs, + deleteProject, + saveProject, +} from "@/app/api/projects/actions"; import { Delete, Error, PlayArrow } from "@mui/icons-material"; import { BuildingType, @@ -38,7 +42,12 @@ import { Typography } from "@mui/material"; import { Grade } from "@/app/api/grades"; import { Customer, Subsidiary } from "@/app/api/customer"; import { isEmpty } from "lodash"; -import { deleteDialog, errorDialog, submitDialog, successDialog } from "../Swal/CustomAlerts"; +import { + deleteDialog, + errorDialog, + submitDialog, + successDialog, +} from "../Swal/CustomAlerts"; import dayjs from "dayjs"; export interface Props { @@ -103,16 +112,15 @@ const CreateProject: React.FC = ({ const handleDelete = () => { deleteDialog(async () => { - await deleteProject(formProps.getValues("projectId")!!) + await deleteProject(formProps.getValues("projectId")!); - const clickSuccessDialog = await successDialog("Delete Success", t) + const clickSuccessDialog = await successDialog("Delete Success", t); if (clickSuccessDialog) { router.replace("/projects"); } - - }, t) - } + }, t); + }; const handleTabChange = useCallback>( (_e, newValue) => { @@ -124,51 +132,55 @@ const CreateProject: React.FC = ({ const onSubmit = useCallback>( async (data, event) => { try { - console.log("first") + console.log("first"); setServerError(""); - let title = t("Do you want to submit?") - let confirmButtonText = t("Submit") - let successTitle = t("Submit Success") - let errorTitle = t("Submit Fail") - const buttonName = (event?.nativeEvent as any).submitter.name + let title = t("Do you want to submit?"); + let confirmButtonText = t("Submit"); + let successTitle = t("Submit Success"); + let errorTitle = t("Submit Fail"); + const buttonName = (event?.nativeEvent as any).submitter.name; if (buttonName === "start") { - title = t("Do you want to start?") - confirmButtonText = t("Start") - successTitle = t("Start Success") - errorTitle = t("Start Fail") + title = t("Do you want to start?"); + confirmButtonText = t("Start"); + successTitle = t("Start Success"); + errorTitle = t("Start Fail"); } else if (buttonName === "complete") { - title = t("Do you want to complete?") - confirmButtonText = t("Complete") - successTitle = t("Complete Success") - errorTitle = t("Complete Fail") + title = t("Do you want to complete?"); + confirmButtonText = t("Complete"); + successTitle = t("Complete Success"); + errorTitle = t("Complete Fail"); } - submitDialog(async () => { - if (buttonName === "start") { - data.projectActualStart = dayjs().format("YYYY-MM-DD") - } else if (buttonName === "complete") { - data.projectActualEnd = dayjs().format("YYYY-MM-DD") - } + submitDialog( + async () => { + if (buttonName === "start") { + data.projectActualStart = dayjs().format("YYYY-MM-DD"); + } else if (buttonName === "complete") { + data.projectActualEnd = dayjs().format("YYYY-MM-DD"); + } - const response = await saveProject(data); + const response = await saveProject(data); - if (response.id > 0) { - successDialog(successTitle, t).then(() => { - router.replace("/projects"); - }) - } else { - errorDialog(errorTitle, t).then(() => { - return false - }) - } - }, t, { title: title, confirmButtonText: confirmButtonText }) + if (response.id > 0) { + successDialog(successTitle, t).then(() => { + router.replace("/projects"); + }); + } else { + errorDialog(errorTitle, t).then(() => { + return false; + }); + } + }, + t, + { title: title, confirmButtonText: confirmButtonText }, + ); } catch (e) { setServerError(t("An error has occurred. Please try again later.")); } }, - [router, t, isEditMode], + [router, t], ); const onSubmitError = useCallback>( @@ -196,8 +208,8 @@ const CreateProject: React.FC = ({ // manhourPercentageByGrade should have a sensible default manhourPercentageByGrade: isEmpty(defaultInputs?.manhourPercentageByGrade) ? grades.reduce((acc, grade) => { - return { ...acc, [grade.id]: 1 / grades.length }; - }, {}) + return { ...acc, [grade.id]: 1 / grades.length }; + }, {}) : defaultInputs?.manhourPercentageByGrade, }, }); @@ -214,15 +226,42 @@ const CreateProject: React.FC = ({ > {isEditMode && !(formProps.getValues("projectDeleted") === true) && ( - {!formProps.getValues("projectActualStart") && } - {formProps.getValues("projectActualStart") && !formProps.getValues("projectActualEnd") && } - {!(formProps.getValues("projectActualStart") && formProps.getValues("projectActualEnd")) && } + {!formProps.getValues("projectActualStart") && ( + + )} + {formProps.getValues("projectActualStart") && + !formProps.getValues("projectActualEnd") && ( + + )} + {!( + formProps.getValues("projectActualStart") && + formProps.getValues("projectActualEnd") + ) && ( + + )} )} = ({ > {t("Cancel")} - diff --git a/src/components/CreateProject/CreateProjectWrapper.tsx b/src/components/CreateProject/CreateProjectWrapper.tsx index f380b2f..6ce6242 100644 --- a/src/components/CreateProject/CreateProjectWrapper.tsx +++ b/src/components/CreateProject/CreateProjectWrapper.tsx @@ -56,7 +56,7 @@ const CreateProjectWrapper: React.FC = async (props) => { ]); const projectInfo = props.isEditMode - ? await fetchProjectDetails(props.projectId!!) + ? await fetchProjectDetails(props.projectId!) : undefined; return ( diff --git a/src/components/TimesheetModal/TimesheetModal.tsx b/src/components/TimesheetModal/TimesheetModal.tsx index d6146b0..c336cef 100644 --- a/src/components/TimesheetModal/TimesheetModal.tsx +++ b/src/components/TimesheetModal/TimesheetModal.tsx @@ -12,8 +12,11 @@ import { import TimesheetTable from "../TimesheetTable"; import { useTranslation } from "react-i18next"; import { Check, Close } from "@mui/icons-material"; -import { FormProvider, useForm } from "react-hook-form"; -import { RecordTimesheetInput } from "@/app/api/timesheets/actions"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { + RecordTimesheetInput, + saveTimesheet, +} from "@/app/api/timesheets/actions"; import dayjs from "dayjs"; import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; import { AssignedProject } from "@/app/api/projects"; @@ -23,6 +26,8 @@ interface Props { onClose: () => void; timesheetType: "time" | "leave"; assignedProjects: AssignedProject[]; + username: string; + defaultTimesheets?: RecordTimesheetInput; } const modalSx: SxProps = { @@ -40,6 +45,8 @@ const TimesheetModal: React.FC = ({ onClose, timesheetType, assignedProjects, + username, + defaultTimesheets, }) => { const { t } = useTranslation("home"); @@ -48,15 +55,37 @@ const TimesheetModal: React.FC = ({ return Array(7) .fill(undefined) .reduce((acc, _, index) => { + const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); return { ...acc, - [today.subtract(index, "day").format(INPUT_DATE_FORMAT)]: [], + [date]: defaultTimesheets?.[date] ?? [], }; }, {}); - }, []); + }, [defaultTimesheets]); const formProps = useForm({ defaultValues }); + const onSubmit = useCallback>( + async (data) => { + const savedRecords = await saveTimesheet(data, username); + + const today = dayjs(); + const newFormValues = Array(7) + .fill(undefined) + .reduce((acc, _, index) => { + const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); + return { + ...acc, + [date]: savedRecords[date] ?? [], + }; + }, {}); + + formProps.reset(newFormValues); + onClose(); + }, + [formProps, onClose, username], + ); + const onCancel = useCallback(() => { formProps.reset(defaultValues); onClose(); @@ -66,7 +95,10 @@ const TimesheetModal: React.FC = ({ - + {t(timesheetType === "time" ? "Timesheet Input" : "Record Leave")} @@ -86,13 +118,8 @@ const TimesheetModal: React.FC = ({ > {t("Cancel")} - diff --git a/src/components/TimesheetTable/EntryInputTable.tsx b/src/components/TimesheetTable/EntryInputTable.tsx index 87eac77..2e704fe 100644 --- a/src/components/TimesheetTable/EntryInputTable.tsx +++ b/src/components/TimesheetTable/EntryInputTable.tsx @@ -36,7 +36,6 @@ type TimeEntryRow = Partial< _isNew: boolean; _error: string; isPlanned: boolean; - id: string; } >; @@ -74,21 +73,19 @@ const EntryInputTable: React.FC = ({ day, assignedProjects }) => { const { getValues, setValue } = useFormContext(); const currentEntries = getValues(day); - const [entries, setEntries] = useState( - currentEntries.map((e, index) => ({ ...e, id: `${day}-${index}` })) || [], - ); + const [entries, setEntries] = useState(currentEntries || []); const [rowModesModel, setRowModesModel] = useState({}); const apiRef = useGridApiRef(); const addRow = useCallback(() => { - const id = `${day}-${Date.now()}`; + const id = Date.now(); setEntries((e) => [...e, { id, _isNew: true }]); setRowModesModel((model) => ({ ...model, [id]: { mode: GridRowModes.Edit, fieldToFocus: "projectId" }, })); - }, [day]); + }, []); const validateRow = useCallback( (id: GridRowId) => { @@ -318,9 +315,11 @@ const EntryInputTable: React.FC = ({ day, assignedProjects }) => { e.inputHours && e.projectId && e.taskId && - e.taskGroupId, + e.taskGroupId && + e.id, ) .map((e) => ({ + id: e.id!, inputHours: e.inputHours!, projectId: e.projectId!, taskId: e.taskId!, diff --git a/src/components/UserWorkspacePage/AssignedProjects.tsx b/src/components/UserWorkspacePage/AssignedProjects.tsx index ccd088c..6baa133 100644 --- a/src/components/UserWorkspacePage/AssignedProjects.tsx +++ b/src/components/UserWorkspacePage/AssignedProjects.tsx @@ -14,9 +14,11 @@ import { Clear, Search } from "@mui/icons-material"; import ProjectGrid from "./ProjectGrid"; import { Props as UserWorkspaceProps } from "./UserWorkspacePage"; -const AssignedProjects: React.FC = ({ - assignedProjects, -}) => { +interface Props { + assignedProjects: UserWorkspaceProps["assignedProjects"]; +} + +const AssignedProjects: React.FC = ({ assignedProjects }) => { const { t } = useTranslation("home"); // Projects diff --git a/src/components/UserWorkspacePage/UserWorkspacePage.tsx b/src/components/UserWorkspacePage/UserWorkspacePage.tsx index fc0b233..e8d51d7 100644 --- a/src/components/UserWorkspacePage/UserWorkspacePage.tsx +++ b/src/components/UserWorkspacePage/UserWorkspacePage.tsx @@ -10,12 +10,19 @@ import ButtonGroup from "@mui/material/ButtonGroup"; import AssignedProjects from "./AssignedProjects"; import TimesheetModal from "../TimesheetModal"; import { AssignedProject } from "@/app/api/projects"; +import { RecordTimesheetInput } from "@/app/api/timesheets/actions"; export interface Props { assignedProjects: AssignedProject[]; + username: string; + defaultTimesheets: RecordTimesheetInput; } -const UserWorkspacePage: React.FC = ({ assignedProjects }) => { +const UserWorkspacePage: React.FC = ({ + assignedProjects, + username, + defaultTimesheets, +}) => { const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false); const [isLeaveModalVisible, setLeaveModalVisible] = useState(false); const { t } = useTranslation("home"); @@ -75,12 +82,15 @@ const UserWorkspacePage: React.FC = ({ assignedProjects }) => { isOpen={isTimeheetModalVisible} onClose={handleCloseTimesheetModal} assignedProjects={assignedProjects} + username={username} + defaultTimesheets={defaultTimesheets} /> diff --git a/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx b/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx index c311488..cd5fe66 100644 --- a/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx +++ b/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx @@ -1,9 +1,24 @@ import { fetchAssignedProjects } from "@/app/api/projects"; import UserWorkspacePage from "./UserWorkspacePage"; +import { fetchTimesheets } from "@/app/api/timesheets"; -const UserWorkspaceWrapper: React.FC = async () => { - const assignedProjects = await fetchAssignedProjects(); - return ; +interface Props { + username: string; +} + +const UserWorkspaceWrapper: React.FC = async ({ username }) => { + const [assignedProjects, timesheets] = await Promise.all([ + fetchAssignedProjects(), + fetchTimesheets(username), + ]); + + return ( + + ); }; export default UserWorkspaceWrapper; diff --git a/src/config/authConfig.ts b/src/config/authConfig.ts index e10f60d..2c2b9da 100644 --- a/src/config/authConfig.ts +++ b/src/config/authConfig.ts @@ -8,12 +8,10 @@ export interface SessionWithTokens extends Session { refreshToken?: string; } - export interface ability { actionSubjectCombo: string; } - export const authOptions: AuthOptions = { debug: process.env.NODE_ENV === "development", providers: [ @@ -55,11 +53,12 @@ export const authOptions: AuthOptions = { const sessionWithToken: SessionWithTokens = { ...session, // Add the data from the token to the session - abilities: (token.abilities as ability[]).map((item: ability) => item.actionSubjectCombo) as string[], + abilities: (token.abilities as ability[]).map( + (item: ability) => item.actionSubjectCombo, + ) as string[], accessToken: token.accessToken as string | undefined, refreshToken: token.refreshToken as string | undefined, }; - // console.log(sessionWithToken) return sessionWithToken; }, From da22362cd9e50d1126adc5e5a91cc935d8a7c968 Mon Sep 17 00:00:00 2001 From: "MSI\\derek" Date: Mon, 6 May 2024 13:32:54 +0800 Subject: [PATCH 15/37] hide menu items based on auth --- src/components/AppBar/AppBar.tsx | 9 +- src/components/AppBar/NavigationToggle.tsx | 16 +- .../NavigationContent/NavigationContent.tsx | 201 +++++++++--------- src/middleware.ts | 5 +- 4 files changed, 127 insertions(+), 104 deletions(-) diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index a38d2bd..57c190a 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -5,18 +5,23 @@ import Profile from "./Profile"; import Box from "@mui/material/Box"; import NavigationToggle from "./NavigationToggle"; import { I18nProvider } from "@/i18n"; +import { authOptions } from "@/config/authConfig"; +import { getServerSession } from "next-auth"; export interface AppBarProps { avatarImageSrc?: string; profileName: string; } -const AppBar: React.FC = ({ avatarImageSrc, profileName }) => { +const AppBar: React.FC = async ({ avatarImageSrc, profileName }) => { + const session = await getServerSession(authOptions) as any; + const abilities: string[] = session.abilities + console.log(abilities) return ( - + diff --git a/src/components/AppBar/NavigationToggle.tsx b/src/components/AppBar/NavigationToggle.tsx index 9f61753..f704267 100644 --- a/src/components/AppBar/NavigationToggle.tsx +++ b/src/components/AppBar/NavigationToggle.tsx @@ -4,8 +4,18 @@ import MenuIcon from "@mui/icons-material/Menu"; import NavigationContent from "../NavigationContent"; import React from "react"; import Drawer from "@mui/material/Drawer"; +import { Session } from "inspector"; +import { authOptions } from "@/config/authConfig"; +import { getServerSession } from "next-auth"; +export interface SessionWithAbilities extends Session { + abilities?: string[] +} -const NavigationToggle: React.FC = () => { +interface Props { + abilities?: string[] +} + +const NavigationToggle: React.FC = ({ abilities }) => { const [isOpened, setIsOpened] = React.useState(false); const openNavigation = () => { @@ -18,7 +28,7 @@ const NavigationToggle: React.FC = () => { return ( <> - + { keepMounted: true, }} > - + , label: "User Workspace", path: "/home" }, - { - icon: , - label: "Dashboard", - path: "", - children: [ - { - icon: , - label: "Financial Summary", - path: "/dashboard/ProjectFinancialSummary", - }, - { - icon: , - label: "Company / Team Cash Flow", - path: "/dashboard/CompanyTeamCashFlow", - }, - { - icon: , - label: "Project Cash Flow", - path: "/dashboard/ProjectCashFlow", - }, - { - icon: , - label: "Project Status by Client", - path: "/dashboard/ProjectStatusByClient", - }, - { - icon: , - label: "Project Status by Team", - path: "/dashboard/ProjectStatusByTeam", - }, - { - icon: , - label: "Staff Utilization", - path: "/dashboard/StaffUtilization", - }, - { - icon: , - label: "Project Resource Summary", - path: "/dashboard/ProjectResourceSummary", - } - ], - }, - { - icon: , - label: "Staff Reimbursement", - path: "/staffReimbursement", - children: [ - { - icon: , - label: "Claim Approval", - path: "/staffReimbursement/ClaimApproval", - }, - { - icon: , - label: "Claim Summary", - path: "/staffReimbursement/ClaimSummary", - }, - ], - }, - { icon: , label: "Project Management", path: "/projects" }, - { icon: , label: "Task Template", path: "/tasks" }, - { icon: , label: "Invoice", path: "/invoice" }, - { icon: , label: "Analysis Report", path: "", - children: [ - {icon: , label:"Late Start Report", path: "/analytics/LateStartReport"}, - {icon: , label:"Delay Report", path: "/analytics/DelayReport"}, - {icon: , label:"Resource Overconsumption Report", path: "/analytics/ResourceOverconsumptionReport"}, - {icon: , label:"Cost and Expense Report", path: "/analytics/CostandExpenseReport"}, - {icon: , label:"Completion Report", path: "/analytics/ProjectCompletionReport"}, - {icon: , label:"Completion Report with Outstanding Un-billed Hours Report", path: "/analytics/ProjectCompletionReportWO"}, - {icon: , label:"Project Claims Report", path: "/analytics/ProjectClaimsReport"}, - {icon: , label:"Project P&L Report", path: "/analytics/ProjectPLReport"}, - {icon: , label:"Financial Status Report", path: "/analytics/FinancialStatusReport"}, - {icon: , label:"Project Cash Flow Report", path: "/analytics/ProjectCashFlowReport"}, - ], -}, - { - icon: , label: "Setting", path: "", - children: [ - { icon: , label: "Client", path: "/settings/customer" }, - { icon: , label: "Subsidiary", path: "/settings/subsidiary" }, - { icon: , label: "Staff", path: "/settings/staff" }, - { icon: , label: "Company", path: "/settings/company" }, - { icon: , label: "Skill", path: "/settings/skill" }, - { icon: , label: "Department", path: "/settings/department" }, - { icon: , label: "Position", path: "/settings/position" }, - { icon: , label: "Salary", path: "/settings/salary" }, - { icon: , label: "Team", path: "/settings/team" }, - { icon: , label: "User", path: "/settings/user" }, - { icon: , label: "Holiday", path: "/settings/holiday" }, +interface Props { + abilities?: string[] +} + +const NavigationContent: React.FC = ({ abilities }) => { + const navigationItems: NavigationItem[] = [ + { icon: , label: "User Workspace", path: "/home" }, + { + icon: , + label: "Dashboard", + path: "", + children: [ + { + icon: , + label: "Financial Summary", + path: "/dashboard/ProjectFinancialSummary", + }, + { + icon: , + label: "Company / Team Cash Flow", + path: "/dashboard/CompanyTeamCashFlow", + }, + { + icon: , + label: "Project Cash Flow", + path: "/dashboard/ProjectCashFlow", + }, + { + icon: , + label: "Project Status by Client", + path: "/dashboard/ProjectStatusByClient", + }, + { + icon: , + label: "Project Status by Team", + path: "/dashboard/ProjectStatusByTeam", + }, + { + icon: , + label: "Staff Utilization", + path: "/dashboard/StaffUtilization", + }, + { + icon: , + label: "Project Resource Summary", + path: "/dashboard/ProjectResourceSummary", + } + ], + }, + { + icon: , + label: "Staff Reimbursement", + path: "/staffReimbursement", + children: [ + { + icon: , + label: "Claim Approval", + path: "/staffReimbursement/ClaimApproval", + }, + { + icon: , + label: "Claim Summary", + path: "/staffReimbursement/ClaimSummary", + }, + ], + }, + { icon: , label: "Project Management", path: "/projects" }, + { icon: , label: "Task Template", path: "/tasks" }, + { icon: , label: "Invoice", path: "/invoice" }, + { icon: , label: "Analysis Report", path: "", isHidden: ![GENERATE_REPORTS].some((ability) => abilities!!.includes(ability)), + children: [ + {icon: , label:"Late Start Report", path: "/analytics/LateStartReport"}, + {icon: , label:"Delay Report", path: "/analytics/DelayReport"}, + {icon: , label:"Resource Overconsumption Report", path: "/analytics/ResourceOverconsumptionReport"}, + {icon: , label:"Cost and Expense Report", path: "/analytics/CostandExpenseReport"}, + {icon: , label:"Completion Report", path: "/analytics/ProjectCompletionReport"}, + {icon: , label:"Completion Report with Outstanding Un-billed Hours Report", path: "/analytics/ProjectCompletionReportWO"}, + {icon: , label:"Project Claims Report", path: "/analytics/ProjectClaimsReport"}, + {icon: , label:"Project P&L Report", path: "/analytics/ProjectPLReport"}, + {icon: , label:"Financial Status Report", path: "/analytics/FinancialStatusReport"}, + {icon: , label:"EX02 - Project Cash Flow Report", path: "/analytics/EX02ProjectCashFlowReport"}, ], }, -]; + { + icon: , label: "Setting", path: "", isHidden: ![VIEW_MASTERDATA, MAINTAIN_MASTERDATA].some((ability) => abilities!!.includes(ability)), + children: [ + { icon: , label: "Client", path: "/settings/customer" }, + { icon: , label: "Subsidiary", path: "/settings/subsidiary" }, + { icon: , label: "Staff", path: "/settings/staff" }, + { icon: , label: "Company", path: "/settings/company" }, + { icon: , label: "Skill", path: "/settings/skill" }, + { icon: , label: "Department", path: "/settings/department" }, + { icon: , label: "Position", path: "/settings/position" }, + { icon: , label: "Salary", path: "/settings/salary" }, + { icon: , label: "Team", path: "/settings/team" }, + { icon: , label: "User", path: "/settings/user" }, + { icon: , label: "Holiday", path: "/settings/holiday" }, + ], + }, + ]; -const NavigationContent: React.FC = () => { const { t } = useTranslation("common"); const pathname = usePathname(); - const [openItems, setOpenItems] = React.useState([]); const toggleItem = (label: string) => { setOpenItems((prevOpenItems) => @@ -188,7 +195,7 @@ const NavigationContent: React.FC = () => { - {navigationItems.map((item) => renderNavigationItem(item))} + {navigationItems.filter(item => item.isHidden !== true).map((item) => renderNavigationItem(item))} {/* {navigationItems.map(({ icon, label, path }, index) => { return ( { let isAuth = Boolean(token); + if (!Boolean(token)) { + return Boolean(token) + } if (req.nextUrl.pathname.startsWith('/settings')) { isAuth = [VIEW_MASTERDATA, MAINTAIN_MASTERDATA].some((ability) => abilities.includes(ability)); } From 65f6987b8eff5014698da76d3d4d2d5f8930eea0 Mon Sep 17 00:00:00 2001 From: "MSI\\2Fi" Date: Mon, 6 May 2024 17:06:13 +0800 Subject: [PATCH 16/37] 1. Company Holiday month view and list view 2. Update Swal class, to be alwasy on top --- src/app/api/holidays/actions.ts | 33 +++++++ src/app/api/holidays/index.ts | 13 ++- src/app/utils/formatUtil.ts | 6 ++ .../CompanyHoliday/CompanyHoliday.tsx | 88 ++++++++++++++++--- .../CompanyHoliday/CompanyHolidayDialog.tsx | 22 +++-- .../CompanyHoliday/CompanyHolidayWrapper.tsx | 14 ++- src/components/Swal/CustomAlerts.js | 9 ++ src/components/Swal/sweetalert2.css | 7 ++ 8 files changed, 166 insertions(+), 26 deletions(-) create mode 100644 src/app/api/holidays/actions.ts create mode 100644 src/components/Swal/sweetalert2.css diff --git a/src/app/api/holidays/actions.ts b/src/app/api/holidays/actions.ts new file mode 100644 index 0000000..8874418 --- /dev/null +++ b/src/app/api/holidays/actions.ts @@ -0,0 +1,33 @@ +"use server"; + +import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { Dayjs } from "dayjs"; +import { cache } from "react"; + +export interface CreateCompanyHolidayInputs { + id: number; + name: string; + date: string; +} + +export const saveCompanyHoliday = async (data: CreateCompanyHolidayInputs) => { + return serverFetchJson(`${BASE_API_URL}/company-holidays/new`, { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); +}; + +export const deleteCompanyHoliday = async (id: number) => { + const holiday = await serverFetchWithNoContent( + `${BASE_API_URL}/company-holidays/${id}`, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }, + ); + + return holiday +}; + diff --git a/src/app/api/holidays/index.ts b/src/app/api/holidays/index.ts index 0a90ffc..1bf21aa 100644 --- a/src/app/api/holidays/index.ts +++ b/src/app/api/holidays/index.ts @@ -4,7 +4,8 @@ import { cache } from "react"; import "server-only"; import EventInput from '@fullcalendar/react'; -export interface HolidaysResult extends EventInput { +export interface HolidaysList extends EventInput { + id: string; title: string; date: string; extendedProps: { @@ -12,12 +13,18 @@ export interface HolidaysResult extends EventInput { }; } +export interface HolidaysResult { + id: string; + name: string; + date: number[]; +} + export const preloadCompanys = () => { fetchHolidays(); }; export const fetchHolidays = cache(async () => { - return serverFetchJson(`${BASE_API_URL}/companys`, { - next: { tags: ["companys"] }, + return serverFetchJson(`${BASE_API_URL}/company-holidays`, { + next: { tags: ["company-holidays"] }, }); }); \ No newline at end of file diff --git a/src/app/utils/formatUtil.ts b/src/app/utils/formatUtil.ts index 918b0ca..899c1cd 100644 --- a/src/app/utils/formatUtil.ts +++ b/src/app/utils/formatUtil.ts @@ -30,6 +30,12 @@ export const convertDateArrayToString = (dateArray: number[], format: string = O return dayjs(dateString).format(format) } } + if (dateArray.length === 3) { + if (!needTime) { + const dateString = `${dateArray[0]}-${dateArray[1]}-${dateArray[2]}` + return dayjs(dateString).format(format) + } + } } const shortDateFormatter_en = new Intl.DateTimeFormat("en-HK", { diff --git a/src/components/CompanyHoliday/CompanyHoliday.tsx b/src/components/CompanyHoliday/CompanyHoliday.tsx index bfa5ecb..291a2df 100644 --- a/src/components/CompanyHoliday/CompanyHoliday.tsx +++ b/src/components/CompanyHoliday/CompanyHoliday.tsx @@ -1,32 +1,46 @@ "use client"; -import { HolidaysResult } from "@/app/api/holidays"; +import { HolidaysList, HolidaysResult } from "@/app/api/holidays"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Grid, Stack } from '@mui/material/'; import { useTranslation } from "react-i18next"; import FullCalendar from '@fullcalendar/react' import dayGridPlugin from '@fullcalendar/daygrid' // a plugin! import interactionPlugin from "@fullcalendar/interaction" // needed for dayClick +import listPlugin from '@fullcalendar/list'; import Holidays from "date-holidays"; import CompanyHolidayDialog from "./CompanyHolidayDialog"; -import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; +import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm, useFormContext } from "react-hook-form"; import { EventBusy } from "@mui/icons-material"; +import { deleteCompanyHoliday, saveCompanyHoliday } from "@/app/api/holidays/actions"; +import { useRouter } from "next/navigation"; +import { deleteDialog, submitDialog } from "../Swal/CustomAlerts"; + interface Props { - holidays: HolidaysResult[]; + holidays: HolidaysList[]; } const CompanyHoliday: React.FC = ({ holidays }) => { const { t } = useTranslation("holidays"); + const router = useRouter(); + const formValues = useFormContext(); + const [serverError, setServerError] = useState(""); const hd = new Holidays('HK') console.log(holidays) - const [companyHolidays, setCompanyHolidays] = useState([]) + const [companyHolidays, setCompanyHolidays] = useState([]) const [dateContent, setDateContent] = useState<{ date: string }>({date: ''}) const [open, setOpen] = useState(false); + const [isEdit, setIsEdit] = useState(false); + const [editable, setEditable] = useState(true); const handleClose = () => { setOpen(false); + setEditable(true) + setIsEdit(false) + formProps.setValue("name", "") + formProps.setValue("id", null) }; const getPublicHolidaysList = () => { @@ -92,34 +106,69 @@ const CompanyHoliday: React.FC = ({ holidays }) => { return {date: `${tempYear}-${tempMonth}-${tempDate}`, title: tempName, extendedProps: {calendar: 'holiday'}} }) - setCompanyHolidays([...events_cyhd, ...events_nyhd] as HolidaysResult[]) + setCompanyHolidays([...events_cyhd, ...events_nyhd, ...holidays] as HolidaysList[]) } useEffect(()=>{ getPublicHolidaysList() },[]) + useEffect(()=>{ + + },[holidays]) + const handleDateClick = (event:any) => { - console.log(event.dateStr) + // console.log(event.dateStr) setDateContent({date: event.dateStr}) setOpen(true); } const handleEventClick = (event:any) => { - console.log(event) + // event.event.id: if id !== "", holiday is created by company + console.log(event.event.id) + if (event.event.id === null || event.event.id === ""){ + setEditable(false) + } + formProps.setValue("name", event.event.title) + formProps.setValue("id", event.event.id) + setDateContent({date: event.event.startStr}) + setOpen(true); + setIsEdit(true); } const onSubmit = useCallback>( async (data) => { try { - console.log(data); - // console.log(JSON.stringify(data)); + // console.log(data); + setServerError(""); + submitDialog(async () => { + await saveCompanyHoliday(data) + window.location.reload() + setOpen(false); + setIsEdit(false); + }, t, {}) } catch (e) { console.log(e); + setServerError(t("An error has occurred. Please try again later.")); } }, - [t, ], + [t, router], ); + + const handleDelete = async (event:any) => { + try { + setServerError(""); + deleteDialog(async () => { + await deleteCompanyHoliday(parseInt(formProps.getValues("id"))) + window.location.reload() + setOpen(false); + setIsEdit(false); + }, t); + } catch (e) { + console.log(e); + setServerError(t("An error has occurred. Please try again later.")); + } + } const onSubmitError = useCallback>( (errors) => { @@ -131,7 +180,8 @@ const CompanyHoliday: React.FC = ({ holidays }) => { const formProps = useForm({ defaultValues: { - title: "" + id: null, + name: "" }, }); @@ -139,24 +189,34 @@ const CompanyHoliday: React.FC = ({ holidays }) => { <> - + {isEdit && } + } + editable={editable} /> diff --git a/src/components/CompanyHoliday/CompanyHolidayDialog.tsx b/src/components/CompanyHoliday/CompanyHolidayDialog.tsx index 3b9419f..78b3d67 100644 --- a/src/components/CompanyHoliday/CompanyHolidayDialog.tsx +++ b/src/components/CompanyHoliday/CompanyHolidayDialog.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Grid, FormControl } from '@mui/material/'; import { useForm, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -12,14 +12,15 @@ interface CompanyHolidayDialogProps { onClose: () => void; title: string; actions: React.ReactNode; - content: Content + content: Content; + editable: Boolean; } interface Content { date: string } -const CompanyHolidayDialog: React.FC = ({ open, onClose, title, actions, content }) => { +const CompanyHolidayDialog: React.FC = ({ open, onClose, title, actions, content, editable }) => { const { t, i18n: { language }, @@ -29,9 +30,14 @@ const CompanyHolidayDialog: React.FC = ({ open, onClo register, formState: { errors }, setValue, - getValues, } = useFormContext(); + useEffect(() => { + setValue("date", content.date); + }, [content]) + + console.log(editable) + return ( = ({ open, onClo { if (!date) return; - setValue("dueDate", date.format(INPUT_DATE_FORMAT)); + setValue("date", date.format(INPUT_DATE_FORMAT)); }} slotProps={{ textField: { diff --git a/src/components/CompanyHoliday/CompanyHolidayWrapper.tsx b/src/components/CompanyHoliday/CompanyHolidayWrapper.tsx index 0c21148..23afb2c 100644 --- a/src/components/CompanyHoliday/CompanyHolidayWrapper.tsx +++ b/src/components/CompanyHoliday/CompanyHolidayWrapper.tsx @@ -4,7 +4,8 @@ import CompanyHoliday from "./CompanyHoliday"; import CompanyHolidayLoading from "./CompanyHolidayLoading"; import { fetchCompanys } from "@/app/api/companys"; import Holidays from "date-holidays"; -import { HolidaysResult, fetchHolidays } from "@/app/api/holidays"; +import { HolidaysResult, fetchHolidays, HolidaysList } from "@/app/api/holidays"; +import { convertDateArrayToString } from "@/app/utils/formatUtil"; interface SubComponents { Loading: typeof CompanyHolidayLoading; @@ -14,9 +15,18 @@ const CompanyHolidayWrapper: React.FC & SubComponents = async () => { // const Companys = await fetchCompanys(); const companyHolidays: HolidaysResult[] = await fetchHolidays() + +// console.log(companyHolidays) + const convertedHolidays = companyHolidays.map((holiday) => { + return { + id: holiday.id.toString(), + title: holiday.name, + date: convertDateArrayToString(holiday.date, "YYYY-MM-DD", false) + } + }) - return ; + return ; }; CompanyHolidayWrapper.Loading = CompanyHolidayLoading; diff --git a/src/components/Swal/CustomAlerts.js b/src/components/Swal/CustomAlerts.js index 668502c..ad1aec6 100644 --- a/src/components/Swal/CustomAlerts.js +++ b/src/components/Swal/CustomAlerts.js @@ -1,6 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import Swal from "sweetalert2"; +import "./sweetalert2.css" export const msg = (text) => { Swal.mixin({ @@ -59,6 +60,10 @@ export const submitDialog = async (confirmAction, t, {...props}) => { confirmButtonText: props.confirmButtonText ?? t("Submit"), showCancelButton: true, showConfirmButton: true, + customClass: { + container: "swal-container-class", // Add a custom class to the Swal.fire container element + popup: "swal-popup-class", // Add a custom class to the Swal.fire popup element + }, }); if (result.isConfirmed) { confirmAction(); @@ -74,6 +79,10 @@ export const deleteDialog = async (confirmAction, t) => { confirmButtonText: t("Delete"), showCancelButton: true, showConfirmButton: true, + customClass: { + container: "swal-container-class", // Add a custom class to the Swal.fire container element + popup: "swal-popup-class", // Add a custom class to the Swal.fire popup element + }, }); if (result.isConfirmed) { confirmAction(); diff --git a/src/components/Swal/sweetalert2.css b/src/components/Swal/sweetalert2.css new file mode 100644 index 0000000..4825796 --- /dev/null +++ b/src/components/Swal/sweetalert2.css @@ -0,0 +1,7 @@ +.swal-container-class { + z-index: 9999; /* Adjust the z-index value as needed */ + } + +.swal-popup-class { +z-index: 10000; /* Adjust the z-index value as needed */ +} \ No newline at end of file From 3aeafee40217aca5bb6ada5543656cb5d13ad7b1 Mon Sep 17 00:00:00 2001 From: Wayne Date: Mon, 6 May 2024 18:41:05 +0900 Subject: [PATCH 17/37] Add leave input --- src/app/(main)/home/page.tsx | 12 +- src/app/api/projects/actions.ts | 4 +- src/app/api/projects/index.ts | 4 +- src/app/api/timesheets/actions.ts | 25 ++ src/app/api/timesheets/index.ts | 22 +- src/components/LeaveModal/LeaveModal.tsx | 127 ++++++++ src/components/LeaveModal/index.ts | 1 + src/components/LeaveTable/LeaveEntryTable.tsx | 283 ++++++++++++++++++ src/components/LeaveTable/LeaveTable.tsx | 133 ++++++++ src/components/LeaveTable/index.ts | 1 + .../TimesheetModal/TimesheetModal.tsx | 4 +- .../UserWorkspacePage/UserWorkspacePage.tsx | 73 +++-- .../UserWorkspaceWrapper.tsx | 14 +- 13 files changed, 653 insertions(+), 50 deletions(-) create mode 100644 src/components/LeaveModal/LeaveModal.tsx create mode 100644 src/components/LeaveModal/index.ts create mode 100644 src/components/LeaveTable/LeaveEntryTable.tsx create mode 100644 src/components/LeaveTable/LeaveTable.tsx create mode 100644 src/components/LeaveTable/index.ts diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx index 155388f..2766580 100644 --- a/src/app/(main)/home/page.tsx +++ b/src/app/(main)/home/page.tsx @@ -1,9 +1,14 @@ import { Metadata } from "next"; import { I18nProvider } from "@/i18n"; import UserWorkspacePage from "@/components/UserWorkspacePage"; -import { fetchTimesheets } from "@/app/api/timesheets"; +import { + fetchLeaveTypes, + fetchLeaves, + fetchTimesheets, +} from "@/app/api/timesheets"; import { authOptions } from "@/config/authConfig"; import { getServerSession } from "next-auth"; +import { fetchAssignedProjects } from "@/app/api/projects"; export const metadata: Metadata = { title: "User Workspace", @@ -14,7 +19,10 @@ const Home: React.FC = async () => { // Get name for caching const username = session!.user!.name!; - await fetchTimesheets(username); + fetchTimesheets(username); + fetchAssignedProjects(username); + fetchLeaves(username); + fetchLeaveTypes(); return ( diff --git a/src/app/api/projects/actions.ts b/src/app/api/projects/actions.ts index 6bb5596..c80dff0 100644 --- a/src/app/api/projects/actions.ts +++ b/src/app/api/projects/actions.ts @@ -7,7 +7,7 @@ import { import { BASE_API_URL } from "@/config/api"; import { Task, TaskGroup } from "../tasks"; import { Customer } from "../customer"; -import { revalidateTag } from "next/cache"; +import { revalidatePath, revalidateTag } from "next/cache"; export interface CreateProjectInputs { // Project @@ -101,6 +101,6 @@ export const deleteProject = async (id: number) => { ); revalidateTag("projects"); - revalidateTag("assignedProjects"); + revalidatePath("/(main)/home"); return project; }; diff --git a/src/app/api/projects/index.ts b/src/app/api/projects/index.ts index 90b0e10..9cc4f01 100644 --- a/src/app/api/projects/index.ts +++ b/src/app/api/projects/index.ts @@ -138,11 +138,11 @@ export const fetchProjectWorkNatures = cache(async () => { }); }); -export const fetchAssignedProjects = cache(async () => { +export const fetchAssignedProjects = cache(async (username: string) => { return serverFetchJson( `${BASE_API_URL}/projects/assignedProjects`, { - next: { tags: ["assignedProjects"] }, + next: { tags: [`assignedProjects__${username}`] }, }, ); }); diff --git a/src/app/api/timesheets/actions.ts b/src/app/api/timesheets/actions.ts index 631c076..97b03a9 100644 --- a/src/app/api/timesheets/actions.ts +++ b/src/app/api/timesheets/actions.ts @@ -18,6 +18,16 @@ export interface RecordTimesheetInput { [date: string]: TimeEntry[]; } +export interface LeaveEntry { + id: number; + inputHours: number; + leaveTypeId: number; +} + +export interface RecordLeaveInput { + [date: string]: LeaveEntry[]; +} + export const saveTimesheet = async ( data: RecordTimesheetInput, username: string, @@ -35,3 +45,18 @@ export const saveTimesheet = async ( return savedRecords; }; + +export const saveLeave = async (data: RecordLeaveInput, username: string) => { + const savedRecords = await serverFetchJson( + `${BASE_API_URL}/timesheets/saveLeave`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); + + revalidateTag(`leaves_${username}`); + + return savedRecords; +}; diff --git a/src/app/api/timesheets/index.ts b/src/app/api/timesheets/index.ts index fd7d20d..d9b1862 100644 --- a/src/app/api/timesheets/index.ts +++ b/src/app/api/timesheets/index.ts @@ -1,10 +1,30 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; import { cache } from "react"; -import { RecordTimesheetInput } from "./actions"; +import { RecordLeaveInput, RecordTimesheetInput } from "./actions"; + +export interface LeaveType { + id: number; + name: string; +} export const fetchTimesheets = cache(async (username: string) => { return serverFetchJson(`${BASE_API_URL}/timesheets`, { next: { tags: [`timesheets_${username}`] }, }); }); + +export const fetchLeaves = cache(async (username: string) => { + return serverFetchJson( + `${BASE_API_URL}/timesheets/leaves`, + { + next: { tags: [`leaves_${username}`] }, + }, + ); +}); + +export const fetchLeaveTypes = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/timesheets/leaveTypes`, { + next: { tags: ["leaveTypes"] }, + }); +}); diff --git a/src/components/LeaveModal/LeaveModal.tsx b/src/components/LeaveModal/LeaveModal.tsx new file mode 100644 index 0000000..b163003 --- /dev/null +++ b/src/components/LeaveModal/LeaveModal.tsx @@ -0,0 +1,127 @@ +import React, { useCallback, useMemo } from "react"; +import { + Box, + Button, + Card, + CardActions, + CardContent, + Modal, + SxProps, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { Check, Close } from "@mui/icons-material"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { RecordLeaveInput, saveLeave } from "@/app/api/timesheets/actions"; +import dayjs from "dayjs"; +import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; +import LeaveTable from "../LeaveTable"; +import { LeaveType } from "@/app/api/timesheets"; + +interface Props { + isOpen: boolean; + onClose: () => void; + username: string; + defaultLeaveRecords?: RecordLeaveInput; + leaveTypes: LeaveType[]; +} + +const modalSx: SxProps = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: { xs: "calc(100% - 2rem)", sm: "90%" }, + maxHeight: "90%", + maxWidth: 1200, +}; + +const LeaveModal: React.FC = ({ + isOpen, + onClose, + username, + defaultLeaveRecords, + leaveTypes, +}) => { + const { t } = useTranslation("home"); + + const defaultValues = useMemo(() => { + const today = dayjs(); + return Array(7) + .fill(undefined) + .reduce((acc, _, index) => { + const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); + return { + ...acc, + [date]: defaultLeaveRecords?.[date] ?? [], + }; + }, {}); + }, [defaultLeaveRecords]); + + const formProps = useForm({ defaultValues }); + + const onSubmit = useCallback>( + async (data) => { + const savedRecords = await saveLeave(data, username); + + const today = dayjs(); + const newFormValues = Array(7) + .fill(undefined) + .reduce((acc, _, index) => { + const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); + return { + ...acc, + [date]: savedRecords[date] ?? [], + }; + }, {}); + + formProps.reset(newFormValues); + onClose(); + }, + [formProps, onClose, username], + ); + + const onCancel = useCallback(() => { + formProps.reset(defaultValues); + onClose(); + }, [defaultValues, formProps, onClose]); + + return ( + + + + + + {t("Record Leave")} + + + + + + + + + + + + + ); +}; + +export default LeaveModal; diff --git a/src/components/LeaveModal/index.ts b/src/components/LeaveModal/index.ts new file mode 100644 index 0000000..cd099c7 --- /dev/null +++ b/src/components/LeaveModal/index.ts @@ -0,0 +1 @@ +export { default } from "./LeaveModal"; diff --git a/src/components/LeaveTable/LeaveEntryTable.tsx b/src/components/LeaveTable/LeaveEntryTable.tsx new file mode 100644 index 0000000..9e9170d --- /dev/null +++ b/src/components/LeaveTable/LeaveEntryTable.tsx @@ -0,0 +1,283 @@ +import { Add, Check, Close, Delete } from "@mui/icons-material"; +import { Box, Button, Typography } from "@mui/material"; +import { + FooterPropsOverrides, + GridActionsCellItem, + GridColDef, + GridEventListener, + GridRowId, + GridRowModel, + GridRowModes, + GridRowModesModel, + GridToolbarContainer, + useGridApiRef, +} from "@mui/x-data-grid"; +import { useTranslation } from "react-i18next"; +import StyledDataGrid from "../StyledDataGrid"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { RecordLeaveInput, LeaveEntry } from "@/app/api/timesheets/actions"; +import { manhourFormatter } from "@/app/utils/formatUtil"; +import dayjs from "dayjs"; +import isBetween from "dayjs/plugin/isBetween"; +import { LeaveType } from "@/app/api/timesheets"; + +dayjs.extend(isBetween); + +interface Props { + day: string; + leaveTypes: LeaveType[]; +} + +type LeaveEntryRow = Partial< + LeaveEntry & { + _isNew: boolean; + _error: string; + } +>; + +const EntryInputTable: React.FC = ({ day, leaveTypes }) => { + const { t } = useTranslation("home"); + + const { getValues, setValue } = useFormContext(); + const currentEntries = getValues(day); + + const [entries, setEntries] = useState(currentEntries || []); + + const [rowModesModel, setRowModesModel] = useState({}); + + const apiRef = useGridApiRef(); + const addRow = useCallback(() => { + const id = Date.now(); + setEntries((e) => [...e, { id, _isNew: true }]); + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.Edit, fieldToFocus: "leaveTypeId" }, + })); + }, []); + + const validateRow = useCallback( + (id: GridRowId) => { + const row = apiRef.current.getRowWithUpdatedValues( + id, + "", + ) as LeaveEntryRow; + + // Test for errrors + let error: keyof LeaveEntry | "" = ""; + if (!row.leaveTypeId) { + error = "leaveTypeId"; + } else if (!row.inputHours || !(row.inputHours >= 0)) { + error = "inputHours"; + } + + apiRef.current.updateRows([{ id, _error: error }]); + return !error; + }, + [apiRef], + ); + + const handleCancel = useCallback( + (id: GridRowId) => () => { + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.View, ignoreModifications: true }, + })); + const editedRow = entries.find((entry) => entry.id === id); + if (editedRow?._isNew) { + setEntries((es) => es.filter((e) => e.id !== id)); + } + }, + [entries], + ); + + const handleDelete = useCallback( + (id: GridRowId) => () => { + setEntries((es) => es.filter((e) => e.id !== id)); + }, + [], + ); + + const handleSave = useCallback( + (id: GridRowId) => () => { + if (validateRow(id)) { + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.View }, + })); + } + }, + [validateRow], + ); + + const handleEditStop = useCallback>( + (params, event) => { + if (!validateRow(params.id)) { + event.defaultMuiPrevented = true; + } + }, + [validateRow], + ); + + const processRowUpdate = useCallback((newRow: GridRowModel) => { + const updatedRow = { ...newRow, _isNew: false }; + setEntries((es) => es.map((e) => (e.id === newRow.id ? updatedRow : e))); + return updatedRow; + }, []); + + const columns = useMemo( + () => [ + { + type: "actions", + field: "actions", + headerName: t("Actions"), + getActions: ({ id }) => { + if (rowModesModel[id]?.mode === GridRowModes.Edit) { + return [ + } + label={t("Save")} + onClick={handleSave(id)} + />, + } + label={t("Cancel")} + onClick={handleCancel(id)} + />, + ]; + } + + return [ + } + label={t("Remove")} + onClick={handleDelete(id)} + />, + ]; + }, + }, + { + field: "leaveTypeId", + headerName: t("Leave Type"), + width: 200, + editable: true, + type: "singleSelect", + valueOptions() { + return leaveTypes.map((p) => ({ value: p.id, label: p.name })); + }, + valueGetter({ value }) { + return value ?? ""; + }, + }, + { + field: "inputHours", + headerName: t("Hours"), + width: 100, + editable: true, + type: "number", + valueFormatter(params) { + return manhourFormatter.format(params.value); + }, + }, + ], + [t, rowModesModel, handleDelete, handleSave, handleCancel, leaveTypes], + ); + + useEffect(() => { + setValue(day, [ + ...entries + .filter( + (e) => + !e._isNew && !e._error && e.inputHours && e.leaveTypeId && e.id, + ) + .map((e) => ({ + id: e.id!, + inputHours: e.inputHours!, + leaveTypeId: e.leaveTypeId!, + })), + ]); + }, [getValues, entries, setValue, day]); + + const footer = ( + + + + ); + + return ( + { + let classname = ""; + if (params.row._error === params.field) { + classname = "hasError"; + } else if ( + params.field === "taskGroupId" && + params.row.isPlanned !== undefined && + !params.row.isPlanned + ) { + classname = "hasWarning"; + } + return classname; + }} + slots={{ + footer: FooterToolbar, + noRowsOverlay: NoRowsOverlay, + }} + slotProps={{ + footer: { child: footer }, + }} + /> + ); +}; + +const NoRowsOverlay: React.FC = () => { + const { t } = useTranslation("home"); + return ( + + {t("Add some leave entries!")} + + ); +}; + +const FooterToolbar: React.FC = ({ child }) => { + return {child}; +}; + +export default EntryInputTable; diff --git a/src/components/LeaveTable/LeaveTable.tsx b/src/components/LeaveTable/LeaveTable.tsx new file mode 100644 index 0000000..5d0a003 --- /dev/null +++ b/src/components/LeaveTable/LeaveTable.tsx @@ -0,0 +1,133 @@ +import { RecordLeaveInput, LeaveEntry } from "@/app/api/timesheets/actions"; +import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; +import { KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; +import { + Box, + Collapse, + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import dayjs from "dayjs"; +import React, { useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import LeaveEntryTable from "./LeaveEntryTable"; +import { LeaveType } from "@/app/api/timesheets"; + +interface Props { + leaveTypes: LeaveType[]; +} + +const MAX_HOURS = 8; + +const LeaveTable: React.FC = ({ leaveTypes }) => { + const { t } = useTranslation("home"); + + const { watch } = useFormContext(); + const currentInput = watch(); + const days = Object.keys(currentInput); + + return ( + + + + + + {t("Date")} + {t("Daily Total Hours")} + + + + {days.map((day, index) => { + const entries = currentInput[day]; + return ( + + ); + })} + +
+
+ ); +}; + +const DayRow: React.FC<{ + day: string; + entries: LeaveEntry[]; + leaveTypes: LeaveType[]; +}> = ({ day, entries, leaveTypes }) => { + const { + t, + i18n: { language }, + } = useTranslation("home"); + const dayJsObj = dayjs(day); + const [open, setOpen] = useState(false); + + const totalHours = entries.reduce((acc, entry) => acc + entry.inputHours, 0); + + return ( + <> + + + setOpen(!open)} + > + {open ? : } + + + + {shortDateFormatter(language).format(dayJsObj.toDate())} + + MAX_HOURS ? "error.main" : undefined }} + > + {manhourFormatter.format(totalHours)} + {totalHours > MAX_HOURS && ( + + {t("(the daily total hours cannot be more than 8.)")} + + )} + + + + + + + + + + + + + ); +}; + +export default LeaveTable; diff --git a/src/components/LeaveTable/index.ts b/src/components/LeaveTable/index.ts new file mode 100644 index 0000000..9aeb679 --- /dev/null +++ b/src/components/LeaveTable/index.ts @@ -0,0 +1 @@ +export { default } from "./LeaveTable"; diff --git a/src/components/TimesheetModal/TimesheetModal.tsx b/src/components/TimesheetModal/TimesheetModal.tsx index c336cef..e8e5061 100644 --- a/src/components/TimesheetModal/TimesheetModal.tsx +++ b/src/components/TimesheetModal/TimesheetModal.tsx @@ -24,7 +24,6 @@ import { AssignedProject } from "@/app/api/projects"; interface Props { isOpen: boolean; onClose: () => void; - timesheetType: "time" | "leave"; assignedProjects: AssignedProject[]; username: string; defaultTimesheets?: RecordTimesheetInput; @@ -43,7 +42,6 @@ const modalSx: SxProps = { const TimesheetModal: React.FC = ({ isOpen, onClose, - timesheetType, assignedProjects, username, defaultTimesheets, @@ -100,7 +98,7 @@ const TimesheetModal: React.FC = ({ onSubmit={formProps.handleSubmit(onSubmit)} > - {t(timesheetType === "time" ? "Timesheet Input" : "Record Leave")} + {t("Timesheet Input")} = ({ + leaveTypes, assignedProjects, username, + defaultLeaveRecords, defaultTimesheets, }) => { const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false); @@ -60,46 +69,36 @@ const UserWorkspacePage: React.FC = ({ flexWrap="wrap" spacing={2} > - {Boolean(assignedProjects.length) && ( - - - - - )} + + + + + + {assignedProjects.length > 0 ? ( - <> - - - - + ) : ( - <> - - {t("You have no assigned projects!")} - - + + {t("You have no assigned projects!")} + )} ); diff --git a/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx b/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx index cd5fe66..529519e 100644 --- a/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx +++ b/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx @@ -1,15 +1,21 @@ import { fetchAssignedProjects } from "@/app/api/projects"; import UserWorkspacePage from "./UserWorkspacePage"; -import { fetchTimesheets } from "@/app/api/timesheets"; +import { + fetchLeaveTypes, + fetchLeaves, + fetchTimesheets, +} from "@/app/api/timesheets"; interface Props { username: string; } const UserWorkspaceWrapper: React.FC = async ({ username }) => { - const [assignedProjects, timesheets] = await Promise.all([ - fetchAssignedProjects(), + const [assignedProjects, timesheets, leaves, leaveTypes] = await Promise.all([ + fetchAssignedProjects(username), fetchTimesheets(username), + fetchLeaves(username), + fetchLeaveTypes(), ]); return ( @@ -17,6 +23,8 @@ const UserWorkspaceWrapper: React.FC = async ({ username }) => { assignedProjects={assignedProjects} username={username} defaultTimesheets={timesheets} + defaultLeaveRecords={leaves} + leaveTypes={leaveTypes} /> ); }; From c217d880a3a352e67201f0f1a1b8716a198f1150 Mon Sep 17 00:00:00 2001 From: "MSI\\derek" Date: Mon, 6 May 2024 18:17:24 +0800 Subject: [PATCH 18/37] update --- src/app/(main)/settings/group/create/page.tsx | 22 ++ src/app/(main)/settings/group/edit/page.tsx | 0 src/app/(main)/settings/group/page.tsx | 55 +++++ src/app/(main)/settings/team/create/page.tsx | 4 - src/app/(main)/settings/user/edit/page.tsx | 24 ++ src/app/(main)/settings/user/page.tsx | 4 +- src/app/api/group/actions.ts | 44 ++++ src/app/api/group/index.ts | 21 ++ src/app/api/user/actions.ts | 11 +- src/app/api/user/index.ts | 1 + .../CreateGroup/AuthorityAllocation.tsx | 211 ++++++++++++++++++ src/components/CreateGroup/CreateGroup.tsx | 130 +++++++++++ .../CreateGroup/CreateGroupLoading.tsx | 40 ++++ .../CreateGroup/CreateGroupWrapper.tsx | 24 ++ src/components/CreateGroup/GroupInfo.tsx | 81 +++++++ src/components/CreateGroup/UserAllocation.tsx | 209 +++++++++++++++++ src/components/CreateGroup/index.ts | 1 + src/components/CreateTeam/TeamInfo.tsx | 2 +- src/components/EditTeam/Allocation.tsx | 3 +- .../NavigationContent/NavigationContent.tsx | 1 + .../UserGroupSearch/UserGroupSearch.tsx | 94 ++++++++ .../UserGroupSearchLoading.tsx | 40 ++++ .../UserGroupSearchWrapper.tsx | 19 ++ src/components/UserGroupSearch/index.ts | 1 + 24 files changed, 1031 insertions(+), 11 deletions(-) create mode 100644 src/app/(main)/settings/group/create/page.tsx create mode 100644 src/app/(main)/settings/group/edit/page.tsx create mode 100644 src/app/(main)/settings/group/page.tsx create mode 100644 src/app/(main)/settings/user/edit/page.tsx create mode 100644 src/app/api/group/actions.ts create mode 100644 src/app/api/group/index.ts create mode 100644 src/components/CreateGroup/AuthorityAllocation.tsx create mode 100644 src/components/CreateGroup/CreateGroup.tsx create mode 100644 src/components/CreateGroup/CreateGroupLoading.tsx create mode 100644 src/components/CreateGroup/CreateGroupWrapper.tsx create mode 100644 src/components/CreateGroup/GroupInfo.tsx create mode 100644 src/components/CreateGroup/UserAllocation.tsx create mode 100644 src/components/CreateGroup/index.ts create mode 100644 src/components/UserGroupSearch/UserGroupSearch.tsx create mode 100644 src/components/UserGroupSearch/UserGroupSearchLoading.tsx create mode 100644 src/components/UserGroupSearch/UserGroupSearchWrapper.tsx create mode 100644 src/components/UserGroupSearch/index.ts diff --git a/src/app/(main)/settings/group/create/page.tsx b/src/app/(main)/settings/group/create/page.tsx new file mode 100644 index 0000000..9130236 --- /dev/null +++ b/src/app/(main)/settings/group/create/page.tsx @@ -0,0 +1,22 @@ +// 'use client'; +import { I18nProvider, getServerI18n } from "@/i18n"; +import React, { useCallback, useState } from "react"; +import { Typography } from "@mui/material"; +import CreateGroup from "@/components/CreateGroup"; + +// const Title = ["title1", "title2"]; + +const CreateStaff: React.FC = async () => { + const { t } = await getServerI18n("group"); + + return ( + <> + {t("Create Group")} + + + + + ); +}; + +export default CreateStaff; diff --git a/src/app/(main)/settings/group/edit/page.tsx b/src/app/(main)/settings/group/edit/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/app/(main)/settings/group/page.tsx b/src/app/(main)/settings/group/page.tsx new file mode 100644 index 0000000..5322132 --- /dev/null +++ b/src/app/(main)/settings/group/page.tsx @@ -0,0 +1,55 @@ +import { preloadClaims } from "@/app/api/claims"; +import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; +import StaffSearch from "@/components/StaffSearch"; +import TeamSearch from "@/components/TeamSearch"; +import UserGroupSearch from "@/components/UserGroupSearch"; +import UserSearch from "@/components/UserSearch"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import Add from "@mui/icons-material/Add"; +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 { Suspense } from "react"; + + +export const metadata: Metadata = { + title: "User Group", + }; + + + const UserGroup: React.FC = async () => { + const { t } = await getServerI18n("User Group"); + // preloadTeamLeads(); + // preloadStaff(); + return ( + <> + + + {t("User Group")} + + + + + }> + + + + + ); + }; + + export default UserGroup; \ No newline at end of file diff --git a/src/app/(main)/settings/team/create/page.tsx b/src/app/(main)/settings/team/create/page.tsx index 721fda7..a47d81c 100644 --- a/src/app/(main)/settings/team/create/page.tsx +++ b/src/app/(main)/settings/team/create/page.tsx @@ -28,10 +28,6 @@ import CreateTeam from "@/components/CreateTeam"; const CreateTeamPage: React.FC = async () => { const { t } = await getServerI18n("team"); - const title = ['', t('Additional Info')] - // const regex = new RegExp("^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$") - // console.log(regex) - return ( <> {t("Create Team")} diff --git a/src/app/(main)/settings/user/edit/page.tsx b/src/app/(main)/settings/user/edit/page.tsx new file mode 100644 index 0000000..659347b --- /dev/null +++ b/src/app/(main)/settings/user/edit/page.tsx @@ -0,0 +1,24 @@ +import { Edit } from "@mui/icons-material"; +import { useSearchParams } from "next/navigation"; +// import EditStaff from "@/components/EditStaff"; +import { Suspense } from "react"; +import { I18nProvider } from "@/i18n"; +// import EditStaffWrapper from "@/components/EditStaff/EditStaffWrapper"; +import { Metadata } from "next"; +import EditUser from "@/components/EditUser"; + + +const EditUserPage: React.FC = () => { + + return ( + <> + + }> + + + + + ); +}; + +export default EditUserPage; diff --git a/src/app/(main)/settings/user/page.tsx b/src/app/(main)/settings/user/page.tsx index 95973ab..ef7635f 100644 --- a/src/app/(main)/settings/user/page.tsx +++ b/src/app/(main)/settings/user/page.tsx @@ -33,14 +33,14 @@ export const metadata: Metadata = { {t("User")} - + */} }> diff --git a/src/app/api/group/actions.ts b/src/app/api/group/actions.ts new file mode 100644 index 0000000..c8881de --- /dev/null +++ b/src/app/api/group/actions.ts @@ -0,0 +1,44 @@ +"use server"; + +import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { revalidateTag } from "next/cache"; +import { cache } from "react"; + + +export interface CreateGroupInputs { + id?: number; + name: string; + description: string; + addUserIds?: number[]; + removeUserIds?: number[]; + addAuthIds?: number[]; + removeAuthIds?: number[]; + } + +export interface auth { + id: number; + module?: any | null; + authority: string; + name: string; + description: string | null; + v: number; + } + +export interface record { + records: auth[]; + } + + export const fetchAuth = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/group/auth/combo`, { + next: { tags: ["auth"] }, + }); + }); + +export const saveGroup = async (data: CreateGroupInputs) => { + return serverFetchJson(`${BASE_API_URL}/group/save`, { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); + }; \ No newline at end of file diff --git a/src/app/api/group/index.ts b/src/app/api/group/index.ts new file mode 100644 index 0000000..9dcee9e --- /dev/null +++ b/src/app/api/group/index.ts @@ -0,0 +1,21 @@ +import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { cache } from "react"; +import "server-only"; + +export interface Records { + records: UserGroupResult[] +} + +export interface UserGroupResult { + id: number; + action: () => void; + name: string; + description: string; +} + +export const fetchGroup = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/group`, { + next: { tags: ["group"] }, + }); + }); diff --git a/src/app/api/user/actions.ts b/src/app/api/user/actions.ts index 5df734a..4d353c3 100644 --- a/src/app/api/user/actions.ts +++ b/src/app/api/user/actions.ts @@ -8,8 +8,7 @@ import { cache } from "react"; export interface UserInputs { username: string; - firstname: string; - lastname: string; + email: string; } @@ -19,6 +18,14 @@ export const fetchUserDetails = cache(async (id: number) => { }); }); +export const editUser = async (id: number, data: UserInputs) => { + return serverFetchWithNoContent(`${BASE_API_URL}/user/${id}`, { + method: "PUT", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); + }; + export const deleteUser = async (id: number) => { return serverFetchWithNoContent(`${BASE_API_URL}/user/${id}`, { method: "DELETE", diff --git a/src/app/api/user/index.ts b/src/app/api/user/index.ts index 9a6065b..3151b64 100644 --- a/src/app/api/user/index.ts +++ b/src/app/api/user/index.ts @@ -19,6 +19,7 @@ export interface UserResult { phone1: string; phone2: string; remarks: string; + groupId: number; } // export interface DetailedUser extends UserResult { diff --git a/src/components/CreateGroup/AuthorityAllocation.tsx b/src/components/CreateGroup/AuthorityAllocation.tsx new file mode 100644 index 0000000..fd9610b --- /dev/null +++ b/src/components/CreateGroup/AuthorityAllocation.tsx @@ -0,0 +1,211 @@ +"use client"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + FieldErrors, + FormProvider, + SubmitErrorHandler, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { + Box, + Card, + CardContent, + Grid, + IconButton, + InputAdornment, + Stack, + Tab, + Tabs, + TabsProps, + TextField, + Typography, +} from "@mui/material"; +import { differenceBy } from "lodash"; +import { CreateGroupInputs, auth } from "@/app/api/group/actions"; +import SearchResults, { Column } from "../SearchResults"; +import { Add, Clear, Remove, Search } from "@mui/icons-material"; + +export interface Props { + auth: auth[]; +} + +const AuthorityAllocation: React.FC = ({ auth }) => { + const { t } = useTranslation(); + const { + setValue, + getValues, + formState: { defaultValues }, + reset, + resetField, + } = useFormContext(); + const initialAuths = auth.map((a) => ({ ...a })).sort((a, b) => a.id - b.id); + const [filteredAuths, setFilteredAuths] = useState(initialAuths); + const [selectedAuths, setSelectedAuths] = useState( + () => { + return filteredAuths.filter( + (s) => getValues("addAuthIds")?.includes(s.id) + ); + } + ); + // Adding / Removing Auth + const addAuth = useCallback((auth: auth) => { + setSelectedAuths((a) => [...a, auth]); + }, []); + const removeAuth = useCallback((auth: auth) => { + setSelectedAuths((a) => a.filter((a) => a.id !== auth.id)); + }, []); + + const clearAuth = useCallback(() => { + if (defaultValues !== undefined) { + resetField("addAuthIds"); + setSelectedAuths( + initialAuths.filter((s) => defaultValues.addAuthIds?.includes(s.id)) + ); + } + }, [defaultValues]); + + // Sync with form + useEffect(() => { + setValue( + "addAuthIds", + selectedAuths.map((a) => a.id) + ); + }, [selectedAuths, setValue]); + + const AuthPoolColumns = useMemo[]>( + () => [ + { + label: t("Add"), + name: "id", + onClick: addAuth, + buttonIcon: , + }, + { label: t("authority"), name: "authority" }, + { label: t("Auth Name"), name: "name" }, + // { label: t("Current Position"), name: "currentPosition" }, + ], + [addAuth, t] + ); + + const allocatedAuthColumns = useMemo[]>( + () => [ + { + label: t("Remove"), + name: "id", + onClick: removeAuth, + buttonIcon: , + }, + { label: t("authority"), name: "authority" }, + { label: t("Auth Name"), name: "name" }, + ], + [removeAuth, selectedAuths, t] + ); + const [query, setQuery] = React.useState(""); + const onQueryInputChange = React.useCallback< + React.ChangeEventHandler + >((e) => { + setQuery(e.target.value); + }, []); + const clearQueryInput = React.useCallback(() => { + setQuery(""); + }, []); + + React.useEffect(() => { + // setFilteredStaff( + // initialStaffs.filter((s) => { + // const q = query.toLowerCase(); + // // s.staffId.toLowerCase().includes(q) + // // const q = query.toLowerCase(); + // // return s.name.toLowerCase().includes(q); + // // s.code.toString().includes(q) || + // // (s.brNo != null && s.brNo.toLowerCase().includes(q)) + // }) + // ); + }, [auth, query]); + + useEffect(() => { + // console.log(getValues("addStaffIds")) + }, [initialAuths]); + + const resetAuth = React.useCallback(() => { + clearQueryInput(); + clearAuth(); + }, [clearQueryInput, clearAuth]); + + const formProps = useForm({}); + + // Tab related + const [tabIndex, setTabIndex] = React.useState(0); + const handleTabChange = React.useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + return ( + <> + + + + + + {t("Authority")} + + + + + + + + + + ), + }} + /> + + + + + + + + {tabIndex === 0 && ( + + )} + {tabIndex === 1 && ( + + )} + + + + + + + ); +}; + +export default AuthorityAllocation; diff --git a/src/components/CreateGroup/CreateGroup.tsx b/src/components/CreateGroup/CreateGroup.tsx new file mode 100644 index 0000000..e931521 --- /dev/null +++ b/src/components/CreateGroup/CreateGroup.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { CreateGroupInputs, auth, saveGroup } from "@/app/api/group/actions"; +import { useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; +import { FieldErrors, FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { Button, Stack, Tab, Tabs, TabsProps, Typography } from "@mui/material"; +import { Check, Close, Error } from "@mui/icons-material"; +import GroupInfo from "./GroupInfo"; +import AuthorityAllocation from "./AuthorityAllocation"; +import UserAllocation from "./UserAllocation"; +import { UserResult } from "@/app/api/user"; + +interface Props { + auth?: auth[] + users?: UserResult[] +} + +const CreateGroup: React.FC = ({ auth, users }) => { + const formProps = useForm(); + const [serverError, setServerError] = useState(""); + const router = useRouter(); + const [tabIndex, setTabIndex] = useState(0); + const { t } = useTranslation(); + + const errors = formProps.formState.errors; + + const onSubmit = useCallback>( + async (data) => { + try { + console.log(data); + const postData = { + ...data, + removeUserIds: [], + removeAuthIds: [], + + } + console.log(postData) + await saveGroup(postData) + router.replace("/settings/group") + } catch (e) { + console.log(e); + setServerError(t("An error has occurred. Please try again later.")); + } + }, + [router] + ); + + const handleCancel = () => { + router.back(); + }; + + const handleTabChange = useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + const hasErrorsInTab = ( + tabIndex: number, + errors: FieldErrors, + ) => { + switch (tabIndex) { + case 0: + return Object.keys(errors).length > 0; + default: + false; + } + }; + + return ( + <> + + + + + ) : undefined + } + iconPosition="end" + /> + + + + {serverError && ( + + {serverError} + + )} + {tabIndex === 0 && } + {tabIndex === 1 && } + {tabIndex === 2 && } + + + + + + + + + ); +}; + +export default CreateGroup; diff --git a/src/components/CreateGroup/CreateGroupLoading.tsx b/src/components/CreateGroup/CreateGroupLoading.tsx new file mode 100644 index 0000000..6a48c4e --- /dev/null +++ b/src/components/CreateGroup/CreateGroupLoading.tsx @@ -0,0 +1,40 @@ +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Skeleton from "@mui/material/Skeleton"; +import Stack from "@mui/material/Stack"; +import React from "react"; + +// Can make this nicer +export const CreateGroupLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + Create Group + + + + + + + + + + + ); +}; + +export default CreateGroupLoading; diff --git a/src/components/CreateGroup/CreateGroupWrapper.tsx b/src/components/CreateGroup/CreateGroupWrapper.tsx new file mode 100644 index 0000000..e4bd018 --- /dev/null +++ b/src/components/CreateGroup/CreateGroupWrapper.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import CreateGroupLoading from "./CreateGroupLoading"; +import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; +import { useSearchParams } from "next/navigation"; +import CreateGroup from "./CreateGroup"; +import { auth, fetchAuth } from "@/app/api/group/actions"; +import { fetchUser } from "@/app/api/user"; + +interface SubComponents { + Loading: typeof CreateGroupLoading; +} + +const CreateGroupWrapper: React.FC & SubComponents = async () => { + const records = await fetchAuth() + const users = await fetchUser() + console.log(users) + const auth = records.records as auth[] + + return ; +}; + +CreateGroupWrapper.Loading = CreateGroupLoading; + +export default CreateGroupWrapper; diff --git a/src/components/CreateGroup/GroupInfo.tsx b/src/components/CreateGroup/GroupInfo.tsx new file mode 100644 index 0000000..d9141bc --- /dev/null +++ b/src/components/CreateGroup/GroupInfo.tsx @@ -0,0 +1,81 @@ +"use client"; +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Grid from "@mui/material/Grid"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { CreateGroupInputs } from "@/app/api/group/actions"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useCallback } from "react"; + +const GroupInfo: React.FC = () => { + const { t } = useTranslation(); + const { + register, + formState: { errors, defaultValues }, + control, + reset, + resetField, + setValue, + } = useFormContext(); + + + const resetGroup = useCallback(() => { + console.log(defaultValues); + if (defaultValues !== undefined) { + resetField("description"); + } + }, [defaultValues]); + + + return ( + + + + + {t("Group Info")} + + + + + + + + + + + + + ); +}; + +export default GroupInfo; diff --git a/src/components/CreateGroup/UserAllocation.tsx b/src/components/CreateGroup/UserAllocation.tsx new file mode 100644 index 0000000..ff13c52 --- /dev/null +++ b/src/components/CreateGroup/UserAllocation.tsx @@ -0,0 +1,209 @@ +"use client"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + FieldErrors, + FormProvider, + SubmitErrorHandler, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { + Box, + Card, + CardContent, + Grid, + IconButton, + InputAdornment, + Stack, + Tab, + Tabs, + TabsProps, + TextField, + Typography, +} from "@mui/material"; +import { differenceBy } from "lodash"; +import { CreateGroupInputs, auth } from "@/app/api/group/actions"; +import SearchResults, { Column } from "../SearchResults"; +import { Add, Clear, Remove, Search } from "@mui/icons-material"; +import { UserResult } from "@/app/api/user"; + +export interface Props { + users: UserResult[]; +} + +const UserAllocation: React.FC = ({ users }) => { + const { t } = useTranslation(); + const { + setValue, + getValues, + formState: { defaultValues }, + reset, + resetField, + } = useFormContext(); + const initialUsers = users.map((u) => ({ ...u })).sort((a, b) => a.id - b.id).filter((u) => u.groupId !== null); + const [filteredUsers, setFilteredUsers] = useState(initialUsers); + const [selectedUsers, setSelectedUsers] = useState( + () => { + return filteredUsers.filter( + (s) => getValues("addUserIds")?.includes(s.id) + ); + } + ); + // Adding / Removing Auth + const addUser = useCallback((users: UserResult) => { + setSelectedUsers((a) => [...a, users]); + }, []); + + const removeUser = useCallback((users: UserResult) => { + setSelectedUsers((a) => a.filter((a) => a.id !== users.id)); + }, []); + + const clearUser = useCallback(() => { + if (defaultValues !== undefined) { + resetField("addUserIds"); + setSelectedUsers( + initialUsers.filter((s) => defaultValues.addUserIds?.includes(s.id)) + ); + } + }, [defaultValues]); + + // Sync with form + useEffect(() => { + setValue( + "addUserIds", + selectedUsers.map((u) => u.id) + ); + }, [selectedUsers, setValue]); + + const UserPoolColumns = useMemo[]>( + () => [ + { + label: t("Add"), + name: "id", + onClick: addUser, + buttonIcon: , + }, + { label: t("User Name"), name: "username" }, + { label: t("name"), name: "name" }, + ], + [addUser, t] + ); + + const allocatedUserColumns = useMemo[]>( + () => [ + { + label: t("Remove"), + name: "id", + onClick: removeUser, + buttonIcon: , + }, + { label: t("User Name"), name: "username" }, + { label: t("name"), name: "name" }, + ], + [removeUser, selectedUsers, t] + ); + + const [query, setQuery] = React.useState(""); + const onQueryInputChange = React.useCallback< + React.ChangeEventHandler + >((e) => { + setQuery(e.target.value); + }, []); + const clearQueryInput = React.useCallback(() => { + setQuery(""); + }, []); + + React.useEffect(() => { + // setFilteredStaff( + // initialStaffs.filter((s) => { + // const q = query.toLowerCase(); + // // s.staffId.toLowerCase().includes(q) + // // const q = query.toLowerCase(); + // // return s.name.toLowerCase().includes(q); + // // s.code.toString().includes(q) || + // // (s.brNo != null && s.brNo.toLowerCase().includes(q)) + // }) + // ); + }, [users, query]); + + const resetUser = React.useCallback(() => { + clearQueryInput(); + clearUser(); + }, [clearQueryInput, clearUser]); + + const formProps = useForm({}); + + // Tab related + const [tabIndex, setTabIndex] = React.useState(0); + const handleTabChange = React.useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + return ( + <> + + + + + + {t("User")} + + + + + + + + + + ), + }} + /> + + + + + + + + {tabIndex === 0 && ( + + )} + {tabIndex === 1 && ( + + )} + + + + + + + ); +}; + +export default UserAllocation; diff --git a/src/components/CreateGroup/index.ts b/src/components/CreateGroup/index.ts new file mode 100644 index 0000000..1034fc8 --- /dev/null +++ b/src/components/CreateGroup/index.ts @@ -0,0 +1 @@ +export { default } from "./CreateGroupWrapper" \ No newline at end of file diff --git a/src/components/CreateTeam/TeamInfo.tsx b/src/components/CreateTeam/TeamInfo.tsx index 4e61f4b..cd8b90a 100644 --- a/src/components/CreateTeam/TeamInfo.tsx +++ b/src/components/CreateTeam/TeamInfo.tsx @@ -27,7 +27,7 @@ const TeamInfo: React.FC = ( setValue, } = useFormContext(); - const resetCustomer = useCallback(() => { + const resetTeam = useCallback(() => { console.log(defaultValues); if (defaultValues !== undefined) { resetField("description"); diff --git a/src/components/EditTeam/Allocation.tsx b/src/components/EditTeam/Allocation.tsx index 2599867..61e9e8f 100644 --- a/src/components/EditTeam/Allocation.tsx +++ b/src/components/EditTeam/Allocation.tsx @@ -49,7 +49,7 @@ const Allocation: React.FC = ({ allStaffs: staff, teamLead }) => { reset, resetField, } = useFormContext(); - + // let firstFilter: StaffResult[] = [] const initialStaffs = staff.map((s) => ({ ...s })); @@ -63,7 +63,6 @@ const Allocation: React.FC = ({ allStaffs: staff, teamLead }) => { return rearrangedStaff.filter((s) => getValues("addStaffIds")?.includes(s.id)) } ); - console.log(filteredStaff.filter((s) => getValues("addStaffIds")?.includes(s.id))) const [seletedTeamLead, setSeletedTeamLead] = useState(); const [deletedStaffIds, setDeletedStaffIds] = useState([]); diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 233f228..226482e 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -143,6 +143,7 @@ const NavigationContent: React.FC = ({ abilities }) => { { icon: , label: "Salary", path: "/settings/salary" }, { icon: , label: "Team", path: "/settings/team" }, { icon: , label: "User", path: "/settings/user" }, + { icon: , label: "User Group", path: "/settings/group" }, { icon: , label: "Holiday", path: "/settings/holiday" }, ], }, diff --git a/src/components/UserGroupSearch/UserGroupSearch.tsx b/src/components/UserGroupSearch/UserGroupSearch.tsx new file mode 100644 index 0000000..0480167 --- /dev/null +++ b/src/components/UserGroupSearch/UserGroupSearch.tsx @@ -0,0 +1,94 @@ +"use client"; + +import SearchBox, { Criterion } from "../SearchBox"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import SearchResults, { Column } from "../SearchResults/index"; +import EditNote from "@mui/icons-material/EditNote"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { useRouter } from "next/navigation"; +import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; +import { UserGroupResult } from "@/app/api/group"; +import { deleteUser } from "@/app/api/user/actions"; + +interface Props { + users: UserGroupResult[]; +} +type SearchQuery = Partial>; +type SearchParamNames = keyof SearchQuery; + +const UserGroupSearch: React.FC = ({ users }) => { + const { t } = useTranslation(); + const [filteredUser, setFilteredUser] = useState(users); + const router = useRouter(); + + const searchCriteria: Criterion[] = useMemo( + () => [ + { + label: t("User Name"), + paramName: "name", + type: "text", + }, + ], + [t] + ); + + const onUserClick = useCallback( + (users: UserGroupResult) => { + console.log(users); + // router.push(`/settings/user/edit?id=${users.id}`) + }, + [router, t] + ); + + const onDeleteClick = useCallback((users: UserGroupResult) => { + deleteDialog(async () => { + await deleteUser(users.id); + + successDialog(t("Delete Success"), t); + + setFilteredUser((prev) => prev.filter((obj) => obj.id !== users.id)); + }, t); + }, []); + + const columns = useMemo[]>( + () => [ + { + name: "action", + label: t("Edit"), + onClick: onUserClick, + buttonIcon: , + }, + { name: "name", label: t("Group Name") }, + { name: "description", label: t("Description") }, + { + name: "action", + label: t("Delete"), + onClick: onDeleteClick, + buttonIcon: , + color: "error" + }, + ], + [t] + ); + + return ( + <> + { + // setFilteredUser( + // users.filter( + // (t) => + // t.name.toLowerCase().includes(query.name.toLowerCase()) && + // t.code.toLowerCase().includes(query.code.toLowerCase()) && + // t.description.toLowerCase().includes(query.description.toLowerCase()) + // ) + // ) + }} + /> + items={filteredUser} columns={columns} /> + + ); +}; +export default UserGroupSearch; diff --git a/src/components/UserGroupSearch/UserGroupSearchLoading.tsx b/src/components/UserGroupSearch/UserGroupSearchLoading.tsx new file mode 100644 index 0000000..5d8df0f --- /dev/null +++ b/src/components/UserGroupSearch/UserGroupSearchLoading.tsx @@ -0,0 +1,40 @@ +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Skeleton from "@mui/material/Skeleton"; +import Stack from "@mui/material/Stack"; +import React from "react"; + +// Can make this nicer +export const UserGroupSearchLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default UserGroupSearchLoading; diff --git a/src/components/UserGroupSearch/UserGroupSearchWrapper.tsx b/src/components/UserGroupSearch/UserGroupSearchWrapper.tsx new file mode 100644 index 0000000..9f792ed --- /dev/null +++ b/src/components/UserGroupSearch/UserGroupSearchWrapper.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import UserGroupSearchLoading from "./UserGroupSearchLoading"; +import { UserGroupResult, fetchGroup } from "@/app/api/group"; +import UserGroupSearch from "./UserGroupSearch"; + +interface SubComponents { + Loading: typeof UserGroupSearchLoading; +} + +const UserGroupSearchWrapper: React.FC & SubComponents = async () => { +const group = await fetchGroup() + console.log(group.records); + + return ; +}; + +UserGroupSearchWrapper.Loading = UserGroupSearchLoading; + +export default UserGroupSearchWrapper; diff --git a/src/components/UserGroupSearch/index.ts b/src/components/UserGroupSearch/index.ts new file mode 100644 index 0000000..f2e5e63 --- /dev/null +++ b/src/components/UserGroupSearch/index.ts @@ -0,0 +1 @@ +export { default } from "./UserGroupSearchWrapper"; From 6e2266fba28179a344bf731b01a4d2e4d74d91c4 Mon Sep 17 00:00:00 2001 From: "MSI\\2Fi" Date: Tue, 7 May 2024 11:47:23 +0800 Subject: [PATCH 19/37] Edit Department + Delete Ediit Postion + Delete --- .../(main)/settings/department/edit/page.tsx | 31 ++++++++++++++++ .../(main)/settings/department/new/page.tsx | 2 +- src/app/api/departments/actions.ts | 21 ++++++++--- src/app/api/departments/index.ts | 10 ++++++ src/app/api/positions/actions.ts | 36 ++++++++++++------- .../CreateDepartment/CreateDepartment.tsx | 22 +++++++----- .../CreateDepartmentWrapper.tsx | 24 ++++++++----- .../CreateDepartment/DepartmentDetails.tsx | 8 ++--- .../CreatePosition/PositionDetails.tsx | 8 ++--- .../DepartmentSearch/DepartmentSearch.tsx | 29 +++++++++++++-- src/components/EditPosition/EditPosition.tsx | 10 +----- .../EditPosition/PositionDetails.tsx | 8 ++--- .../PositionSearch/PositionSearch.tsx | 27 ++++++++++++-- 13 files changed, 175 insertions(+), 61 deletions(-) create mode 100644 src/app/(main)/settings/department/edit/page.tsx diff --git a/src/app/(main)/settings/department/edit/page.tsx b/src/app/(main)/settings/department/edit/page.tsx new file mode 100644 index 0000000..748003e --- /dev/null +++ b/src/app/(main)/settings/department/edit/page.tsx @@ -0,0 +1,31 @@ +import CreateDepartment from "@/components/CreateDepartment"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import Typography from "@mui/material/Typography"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Create Department", +}; + +interface Props { + searchParams: { [key: string]: string | undefined }; +} + +const Department: React.FC = async ({searchParams}) => { + const { t } = await getServerI18n("departments"); + + // Preload necessary dependencies + // Assume projectId is string here + const departmentId = searchParams["id"]; + + return ( + <> + {t("Create Department")} + + + + + ); +}; + +export default Department; diff --git a/src/app/(main)/settings/department/new/page.tsx b/src/app/(main)/settings/department/new/page.tsx index 1f94edc..2121dc6 100644 --- a/src/app/(main)/settings/department/new/page.tsx +++ b/src/app/(main)/settings/department/new/page.tsx @@ -16,7 +16,7 @@ const Department: React.FC = async () => { <> {t("Create Department")} - + ); diff --git a/src/app/api/departments/actions.ts b/src/app/api/departments/actions.ts index c6bdfd2..48a3174 100644 --- a/src/app/api/departments/actions.ts +++ b/src/app/api/departments/actions.ts @@ -1,6 +1,6 @@ "use server" -import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; import { cache } from "react"; @@ -14,8 +14,9 @@ export interface combo { records: comboProp[]; } export interface CreateDepartmentInputs { - departmentCode: string; - departmentName: string; + id: number; + code: string; + name: string; description: string; } @@ -25,7 +26,19 @@ export const saveDepartment = async (data: CreateDepartmentInputs) => { body: JSON.stringify(data), headers: { "Content-Type": "application/json" }, }); - }; +}; + +export const deleteDepartment = async (id: number) => { + const department = await serverFetchWithNoContent( + `${BASE_API_URL}/departments/${id}`, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }, + ); + + return department +}; export const fetchDepartmentCombo = cache(async () => { diff --git a/src/app/api/departments/index.ts b/src/app/api/departments/index.ts index 9bb4354..580b302 100644 --- a/src/app/api/departments/index.ts +++ b/src/app/api/departments/index.ts @@ -2,6 +2,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; import { cache } from "react"; import "server-only"; +import { CreateDepartmentInputs } from "./actions"; export interface DepartmentResult { id: number; @@ -18,4 +19,13 @@ export const fetchDepartments = cache(async () => { return serverFetchJson(`${BASE_API_URL}/departments`, { next: { tags: ["departments"] }, }); +}); + +export const fetchDepartmentDetails = cache(async (departmentId: string) => { + return serverFetchJson( + `${BASE_API_URL}/departments/departmentDetails/${departmentId}`, + { + next: { tags: [`departmentDetail${departmentId}`] }, + }, + ); }); \ No newline at end of file diff --git a/src/app/api/positions/actions.ts b/src/app/api/positions/actions.ts index 2f69990..8826cc7 100644 --- a/src/app/api/positions/actions.ts +++ b/src/app/api/positions/actions.ts @@ -1,6 +1,6 @@ "use server" -import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; import { cache } from "react"; import { PositionResult } from "."; @@ -15,15 +15,15 @@ export interface combo { } export interface CreatePositionInputs { - positionCode: string; - positionName: string; + code: string; + name: string; description: string; } export interface EditPositionInputs { id: number; - positionCode: string; - positionName: string; + code: string; + name: string; description: string; } @@ -35,13 +35,25 @@ export const savePosition = async (data: CreatePositionInputs) => { }); }; - export const editPosition = async (data: EditPositionInputs) => { - return serverFetchJson(`${BASE_API_URL}/positions/new`, { - method: "POST", - body: JSON.stringify(data), - headers: { "Content-Type": "application/json" }, - }); - }; +export const editPosition = async (data: EditPositionInputs) => { + return serverFetchJson(`${BASE_API_URL}/positions/new`, { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); +}; + +export const deletePosition = async (id: number) => { + const position = await serverFetchWithNoContent( + `${BASE_API_URL}/positions/${id}`, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }, + ); + + return position +}; export const fetchPositionCombo = cache(async () => { return serverFetchJson(`${BASE_API_URL}/positions/combo`, { diff --git a/src/components/CreateDepartment/CreateDepartment.tsx b/src/components/CreateDepartment/CreateDepartment.tsx index 42088fe..2d95d28 100644 --- a/src/components/CreateDepartment/CreateDepartment.tsx +++ b/src/components/CreateDepartment/CreateDepartment.tsx @@ -22,18 +22,23 @@ import { Error } from "@mui/icons-material"; import { ProjectCategory } from "@/app/api/projects"; import { Typography } from "@mui/material"; import DepartmentDetails from "./DepartmentDetails"; +import { DepartmentResult } from "@/app/api/departments"; +interface Props { + isEdit: Boolean; + department?: CreateDepartmentInputs; +} -const CreateDepartment: React.FC = ({ - // allTasks, - // projectCategories, - // taskTemplates, - // teamLeads, +const CreateDepartment: React.FC = ({ + isEdit, + department, }) => { const [serverError, setServerError] = useState(""); const { t } = useTranslation(); const router = useRouter(); + console.log(department) + const handleCancel = () => { router.back(); }; @@ -62,9 +67,10 @@ const CreateDepartment: React.FC = ({ const formProps = useForm({ defaultValues: { - departmentCode: "", - departmentName: "", - description: "", + id: department?.id, + code: department?.code, + name: department?.name, + description: department?.description, }, }); diff --git a/src/components/CreateDepartment/CreateDepartmentWrapper.tsx b/src/components/CreateDepartment/CreateDepartmentWrapper.tsx index cf32044..199ed1c 100644 --- a/src/components/CreateDepartment/CreateDepartmentWrapper.tsx +++ b/src/components/CreateDepartment/CreateDepartmentWrapper.tsx @@ -1,18 +1,24 @@ import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; import CreateDepartment from "./CreateDepartment"; import { fetchTeamLeads } from "@/app/api/staff"; +import { DepartmentResult, fetchDepartmentDetails } from "@/app/api/departments"; -const CreateDepartmentWrapper: React.FC = async () => { - // const [tasks, taskTemplates, DepartmentCategories, teamLeads] = - // await Promise.all([ - // fetchAllTasks(), - // fetchTaskTemplates(), - // fetchDepartmentCategories(), - // fetchTeamLeads(), - // ]); +type CreateDepartmentProps = { isEdit: false }; +interface EditDepartmentProps { + isEdit: true; + departmentId?: string; +} + +type Props = CreateDepartmentProps | EditDepartmentProps; + +const CreateDepartmentWrapper: React.FC = async (props) => { + + const departmentInfo = props.isEdit + ? await fetchDepartmentDetails(props.departmentId!) + : undefined; return ( - ); }; diff --git a/src/components/CreateDepartment/DepartmentDetails.tsx b/src/components/CreateDepartment/DepartmentDetails.tsx index 4039587..8cd559c 100644 --- a/src/components/CreateDepartment/DepartmentDetails.tsx +++ b/src/components/CreateDepartment/DepartmentDetails.tsx @@ -39,20 +39,20 @@ const DepartmentDetails: React.FC = ({
diff --git a/src/components/CreatePosition/PositionDetails.tsx b/src/components/CreatePosition/PositionDetails.tsx index 5e03acd..22542c5 100644 --- a/src/components/CreatePosition/PositionDetails.tsx +++ b/src/components/CreatePosition/PositionDetails.tsx @@ -39,20 +39,20 @@ const PositionDetails: React.FC = ({ diff --git a/src/components/DepartmentSearch/DepartmentSearch.tsx b/src/components/DepartmentSearch/DepartmentSearch.tsx index 57cd3ab..b881bb4 100644 --- a/src/components/DepartmentSearch/DepartmentSearch.tsx +++ b/src/components/DepartmentSearch/DepartmentSearch.tsx @@ -5,8 +5,11 @@ import SearchBox, { Criterion } from "../SearchBox"; import { useTranslation } from "react-i18next"; import SearchResults, { Column } from "../SearchResults"; import EditNote from "@mui/icons-material/EditNote"; -import uniq from "lodash/uniq"; import { DepartmentResult } from "@/app/api/departments"; +import { useRouter } from "next/navigation"; +import DeleteIcon from '@mui/icons-material/Delete'; +import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; +import { deleteDepartment } from "@/app/api/departments/actions"; interface Props { departments: DepartmentResult[]; @@ -17,6 +20,7 @@ type SearchParamNames = keyof SearchQuery; const DepartmentSearch: React.FC = ({ departments }) => { const { t } = useTranslation("departments"); + const router = useRouter(); const [filteredDepartments, setFilteredDepartments] = useState(departments); @@ -33,8 +37,20 @@ const DepartmentSearch: React.FC = ({ departments }) => { setFilteredDepartments(departments); }, [departments]); - const onProjectClick = useCallback((project: DepartmentResult) => { - console.log(project); + const onProjectClick = useCallback((department: DepartmentResult) => { + console.log(department.id) + router.push(`/settings/department/edit?id=${department.id}`); + }, [router]); + + const onDeleteClick = useCallback((department: DepartmentResult) => { + + deleteDialog(async() => { + await deleteDepartment(department.id) + + successDialog("Delete Success", t) + + setFilteredDepartments((prev) => prev.filter((obj) => obj.id !== department.id)) + }, t) }, []); const columns = useMemo[]>( @@ -48,6 +64,13 @@ const DepartmentSearch: React.FC = ({ departments }) => { { name: "code", label: t("Department Code") }, { name: "name", label: t("Department Name") }, { name: "description", label: t("Department Description") }, + { + name: "id", + label: t("Delete"), + onClick: onDeleteClick, + buttonIcon: , + color: "error" + }, ], [t, onProjectClick], ); diff --git a/src/components/EditPosition/EditPosition.tsx b/src/components/EditPosition/EditPosition.tsx index 92cf871..602f489 100644 --- a/src/components/EditPosition/EditPosition.tsx +++ b/src/components/EditPosition/EditPosition.tsx @@ -45,16 +45,8 @@ const EditPosition: React.FC = ({ try{ if (positionId !== null && parseInt(positionId) > 0) { const postionDetails = await fetchPositionDetails(parseInt(positionId)) - const updatedArray: EditPositionInputs[] = postionDetails.map((obj) => { - return { - id: obj.id, - positionCode: obj.code, - positionName: obj.name, - description: obj.description - }; - }); - setPositionDetails(updatedArray[0]) + setPositionDetails(postionDetails[0]) } } catch (error){ console.log(error) diff --git a/src/components/EditPosition/PositionDetails.tsx b/src/components/EditPosition/PositionDetails.tsx index 086998e..91ac18d 100644 --- a/src/components/EditPosition/PositionDetails.tsx +++ b/src/components/EditPosition/PositionDetails.tsx @@ -46,20 +46,20 @@ const PositionDetails: React.FC = ({ diff --git a/src/components/PositionSearch/PositionSearch.tsx b/src/components/PositionSearch/PositionSearch.tsx index d0b3ee4..e700e2a 100644 --- a/src/components/PositionSearch/PositionSearch.tsx +++ b/src/components/PositionSearch/PositionSearch.tsx @@ -7,6 +7,9 @@ import SearchResults, { Column } from "../SearchResults"; import EditNote from "@mui/icons-material/EditNote"; import { PositionResult } from "@/app/api/positions"; import { useRouter } from "next/navigation"; +import DeleteIcon from '@mui/icons-material/Delete'; +import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; +import { deletePosition } from "@/app/api/positions/actions"; interface Props { positions: PositionResult[]; @@ -34,12 +37,23 @@ const PositionSearch: React.FC = ({ positions }) => { setFilteredPositions(positions); }, [positions]); - const onPositionClick = useCallback((project: PositionResult) => { - console.log(project); - const id = project.id + const onPositionClick = useCallback((position: PositionResult) => { + console.log(position); + const id = position.id router.push(`/settings/position/edit?id=${id}`); }, []); + const onDeleteClick = useCallback((position: PositionResult) => { + + deleteDialog(async() => { + await deletePosition(position.id) + + successDialog("Delete Success", t) + + setFilteredPositions((prev) => prev.filter((obj) => obj.id !== position.id)) + }, t) +}, []); + const columns = useMemo[]>( () => [ { @@ -51,6 +65,13 @@ const PositionSearch: React.FC = ({ positions }) => { { name: "code", label: t("Position Code") }, { name: "name", label: t("Position Name") }, { name: "description", label: t("Position Description") }, + { + name: "id", + label: t("Delete"), + onClick: onDeleteClick, + buttonIcon: , + color: "error" + }, ], [t, onPositionClick], ); From e689ad108354dc5987678e44c71ab2c21ff07333 Mon Sep 17 00:00:00 2001 From: "MSI\\derek" Date: Tue, 7 May 2024 16:07:05 +0800 Subject: [PATCH 20/37] update user group page --- src/app/(main)/settings/group/edit/page.tsx | 26 +++ src/app/api/group/actions.ts | 17 +- .../CreateGroup/AuthorityAllocation.tsx | 5 +- .../EditUserGroup/AuthorityAllocation.tsx | 210 +++++++++++++++++ .../EditUserGroup/EditUserGroup.tsx | 165 +++++++++++++ .../EditUserGroup/EditUserGroupLoading.tsx | 40 ++++ .../EditUserGroup/EditUserGroupWrapper.tsx | 31 +++ src/components/EditUserGroup/GroupInfo.tsx | 81 +++++++ .../EditUserGroup/UserAllocation.tsx | 216 ++++++++++++++++++ src/components/EditUserGroup/index.ts | 1 + .../UserGroupSearch/UserGroupSearch.tsx | 13 +- 11 files changed, 790 insertions(+), 15 deletions(-) create mode 100644 src/components/EditUserGroup/AuthorityAllocation.tsx create mode 100644 src/components/EditUserGroup/EditUserGroup.tsx create mode 100644 src/components/EditUserGroup/EditUserGroupLoading.tsx create mode 100644 src/components/EditUserGroup/EditUserGroupWrapper.tsx create mode 100644 src/components/EditUserGroup/GroupInfo.tsx create mode 100644 src/components/EditUserGroup/UserAllocation.tsx create mode 100644 src/components/EditUserGroup/index.ts diff --git a/src/app/(main)/settings/group/edit/page.tsx b/src/app/(main)/settings/group/edit/page.tsx index e69de29..ae51d16 100644 --- a/src/app/(main)/settings/group/edit/page.tsx +++ b/src/app/(main)/settings/group/edit/page.tsx @@ -0,0 +1,26 @@ +import EditPosition from "@/components/EditPosition"; +import EditUserGroup from "@/components/EditUserGroup"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import Typography from "@mui/material/Typography"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Edit User Group", +}; + +const Positions: React.FC = async () => { + const { t } = await getServerI18n("group"); + + // Preload necessary dependencies + + return ( + <> + {/* {t("Edit User Group")} */} + + + + + ); +}; + +export default Positions; \ No newline at end of file diff --git a/src/app/api/group/actions.ts b/src/app/api/group/actions.ts index c8881de..204c1d9 100644 --- a/src/app/api/group/actions.ts +++ b/src/app/api/group/actions.ts @@ -29,11 +29,11 @@ export interface record { records: auth[]; } - export const fetchAuth = cache(async () => { - return serverFetchJson(`${BASE_API_URL}/group/auth/combo`, { - next: { tags: ["auth"] }, - }); +export const fetchAuth = cache(async (id?: number) => { + return serverFetchJson(`${BASE_API_URL}/group/auth/combo/${id ?? 0}`, { + next: { tags: ["auth"] }, }); +}); export const saveGroup = async (data: CreateGroupInputs) => { return serverFetchJson(`${BASE_API_URL}/group/save`, { @@ -41,4 +41,11 @@ export const saveGroup = async (data: CreateGroupInputs) => { body: JSON.stringify(data), headers: { "Content-Type": "application/json" }, }); - }; \ No newline at end of file + }; + +export const deleteGroup = async (id: number) => { + return serverFetchWithNoContent(`${BASE_API_URL}/group/${id}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }); +}; \ No newline at end of file diff --git a/src/components/CreateGroup/AuthorityAllocation.tsx b/src/components/CreateGroup/AuthorityAllocation.tsx index fd9610b..bdd4ccb 100644 --- a/src/components/CreateGroup/AuthorityAllocation.tsx +++ b/src/components/CreateGroup/AuthorityAllocation.tsx @@ -50,6 +50,7 @@ const AuthorityAllocation: React.FC = ({ auth }) => { ); } ); + // Adding / Removing Auth const addAuth = useCallback((auth: auth) => { setSelectedAuths((a) => [...a, auth]); @@ -126,10 +127,6 @@ const AuthorityAllocation: React.FC = ({ auth }) => { // ); }, [auth, query]); - useEffect(() => { - // console.log(getValues("addStaffIds")) - }, [initialAuths]); - const resetAuth = React.useCallback(() => { clearQueryInput(); clearAuth(); diff --git a/src/components/EditUserGroup/AuthorityAllocation.tsx b/src/components/EditUserGroup/AuthorityAllocation.tsx new file mode 100644 index 0000000..da502da --- /dev/null +++ b/src/components/EditUserGroup/AuthorityAllocation.tsx @@ -0,0 +1,210 @@ +"use client"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + FieldErrors, + FormProvider, + SubmitErrorHandler, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { + Box, + Card, + CardContent, + Grid, + IconButton, + InputAdornment, + Stack, + Tab, + Tabs, + TabsProps, + TextField, + Typography, +} from "@mui/material"; +import { differenceBy } from "lodash"; +import { CreateGroupInputs, auth } from "@/app/api/group/actions"; +import SearchResults, { Column } from "../SearchResults"; +import { Add, Clear, Remove, Search } from "@mui/icons-material"; + +export interface Props { + auth: auth[]; +} + +const AuthorityAllocation: React.FC = ({ auth }) => { + const { t } = useTranslation(); + const { + setValue, + getValues, + formState: { defaultValues }, + reset, + resetField, + } = useFormContext(); + console.log(auth) + const initialAuths = auth.map((a) => ({ ...a })).sort((a, b) => a.id - b.id); + const [filteredAuths, setFilteredAuths] = useState(initialAuths); + const [selectedAuths, setSelectedAuths] = useState( + () => initialAuths.filter((s) => getValues("addAuthIds")?.includes(s.id))) + const [removeAuthIds, setRemoveAuthIds] = useState([]); + + // Adding / Removing Auth + const addAuth = useCallback((auth: auth) => { + setSelectedAuths((a) => [...a, auth]); + }, []); + const removeAuth = useCallback((auth: auth) => { + setSelectedAuths((a) => a.filter((a) => a.id !== auth.id)); + setRemoveAuthIds((prevIds) => [...prevIds, auth.id]); +}, []); + + const clearAuth = useCallback(() => { + if (defaultValues !== undefined) { + resetField("addAuthIds"); + setSelectedAuths( + initialAuths.filter((auth) => defaultValues.addAuthIds?.includes(auth.id)) + ); + } + }, [defaultValues]); + + // Sync with form + useEffect(() => { + setValue( + "addAuthIds", + selectedAuths.map((a) => a.id) + ); + setValue( + "removeAuthIds", + removeAuthIds + ); + }, [selectedAuths, removeAuthIds, setValue]); + + const AuthPoolColumns = useMemo[]>( + () => [ + { + label: t("Add"), + name: "id", + onClick: addAuth, + buttonIcon: , + }, + { label: t("authority"), name: "authority" }, + { label: t("Auth Name"), name: "name" }, + // { label: t("Current Position"), name: "currentPosition" }, + ], + [addAuth, t] + ); + + const allocatedAuthColumns = useMemo[]>( + () => [ + { + label: t("Remove"), + name: "id", + onClick: removeAuth, + buttonIcon: , + }, + { label: t("authority"), name: "authority" }, + { label: t("Auth Name"), name: "name" }, + ], + [removeAuth, selectedAuths, t] + ); + const [query, setQuery] = React.useState(""); + const onQueryInputChange = React.useCallback< + React.ChangeEventHandler + >((e) => { + setQuery(e.target.value); + }, []); + const clearQueryInput = React.useCallback(() => { + setQuery(""); + }, []); + + React.useEffect(() => { + // setFilteredStaff( + // initialStaffs.filter((s) => { + // const q = query.toLowerCase(); + // // s.staffId.toLowerCase().includes(q) + // // const q = query.toLowerCase(); + // // return s.name.toLowerCase().includes(q); + // // s.code.toString().includes(q) || + // // (s.brNo != null && s.brNo.toLowerCase().includes(q)) + // }) + // ); + }, [auth, query]); + + const resetAuth = React.useCallback(() => { + clearQueryInput(); + clearAuth(); + }, [clearQueryInput, clearAuth]); + + const formProps = useForm({}); + + // Tab related + const [tabIndex, setTabIndex] = React.useState(0); + const handleTabChange = React.useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + return ( + <> + + + + + + {t("Authority")} + + + + + + + + + + ), + }} + /> + + + + + + + + {tabIndex === 0 && ( + + )} + {tabIndex === 1 && ( + + )} + + + + + + + ); +}; + +export default AuthorityAllocation; diff --git a/src/components/EditUserGroup/EditUserGroup.tsx b/src/components/EditUserGroup/EditUserGroup.tsx new file mode 100644 index 0000000..fe75821 --- /dev/null +++ b/src/components/EditUserGroup/EditUserGroup.tsx @@ -0,0 +1,165 @@ +"use client"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import SearchResults, { Column } from "../SearchResults"; +// import { TeamResult } from "@/app/api/team"; +import { useTranslation } from "react-i18next"; +import { Button, Stack, Tab, Tabs, TabsProps, Typography } from "@mui/material"; +import { CreateTeamInputs, saveTeam } from "@/app/api/team/actions"; +import { + FieldErrors, + FormProvider, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { Check, Close, Error } from "@mui/icons-material"; +import { StaffResult } from "@/app/api/staff"; +import { CreateGroupInputs, auth, fetchAuth, saveGroup } from "@/app/api/group/actions"; +import { UserGroupResult } from "@/app/api/group"; +import { UserResult } from "@/app/api/user"; +import GroupInfo from "./GroupInfo"; +import AuthorityAllocation from "./AuthorityAllocation"; +import UserAllocation from "./UserAllocation"; +interface Props { + groups: UserGroupResult[]; +// auths: auth[]; + users: UserResult[]; +} + +const EditUserGroup: React.FC = ({ groups, users }) => { + // console.log(users) + const { t } = useTranslation(); + const [serverError, setServerError] = useState(""); + const formProps = useForm(); + const searchParams = useSearchParams(); + const id = parseInt(searchParams.get("id") || "0"); + const router = useRouter(); + const [tabIndex, setTabIndex] = useState(0); + const [auths, setAuths] = useState(); + + const errors = formProps.formState.errors; + + const handleTabChange = useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + const hasErrorsInTab = ( + tabIndex: number, + errors: FieldErrors + ) => { + switch (tabIndex) { + case 0: + return Object.keys(errors).length > 0; + default: + false; + } + }; + + const onSubmit = useCallback>( + async (data) => { + try { + console.log(data); + const tempData = { + ...data, + removeUserIds: data.removeUserIds ?? [], + removeAuthIds: data.removeAuthIds ?? [], + id: id + } + console.log(tempData) + await saveGroup(tempData); + router.replace("/settings/group"); + } catch (e) { + console.log(e); + setServerError(t("An error has occurred. Please try again later.")); + } + }, + [router] + ); + useEffect(() => { + const thisGroup = groups.filter((item) => item.id === id)[0]; + const addUserIds = users.filter((item) => item.groupId === id).map((data) => data.id) + let addAuthIds: number[] = [] + fetchAuth(id).then((data) => { + setAuths(data.records) + addAuthIds = data.records.filter((data) => data.v === 1).map((data) => data.id).sort((a, b) => a - b); + formProps.reset({ + name: thisGroup.name, + description: thisGroup.description, + addAuthIds: addAuthIds, + addUserIds: addUserIds, + }); + }); + // console.log(auths) + }, [groups, users]); + + return ( + <> + + + + {t("Edit User Group")} + + + + + ) : undefined + } + iconPosition="end" + /> + + + + + {serverError && ( + + {serverError} + + )} + {tabIndex === 0 && } + {tabIndex === 1 && } + {tabIndex === 2 && } + + + + + + + + ); +}; + +export default EditUserGroup; diff --git a/src/components/EditUserGroup/EditUserGroupLoading.tsx b/src/components/EditUserGroup/EditUserGroupLoading.tsx new file mode 100644 index 0000000..9238474 --- /dev/null +++ b/src/components/EditUserGroup/EditUserGroupLoading.tsx @@ -0,0 +1,40 @@ +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Skeleton from "@mui/material/Skeleton"; +import Stack from "@mui/material/Stack"; +import React from "react"; + +// Can make this nicer +export const EditUserGroupLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + EditUserGroup + + + + + + + + + + + ); +}; + +export default EditUserGroupLoading; diff --git a/src/components/EditUserGroup/EditUserGroupWrapper.tsx b/src/components/EditUserGroup/EditUserGroupWrapper.tsx new file mode 100644 index 0000000..84f7501 --- /dev/null +++ b/src/components/EditUserGroup/EditUserGroupWrapper.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import EditUserGroup from "./EditUserGroup"; +import EditUserGroupLoading from "./EditUserGroupLoading"; +import { fetchGroup } from "@/app/api/group"; +import { fetchAuth } from "@/app/api/group/actions"; +import { fetchUser } from "@/app/api/user"; +import { useSearchParams } from "next/navigation"; + +interface SubComponents { + Loading: typeof EditUserGroupLoading; +} + +const EditUserGroupWrapper: React.FC & SubComponents = async () => { + + const [ + groups, + // auths, + users, + ] = await Promise.all([ + fetchGroup(), + // fetchAuth(), + fetchUser(), + ]); + console.log(users) + + return ; +}; + +EditUserGroupWrapper.Loading = EditUserGroupLoading; + +export default EditUserGroupWrapper; diff --git a/src/components/EditUserGroup/GroupInfo.tsx b/src/components/EditUserGroup/GroupInfo.tsx new file mode 100644 index 0000000..d9141bc --- /dev/null +++ b/src/components/EditUserGroup/GroupInfo.tsx @@ -0,0 +1,81 @@ +"use client"; +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Grid from "@mui/material/Grid"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { CreateGroupInputs } from "@/app/api/group/actions"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useCallback } from "react"; + +const GroupInfo: React.FC = () => { + const { t } = useTranslation(); + const { + register, + formState: { errors, defaultValues }, + control, + reset, + resetField, + setValue, + } = useFormContext(); + + + const resetGroup = useCallback(() => { + console.log(defaultValues); + if (defaultValues !== undefined) { + resetField("description"); + } + }, [defaultValues]); + + + return ( + + + + + {t("Group Info")} + + + + + + + + + + + + + ); +}; + +export default GroupInfo; diff --git a/src/components/EditUserGroup/UserAllocation.tsx b/src/components/EditUserGroup/UserAllocation.tsx new file mode 100644 index 0000000..14ed975 --- /dev/null +++ b/src/components/EditUserGroup/UserAllocation.tsx @@ -0,0 +1,216 @@ +"use client"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + FieldErrors, + FormProvider, + SubmitErrorHandler, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { + Box, + Card, + CardContent, + Grid, + IconButton, + InputAdornment, + Stack, + Tab, + Tabs, + TabsProps, + TextField, + Typography, +} from "@mui/material"; +import { differenceBy } from "lodash"; +import { CreateGroupInputs, auth } from "@/app/api/group/actions"; +import SearchResults, { Column } from "../SearchResults"; +import { Add, Clear, Remove, Search } from "@mui/icons-material"; +import { UserResult } from "@/app/api/user"; + +export interface Props { + users: UserResult[]; +} + +const UserAllocation: React.FC = ({ users }) => { + const { t } = useTranslation(); + const { + setValue, + getValues, + formState: { defaultValues }, + reset, + resetField, + } = useFormContext(); + const initialUsers = users.map((u) => ({ ...u })).sort((a, b) => a.id - b.id); + const [filteredUsers, setFilteredUsers] = useState(initialUsers); + const [selectedUsers, setSelectedUsers] = useState( + () => { + return filteredUsers.filter( + (s) => getValues("addUserIds")?.includes(s.id) + ); + } + ); + const [deletedUserIds, setDeletedUserIds] = useState([]); + + // Adding / Removing Auth + const addUser = useCallback((users: UserResult) => { + setSelectedUsers((a) => [...a, users]); + }, []); + + const removeUser = useCallback((users: UserResult) => { + setSelectedUsers((a) => a.filter((a) => a.id !== users.id)); + setDeletedUserIds((prevIds) => [...prevIds, users.id]); + }, []); + + const clearUser = useCallback(() => { + if (defaultValues !== undefined) { + resetField("addUserIds"); + setSelectedUsers( + initialUsers.filter((s) => defaultValues.addUserIds?.includes(s.id)) + ); + } + }, [defaultValues]); + + // Sync with form + useEffect(() => { + setValue( + "addUserIds", + selectedUsers.map((u) => u.id) + ); + setValue( + "removeUserIds", + deletedUserIds + ); + }, [selectedUsers, deletedUserIds, setValue]); + + const UserPoolColumns = useMemo[]>( + () => [ + { + label: t("Add"), + name: "id", + onClick: addUser, + buttonIcon: , + }, + { label: t("User Name"), name: "username" }, + { label: t("name"), name: "name" }, + ], + [addUser, t] + ); + + const allocatedUserColumns = useMemo[]>( + () => [ + { + label: t("Remove"), + name: "id", + onClick: removeUser, + buttonIcon: , + }, + { label: t("User Name"), name: "username" }, + { label: t("name"), name: "name" }, + ], + [removeUser, selectedUsers, t] + ); + + const [query, setQuery] = React.useState(""); + const onQueryInputChange = React.useCallback< + React.ChangeEventHandler + >((e) => { + setQuery(e.target.value); + }, []); + const clearQueryInput = React.useCallback(() => { + setQuery(""); + }, []); + + React.useEffect(() => { + // setFilteredStaff( + // initialStaffs.filter((s) => { + // const q = query.toLowerCase(); + // // s.staffId.toLowerCase().includes(q) + // // const q = query.toLowerCase(); + // // return s.name.toLowerCase().includes(q); + // // s.code.toString().includes(q) || + // // (s.brNo != null && s.brNo.toLowerCase().includes(q)) + // }) + // ); + }, [users, query]); + + const resetUser = React.useCallback(() => { + clearQueryInput(); + clearUser(); + }, [clearQueryInput, clearUser]); + + const formProps = useForm({}); + + // Tab related + const [tabIndex, setTabIndex] = React.useState(0); + const handleTabChange = React.useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + return ( + <> + + + + + + {t("User")} + + + + + + + + + + ), + }} + /> + + + + + + + + {tabIndex === 0 && ( + + )} + {tabIndex === 1 && ( + + )} + + + + + + + ); +}; + +export default UserAllocation; diff --git a/src/components/EditUserGroup/index.ts b/src/components/EditUserGroup/index.ts new file mode 100644 index 0000000..b062020 --- /dev/null +++ b/src/components/EditUserGroup/index.ts @@ -0,0 +1 @@ +export { default } from "./EditUserGroupWrapper"; diff --git a/src/components/UserGroupSearch/UserGroupSearch.tsx b/src/components/UserGroupSearch/UserGroupSearch.tsx index 0480167..fee25e4 100644 --- a/src/components/UserGroupSearch/UserGroupSearch.tsx +++ b/src/components/UserGroupSearch/UserGroupSearch.tsx @@ -10,6 +10,7 @@ import { useRouter } from "next/navigation"; import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; import { UserGroupResult } from "@/app/api/group"; import { deleteUser } from "@/app/api/user/actions"; +import { deleteGroup } from "@/app/api/group/actions"; interface Props { users: UserGroupResult[]; @@ -34,20 +35,20 @@ const UserGroupSearch: React.FC = ({ users }) => { ); const onUserClick = useCallback( - (users: UserGroupResult) => { - console.log(users); - // router.push(`/settings/user/edit?id=${users.id}`) + (group: UserGroupResult) => { + console.log(group); + router.push(`/settings/group/edit?id=${group.id}`) }, [router, t] ); - const onDeleteClick = useCallback((users: UserGroupResult) => { + const onDeleteClick = useCallback((group: UserGroupResult) => { deleteDialog(async () => { - await deleteUser(users.id); + await deleteGroup(group.id); successDialog(t("Delete Success"), t); - setFilteredUser((prev) => prev.filter((obj) => obj.id !== users.id)); + setFilteredUser((prev) => prev.filter((obj) => obj.id !== group.id)); }, t); }, []); From e1e82fbaf775693551274af2a9e7f186febd99f0 Mon Sep 17 00:00:00 2001 From: "MSI\\2Fi" Date: Tue, 7 May 2024 18:22:45 +0800 Subject: [PATCH 21/37] Import Salary Export Salary Temaplet, Todo: template with data in DB --- src/app/api/salarys/actions.ts | 32 ++++++++- src/app/utils/fetchUtil.ts | 21 +++++- src/components/Breadcrumb/Breadcrumb.tsx | 1 + .../CompanyHoliday/CompanyHoliday.tsx | 5 +- .../CompanyHoliday/CompanyHolidayDialog.tsx | 4 +- .../DepartmentSearch/DepartmentSearch.tsx | 2 +- src/components/SalarySearch/SalarySearch.tsx | 70 ++++++++++++++++++- .../SalarySearch/SalarySearchWrapper.tsx | 12 ++-- 8 files changed, 131 insertions(+), 16 deletions(-) diff --git a/src/app/api/salarys/actions.ts b/src/app/api/salarys/actions.ts index 7cd01d3..0bb1482 100644 --- a/src/app/api/salarys/actions.ts +++ b/src/app/api/salarys/actions.ts @@ -1,8 +1,9 @@ "use server" -import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { serverFetchBlob, serverFetchJson, serverFetchString } from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; import { cache } from "react"; +import { FileResponse } from "../reports/actions"; export interface comboProp { id: any; @@ -17,4 +18,31 @@ export const fetchSalaryCombo = cache(async () => { return serverFetchJson(`${BASE_API_URL}/salarys/combo`, { next: { tags: ["salary"] }, }); - }); \ No newline at end of file + }); + +export const importSalarys = async (data: FormData) => { + console.log("----------------",data) + const importSalarys = await serverFetchString( + `${BASE_API_URL}/salarys/import`, + { + method: "POST", + body: data, + // headers: { "Content-Type": "multipart/form-data" }, + }, + ); + + return importSalarys; +}; + +export const exportSalary = async () => { + const reportBlob = await serverFetchBlob( + `${BASE_API_URL}/salarys/export`, + { + method: "POST", + // body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); + + return reportBlob +}; \ No newline at end of file diff --git a/src/app/utils/fetchUtil.ts b/src/app/utils/fetchUtil.ts index a519164..c1f310b 100644 --- a/src/app/utils/fetchUtil.ts +++ b/src/app/utils/fetchUtil.ts @@ -27,7 +27,7 @@ export const serverFetch: typeof fetch = async (input, init) => { ? { Authorization: `Bearer ${accessToken}`, Accept: - "application/json, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/json, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, multipart/form-data", } : {}), }, @@ -71,6 +71,25 @@ export async function serverFetchWithNoContent(...args: FetchParams) { } } +export async function serverFetchString(...args: FetchParams) { + const response = await serverFetch(...args); + + if (response.ok) { + return response.text() as T; + } else { + switch (response.status) { + case 401: + signOutUser(); + default: + console.error(await response.text()); + throw new ServerFetchError( + "Something went wrong fetching data in server.", + response, + ); + } + } +} + export async function serverFetchBlob(...args: FetchParams) { const response = await serverFetch(...args); diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 72e8b35..65809dc 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -30,6 +30,7 @@ const pathToLabelMap: { [path: string]: string } = { "/settings/position/new": "Create Position", "/settings/salarys": "Salary", "/analytics/ProjectCashFlowReport": "Project Cash Flow Report", + "/settings/holiday": "Holiday", }; const Breadcrumb = () => { diff --git a/src/components/CompanyHoliday/CompanyHoliday.tsx b/src/components/CompanyHoliday/CompanyHoliday.tsx index 291a2df..521b172 100644 --- a/src/components/CompanyHoliday/CompanyHoliday.tsx +++ b/src/components/CompanyHoliday/CompanyHoliday.tsx @@ -200,8 +200,9 @@ const CompanyHoliday: React.FC = ({ holidays }) => { end: "dayGridMonth listMonth" }} buttonText={{ - month: t("Month view"), - list: t("List View") + month: t("Calender View"), + list: t("List View"), + today: t("Today") }} /> = ({ open, onClo diff --git a/src/components/DepartmentSearch/DepartmentSearch.tsx b/src/components/DepartmentSearch/DepartmentSearch.tsx index b881bb4..bbbd0a7 100644 --- a/src/components/DepartmentSearch/DepartmentSearch.tsx +++ b/src/components/DepartmentSearch/DepartmentSearch.tsx @@ -70,7 +70,7 @@ const DepartmentSearch: React.FC = ({ departments }) => { onClick: onDeleteClick, buttonIcon: , color: "error" - }, + }, ], [t, onProjectClick], ); diff --git a/src/components/SalarySearch/SalarySearch.tsx b/src/components/SalarySearch/SalarySearch.tsx index e7469fd..b21176f 100644 --- a/src/components/SalarySearch/SalarySearch.tsx +++ b/src/components/SalarySearch/SalarySearch.tsx @@ -7,6 +7,11 @@ import SearchResults, { Column } from "../SearchResults"; import EditNote from "@mui/icons-material/EditNote"; import { SalaryResult } from "@/app/api/salarys"; import { convertLocaleStringToNumber } from "@/app/utils/formatUtil" +import { Button, ButtonGroup, Stack } from "@mui/material"; +import FileDownloadIcon from '@mui/icons-material/FileDownload'; +import FileUploadIcon from '@mui/icons-material/FileUpload'; +import { exportSalary, importSalarys } from "@/app/api/salarys/actions"; +import { downloadFile } from "@/app/utils/commonUtil"; interface Props { salarys: SalaryResult[]; @@ -32,8 +37,47 @@ const SalarySearch: React.FC = ({ salarys }) => { setFilteredSalarys(salarys); }, [salarys]); - const onSalaryClick = useCallback((project: SalaryResult) => { - console.log(project); + const onSalaryClick = useCallback((salary: SalaryResult) => { + console.log(salary); + }, []); + + const handleImportClick = useCallback(async (event:any) => { + // console.log(event) + try { + + const file = event.target.files[0]; + + if (!file) { + console.log('No file selected'); + return; + } + + if (file.type !== 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') { + console.log('Invalid file format. Only XLSX files are allowed.'); + return; + } + + const formData = new FormData(); + formData.append('multipartFileList', file); + + const response = await importSalarys(formData); + + if (response === "OK") { + window.location.reload() + } + + } catch (err) { + console.log(err) + return false + } + }, []); + + const handleExportClick = useCallback(async (event:any) => { + // console.log(event); + const response = await exportSalary() + if (response) { + downloadFile(new Uint8Array(response.blobValue), response.filename!!) + } }, []); const columns = useMemo[]>( @@ -54,6 +98,28 @@ const SalarySearch: React.FC = ({ salarys }) => { return ( <> + + + + + + { diff --git a/src/components/SalarySearch/SalarySearchWrapper.tsx b/src/components/SalarySearch/SalarySearchWrapper.tsx index 3c910f9..83fe593 100644 --- a/src/components/SalarySearch/SalarySearchWrapper.tsx +++ b/src/components/SalarySearch/SalarySearchWrapper.tsx @@ -8,21 +8,21 @@ interface SubComponents { Loading: typeof SalarySearchLoading; } -function calculateHourlyRate(loweLimit: number, upperLimit: number, numOfWorkingDay: number, workingHour: number){ - const hourlyRate = (loweLimit + upperLimit)/2/numOfWorkingDay/workingHour - return hourlyRate.toLocaleString() -} +// function calculateHourlyRate(loweLimit: number, upperLimit: number, numOfWorkingDay: number, workingHour: number){ +// const hourlyRate = (loweLimit + upperLimit)/2/numOfWorkingDay/workingHour +// return hourlyRate.toLocaleString() +// } const SalarySearchWrapper: React.FC & SubComponents = async () => { const Salarys = await fetchSalarys(); // const Salarys:any[] = [] const salarysWithHourlyRate = Salarys.map((salary) => { - const hourlyRate = calculateHourlyRate(Number(salary.lowerLimit), Number(salary.upperLimit),22, 8) + // const hourlyRate = calculateHourlyRate(Number(salary.lowerLimit), Number(salary.upperLimit),22, 8) return { ...salary, upperLimit: salary.upperLimit.toLocaleString(), lowerLimit: salary.lowerLimit.toLocaleString(), - hourlyRate: hourlyRate + hourlyRate: salary.hourlyRate.toLocaleString(), } }) // console.log(salarysWithHourlyRate) From 61d7f400ef06191ce67b9f2e3863ecb5883fd635 Mon Sep 17 00:00:00 2001 From: Wayne Date: Tue, 7 May 2024 23:22:15 +0900 Subject: [PATCH 22/37] Update timesheet entry --- src/app/(main)/home/page.tsx | 6 +- src/app/api/projects/index.ts | 14 +- src/app/api/timesheets/actions.ts | 8 +- src/app/api/timesheets/utils.ts | 44 +++++ src/components/LeaveTable/LeaveEntryTable.tsx | 17 +- src/components/LeaveTable/LeaveTable.tsx | 14 +- .../TimesheetModal/TimesheetModal.tsx | 9 +- .../TimesheetTable/EntryInputTable.tsx | 187 ++++++++++-------- .../TimesheetTable/ProjectSelect.tsx | 89 +++++++++ .../TimesheetTable/TaskGroupSelect.tsx | 69 +++++++ src/components/TimesheetTable/TaskSelect.tsx | 72 +++++++ .../TimesheetTable/TimesheetTable.tsx | 29 ++- .../UserWorkspacePage/UserWorkspacePage.tsx | 5 +- .../UserWorkspaceWrapper.tsx | 20 +- 14 files changed, 464 insertions(+), 119 deletions(-) create mode 100644 src/app/api/timesheets/utils.ts create mode 100644 src/components/TimesheetTable/ProjectSelect.tsx create mode 100644 src/components/TimesheetTable/TaskGroupSelect.tsx create mode 100644 src/components/TimesheetTable/TaskSelect.tsx diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx index 2766580..bd0c08a 100644 --- a/src/app/(main)/home/page.tsx +++ b/src/app/(main)/home/page.tsx @@ -8,7 +8,10 @@ import { } from "@/app/api/timesheets"; import { authOptions } from "@/config/authConfig"; import { getServerSession } from "next-auth"; -import { fetchAssignedProjects } from "@/app/api/projects"; +import { + fetchAssignedProjects, + fetchProjectWithTasks, +} from "@/app/api/projects"; export const metadata: Metadata = { title: "User Workspace", @@ -23,6 +26,7 @@ const Home: React.FC = async () => { fetchAssignedProjects(username); fetchLeaves(username); fetchLeaveTypes(); + fetchProjectWithTasks(); return ( diff --git a/src/app/api/projects/index.ts b/src/app/api/projects/index.ts index 9cc4f01..30bd385 100644 --- a/src/app/api/projects/index.ts +++ b/src/app/api/projects/index.ts @@ -49,7 +49,7 @@ export interface WorkNature { name: string; } -export interface AssignedProject { +export interface ProjectWithTasks { id: number; code: string; name: string; @@ -60,6 +60,9 @@ export interface AssignedProject { endDate?: string; }; }; +} + +export interface AssignedProject extends ProjectWithTasks { // Manhour info hoursSpent: number; hoursSpentOther: number; @@ -147,6 +150,15 @@ export const fetchAssignedProjects = cache(async (username: string) => { ); }); +export const fetchProjectWithTasks = cache(async () => { + return serverFetchJson( + `${BASE_API_URL}/projects/allProjectWithTasks`, + { + next: { tags: ["allProjectWithTasks"] }, + }, + ); +}); + export const fetchProjectDetails = cache(async (projectId: string) => { return serverFetchJson( `${BASE_API_URL}/projects/projectDetails/${projectId}`, diff --git a/src/app/api/timesheets/actions.ts b/src/app/api/timesheets/actions.ts index 97b03a9..c836e6e 100644 --- a/src/app/api/timesheets/actions.ts +++ b/src/app/api/timesheets/actions.ts @@ -8,10 +8,11 @@ import { revalidateTag } from "next/cache"; export interface TimeEntry { id: number; - projectId: ProjectResult["id"]; - taskGroupId: TaskGroup["id"]; - taskId: Task["id"]; + projectId?: ProjectResult["id"]; + taskGroupId?: TaskGroup["id"]; + taskId?: Task["id"]; inputHours: number; + remark?: string; } export interface RecordTimesheetInput { @@ -22,6 +23,7 @@ export interface LeaveEntry { id: number; inputHours: number; leaveTypeId: number; + remark?: string; } export interface RecordLeaveInput { diff --git a/src/app/api/timesheets/utils.ts b/src/app/api/timesheets/utils.ts new file mode 100644 index 0000000..68ee075 --- /dev/null +++ b/src/app/api/timesheets/utils.ts @@ -0,0 +1,44 @@ +import { LeaveEntry, TimeEntry } from "./actions"; + +/** + * @param entry - the time entry + * @returns the field where there is an error, or an empty string if there is none + */ +export const isValidTimeEntry = (entry: Partial): string => { + // Test for errors + let error: keyof TimeEntry | "" = ""; + + // If there is a project id, there should also be taskGroupId, taskId, inputHours + if (entry.projectId) { + if (!entry.taskGroupId) { + error = "taskGroupId"; + } else if (!entry.taskId) { + error = "taskId"; + } else if (!entry.inputHours || !(entry.inputHours >= 0)) { + error = "inputHours"; + } + } else { + if (!entry.inputHours || !(entry.inputHours >= 0)) { + error = "inputHours"; + } else if (!entry.remark) { + error = "remark"; + } + } + + return error; +}; + +export const isValidLeaveEntry = (entry: Partial): string => { + // Test for errrors + let error: keyof LeaveEntry | "" = ""; + if (!entry.leaveTypeId) { + error = "leaveTypeId"; + } else if (!entry.inputHours || !(entry.inputHours >= 0)) { + error = "inputHours"; + } + + return error; +}; + +export const LEAVE_DAILY_MAX_HOURS = 8; +export const TIMESHEET_DAILY_MAX_HOURS = 20; diff --git a/src/components/LeaveTable/LeaveEntryTable.tsx b/src/components/LeaveTable/LeaveEntryTable.tsx index 9e9170d..dd8fda7 100644 --- a/src/components/LeaveTable/LeaveEntryTable.tsx +++ b/src/components/LeaveTable/LeaveEntryTable.tsx @@ -21,6 +21,7 @@ import { manhourFormatter } from "@/app/utils/formatUtil"; import dayjs from "dayjs"; import isBetween from "dayjs/plugin/isBetween"; import { LeaveType } from "@/app/api/timesheets"; +import { isValidLeaveEntry } from "@/app/api/timesheets/utils"; dayjs.extend(isBetween); @@ -63,13 +64,7 @@ const EntryInputTable: React.FC = ({ day, leaveTypes }) => { "", ) as LeaveEntryRow; - // Test for errrors - let error: keyof LeaveEntry | "" = ""; - if (!row.leaveTypeId) { - error = "leaveTypeId"; - } else if (!row.inputHours || !(row.inputHours >= 0)) { - error = "inputHours"; - } + const error = isValidLeaveEntry(row); apiRef.current.updateRows([{ id, _error: error }]); return !error; @@ -182,6 +177,13 @@ const EntryInputTable: React.FC = ({ day, leaveTypes }) => { return manhourFormatter.format(params.value); }, }, + { + field: "remark", + headerName: t("Remark"), + sortable: false, + flex: 1, + editable: true, + }, ], [t, rowModesModel, handleDelete, handleSave, handleCancel, leaveTypes], ); @@ -197,6 +199,7 @@ const EntryInputTable: React.FC = ({ day, leaveTypes }) => { id: e.id!, inputHours: e.inputHours!, leaveTypeId: e.leaveTypeId!, + remark: e.remark, })), ]); }, [getValues, entries, setValue, day]); diff --git a/src/components/LeaveTable/LeaveTable.tsx b/src/components/LeaveTable/LeaveTable.tsx index 5d0a003..12097c5 100644 --- a/src/components/LeaveTable/LeaveTable.tsx +++ b/src/components/LeaveTable/LeaveTable.tsx @@ -19,13 +19,12 @@ import { useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; import LeaveEntryTable from "./LeaveEntryTable"; import { LeaveType } from "@/app/api/timesheets"; +import { LEAVE_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils"; interface Props { leaveTypes: LeaveType[]; } -const MAX_HOURS = 8; - const LeaveTable: React.FC = ({ leaveTypes }) => { const { t } = useTranslation("home"); @@ -94,17 +93,22 @@ const DayRow: React.FC<{ {shortDateFormatter(language).format(dayJsObj.toDate())} MAX_HOURS ? "error.main" : undefined }} + sx={{ + color: + totalHours > LEAVE_DAILY_MAX_HOURS ? "error.main" : undefined, + }} > {manhourFormatter.format(totalHours)} - {totalHours > MAX_HOURS && ( + {totalHours > LEAVE_DAILY_MAX_HOURS && ( - {t("(the daily total hours cannot be more than 8.)")} + {t("(the daily total hours cannot be more than {{hours}})", { + hours: LEAVE_DAILY_MAX_HOURS, + })} )} diff --git a/src/components/TimesheetModal/TimesheetModal.tsx b/src/components/TimesheetModal/TimesheetModal.tsx index e8e5061..3d55878 100644 --- a/src/components/TimesheetModal/TimesheetModal.tsx +++ b/src/components/TimesheetModal/TimesheetModal.tsx @@ -19,11 +19,12 @@ import { } from "@/app/api/timesheets/actions"; import dayjs from "dayjs"; import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; -import { AssignedProject } from "@/app/api/projects"; +import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; interface Props { isOpen: boolean; onClose: () => void; + allProjects: ProjectWithTasks[]; assignedProjects: AssignedProject[]; username: string; defaultTimesheets?: RecordTimesheetInput; @@ -42,6 +43,7 @@ const modalSx: SxProps = { const TimesheetModal: React.FC = ({ isOpen, onClose, + allProjects, assignedProjects, username, defaultTimesheets, @@ -106,7 +108,10 @@ const TimesheetModal: React.FC = ({ marginBlock: 4, }} > - +
+ + + + +
+ + ); +}; +export default EditUser; diff --git a/src/components/EditUser/EditUserLoading.tsx b/src/components/EditUser/EditUserLoading.tsx new file mode 100644 index 0000000..971c9e4 --- /dev/null +++ b/src/components/EditUser/EditUserLoading.tsx @@ -0,0 +1,40 @@ +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Skeleton from "@mui/material/Skeleton"; +import Stack from "@mui/material/Stack"; +import React from "react"; + +// Can make this nicer +export const EditUserLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + EditUser + + + + + + + + + + + ); +}; + +export default EditUserLoading; diff --git a/src/components/EditUser/EditUserWrapper.tsx b/src/components/EditUser/EditUserWrapper.tsx new file mode 100644 index 0000000..eaf7aa5 --- /dev/null +++ b/src/components/EditUser/EditUserWrapper.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import EditUser from "./EditUser"; +import EditUserLoading from "./EditUserLoading"; +// import { fetchTeam, fetchTeamLeads } from "@/app/api/Team"; +import { useSearchParams } from "next/navigation"; +import { fetchTeam, fetchTeamDetail } from "@/app/api/team"; +import { fetchStaff } from "@/app/api/staff"; +import { fetchUser } from "@/app/api/user"; + +interface SubComponents { + Loading: typeof EditUserLoading; +} + +const EditUserWrapper: React.FC & SubComponents = async () => { + // const users = await fetchUser() + // console.log(users) + + return ; +}; + +EditUserWrapper.Loading = EditUserLoading; + +export default EditUserWrapper; diff --git a/src/components/EditUser/UserDetail.tsx b/src/components/EditUser/UserDetail.tsx new file mode 100644 index 0000000..ed7ad2a --- /dev/null +++ b/src/components/EditUser/UserDetail.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { UserResult } from "@/app/api/user"; +import { + Card, + CardContent, + Grid, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +interface Props { + data: UserResult +} + + +const UserDetail: React.FC = ({ + data +}) => { + const { t } = useTranslation(); + const { + register, + formState: { errors }, + control, + } = useFormContext(); + + return ( + + + + {t("User Detail")} + + + + + + {/* + + + + + + + + + + + + + + + + + + + + + + + */} + + + + ); +}; + +export default UserDetail; diff --git a/src/components/EditUser/index.ts b/src/components/EditUser/index.ts new file mode 100644 index 0000000..c12dc8e --- /dev/null +++ b/src/components/EditUser/index.ts @@ -0,0 +1 @@ +export { default } from "./EditUserWrapper"; diff --git a/src/components/EditUserGroup/EditUserGroupWrapper.tsx b/src/components/EditUserGroup/EditUserGroupWrapper.tsx index 84f7501..dffd5e7 100644 --- a/src/components/EditUserGroup/EditUserGroupWrapper.tsx +++ b/src/components/EditUserGroup/EditUserGroupWrapper.tsx @@ -14,14 +14,11 @@ const EditUserGroupWrapper: React.FC & SubComponents = async () => { const [ groups, - // auths, users, ] = await Promise.all([ fetchGroup(), - // fetchAuth(), fetchUser(), ]); - console.log(users) return ; }; diff --git a/src/components/StaffSearch/StaffSearch.tsx b/src/components/StaffSearch/StaffSearch.tsx index 4111d14..5bfb017 100644 --- a/src/components/StaffSearch/StaffSearch.tsx +++ b/src/components/StaffSearch/StaffSearch.tsx @@ -9,6 +9,7 @@ import DeleteIcon from "@mui/icons-material/Delete"; import { deleteStaff } from "@/app/api/staff/actions"; import { useRouter } from "next/navigation"; import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; +import Person from '@mui/icons-material/Person'; interface Props { staff: StaffResult[]; @@ -65,6 +66,14 @@ const StaffSearch: React.FC = ({ staff }) => { [router, t] ); + const onUserClick = useCallback( + (staff: StaffResult) => { + console.log(staff); + router.push(`/settings/staff/user?id=${staff.id}`); + }, + [router, t] + ); + const deleteClick = useCallback((staff: StaffResult) => { deleteDialog(async () => { await deleteStaff(staff.id); @@ -81,6 +90,12 @@ const StaffSearch: React.FC = ({ staff }) => { onClick: onStaffClick, buttonIcon: , }, + { + name: "id", + label: t("Actions"), + onClick: onUserClick, + buttonIcon: , + }, { name: "team", label: t("Team") }, { name: "name", label: t("Staff Name") }, { name: "staffId", label: t("Staff ID") }, diff --git a/src/middleware.ts b/src/middleware.ts index 078ace6..a793a79 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -71,9 +71,6 @@ export default async function middleware( return response; } - // const session = await getServerSession(authOptions); - // console.log(session); - let abilities: string[] = [] if (token) { abilities = (token.abilities as ability[]).map((item: ability) => item.actionSubjectCombo); @@ -93,6 +90,9 @@ export default async function middleware( if (req.nextUrl.pathname.startsWith('/settings/user')) { isAuth = [MAINTAIN_USER, VIEW_USER].some((ability) => abilities.includes(ability)); } + if (req.nextUrl.pathname.startsWith('/settings/staff/user')) { + isAuth = [MAINTAIN_USER, VIEW_USER].some((ability) => abilities.includes(ability)); + } if (req.nextUrl.pathname.startsWith('/analytics')) { isAuth = [GENERATE_REPORTS].some((ability) => abilities.includes(ability)); } @@ -104,40 +104,6 @@ export default async function middleware( } }); - - // for (const obj of abilities) { - // switch (obj.actionSubjectCombo.toLowerCase()) { - // case "maintain_user": - // // appendRoutes(settings) - // break; - // case "maintain_group": - // // appendRoutes("/testing-maintain_user") - // break; - // case "view_user": - // // appendRoutes("/testing-maintain_user") - // break; - // case "view_group": - // // appendRoutes("/testing-maintain_user") - // break; - // } - // } - -// console.log("TESTING_ROUTES: ") -// console.log(TESTING_ROUTES) - -// TESTING_ROUTES.some((route) => { -// if (req.nextUrl.pathname.startsWith(route)) { -// console.log("////////////////start//////////////// ") -// console.log("TESTING_ROUTES:") -// console.log("route:") -// console.log(route) -// console.log("pathname:") -// console.log(req.nextUrl.pathname) -// console.log("////////////////end////////////////") -// } -// return (req.nextUrl.pathname.startsWith(route)) -// }) - // Matcher for using the auth middleware return PRIVATE_ROUTES.some((route) => req.nextUrl.pathname.startsWith(route)) ? await authMiddleware(req, event) // Let auth middleware handle response From ccbb8942bc56b3bede88ec2a1192b99fa75a6853 Mon Sep 17 00:00:00 2001 From: "cyril.tsui" Date: Wed, 8 May 2024 14:21:01 +0800 Subject: [PATCH 24/37] update edit project --- .../CreateProject/CreateProject.tsx | 80 +++++++++++++++---- .../CreateProject/ProjectTotalFee.tsx | 18 ++++- .../CreateProject/ResourceAllocation.tsx | 63 ++++++++++++--- .../NavigationContent/NavigationContent.tsx | 2 +- .../TableCellEdit/TableCellEdit.tsx | 11 ++- 5 files changed, 141 insertions(+), 33 deletions(-) diff --git a/src/components/CreateProject/CreateProject.tsx b/src/components/CreateProject/CreateProject.tsx index 5511464..4166be0 100644 --- a/src/components/CreateProject/CreateProject.tsx +++ b/src/components/CreateProject/CreateProject.tsx @@ -78,6 +78,14 @@ const hasErrorsInTab = ( return ( errors.projectName || errors.projectCode || errors.projectDescription ); + case 2: + return ( + errors.totalManhour || errors.manhourPercentageByGrade || errors.taskGroups + ); + case 3: + return ( + errors.milestones + ) default: false; } @@ -132,7 +140,31 @@ const CreateProject: React.FC = ({ const onSubmit = useCallback>( async (data, event) => { try { - console.log("first"); + console.log(data); + + // detect errors + let hasErrors = false + if (data.totalManhour === null || data.totalManhour <= 0) { + formProps.setError("totalManhour", { message: "totalManhour value is not valid", type: "required" }) + hasErrors = true + } + + const manhourPercentageByGradeKeys = Object.keys(data.manhourPercentageByGrade) + if (manhourPercentageByGradeKeys.filter(k => data.manhourPercentageByGrade[k as any] < 0).length > 0 || + manhourPercentageByGradeKeys.reduce((acc, value) => acc + data.manhourPercentageByGrade[value as any], 0) !== 1) { + formProps.setError("manhourPercentageByGrade", { message: "manhourPercentageByGrade value is not valid", type: "invalid" }) + hasErrors = true + } + + const taskGroupKeys = Object.keys(data.taskGroups) + if (taskGroupKeys.filter(k => data.taskGroups[k as any].percentAllocation < 0).length > 0 || + taskGroupKeys.reduce((acc, value) => acc + data.taskGroups[value as any].percentAllocation, 0) !== 1) { + formProps.setError("taskGroups", {message: "Task Groups value is not invalid", type: "invalid"}) + hasErrors = true + } + + if (hasErrors) return false + // save project setServerError(""); let title = t("Do you want to submit?"); @@ -185,6 +217,7 @@ const CreateProject: React.FC = ({ const onSubmitError = useCallback>( (errors) => { + console.log(errors) // Set the tab so that the focus will go there if ( errors.projectName || @@ -192,6 +225,10 @@ const CreateProject: React.FC = ({ errors.projectCode ) { setTabIndex(0); + } else if (errors.totalManhour || errors.manhourPercentageByGrade || errors.taskGroups) { + setTabIndex(2) + } else if (errors.milestones) { + setTabIndex(3) } }, [], @@ -208,8 +245,8 @@ const CreateProject: React.FC = ({ // manhourPercentageByGrade should have a sensible default manhourPercentageByGrade: isEmpty(defaultInputs?.manhourPercentageByGrade) ? grades.reduce((acc, grade) => { - return { ...acc, [grade.id]: 1 / grades.length }; - }, {}) + return { ...acc, [grade.id]: 1 / grades.length }; + }, {}) : defaultInputs?.manhourPercentageByGrade, }, }); @@ -253,15 +290,15 @@ const CreateProject: React.FC = ({ formProps.getValues("projectActualStart") && formProps.getValues("projectActualEnd") ) && ( - - )} + + )} )} = ({ > @@ -278,12 +316,26 @@ const CreateProject: React.FC = ({ } iconPosition="end" /> - + + ) : undefined + } iconPosition="end" /> - + ) + : undefined} + iconPosition="end" /> { = ({ taskGroups }) => { const { t } = useTranslation(); - const { watch } = useFormContext(); + const { watch, setError, clearErrors } = useFormContext(); const milestones = watch("milestones"); const expectedTotalFee = watch("expectedProjectFee"); let projectTotal = 0; + useEffect(() => { + console.log(Object.keys(milestones).reduce((acc, key) => acc + milestones[parseInt(key)].payments.reduce((acc2, value) => acc2 + value.amount, 0), 0)) + if (Object.keys(milestones).reduce((acc, key) => acc + milestones[parseInt(key)].payments.reduce((acc2, value) => acc2 + value.amount, 0), 0) !== expectedTotalFee) { + setError("milestones", {message: "project total is not valid", type: "invalid"}) + } else { + clearErrors("milestones") + } + }, [milestones]) + return ( {taskGroups.map((group, index) => { const payments = milestones[group.id]?.payments || []; const paymentTotal = payments.reduce((acc, p) => acc + p.amount, 0); + projectTotal += paymentTotal; return ( @@ -41,9 +51,9 @@ const ProjectTotalFee: React.FC = ({ taskGroups }) => { {t("Project Total Fee")} {moneyFormatter.format(projectTotal)} - {projectTotal > expectedTotalFee && ( + {projectTotal !== expectedTotalFee && ( - {t("Project total fee is larger than the expected total fee!")} + {t("Project total fee should be same as the expected total fee!")} )} diff --git a/src/components/CreateProject/ResourceAllocation.tsx b/src/components/CreateProject/ResourceAllocation.tsx index c593580..c1e2706 100644 --- a/src/components/CreateProject/ResourceAllocation.tsx +++ b/src/components/CreateProject/ResourceAllocation.tsx @@ -45,9 +45,20 @@ const leftRightBorderCellSx: SxProps = { borderColor: "divider", }; +const errorCellSx: SxProps = { + outline: "1px solid", + outlineColor: "error.main", + + // borderLeft: "1px solid", + // borderRight: "1px solid", + // borderTop: "1px solid", + // borderBottom: "1px solid", + // borderColor: 'error.main' +} + const ResourceAllocationByGrade: React.FC = ({ grades }) => { const { t } = useTranslation(); - const { watch, register, setValue } = useFormContext(); + const { watch, register, setValue, formState: { errors }, setError, clearErrors } = useFormContext(); const manhourPercentageByGrade = watch("manhourPercentageByGrade"); const totalManhour = watch("totalManhour"); @@ -59,10 +70,20 @@ const ResourceAllocationByGrade: React.FC = ({ grades }) => { const makeUpdatePercentage = useCallback( (gradeId: Grade["id"]) => (percentage?: number) => { if (percentage !== undefined) { - setValue("manhourPercentageByGrade", { + const updatedManhourPercentageByGrade = { ...manhourPercentageByGrade, [gradeId]: percentage, - }); + } + setValue("manhourPercentageByGrade", updatedManhourPercentageByGrade); + + const keys = Object.keys(updatedManhourPercentageByGrade) + if (keys.filter(k => updatedManhourPercentageByGrade[k as any] < 0).length > 0 || + keys.reduce((acc, value) => acc + updatedManhourPercentageByGrade[value as any], 0) !== 1) { + setError("manhourPercentageByGrade", {message: "manhourPercentageByGrade value is not valid", type: "invalid"}) + } else { + clearErrors("manhourPercentageByGrade") + } + } }, [manhourPercentageByGrade, setValue], @@ -79,7 +100,10 @@ const ResourceAllocationByGrade: React.FC = ({ grades }) => { type="number" {...register("totalManhour", { valueAsNumber: true, + required: "totalManhour code required!", + min: 1, })} + error={Boolean(errors.totalManhour)} /> ({ @@ -115,9 +139,10 @@ const ResourceAllocationByGrade: React.FC = ({ grades }) => { convertValue={(inputValue) => Number(inputValue)} cellSx={{ backgroundColor: "primary.lightest" }} inputSx={{ width: "3rem" }} + error={manhourPercentageByGrade[column.id] < 0} /> ))} - + {percentFormatter.format(totalPercentage)} @@ -144,7 +169,7 @@ const ResourceAllocationByGrade: React.FC = ({ grades }) => { const ResourceAllocationByStage: React.FC = ({ grades, allTasks }) => { const { t } = useTranslation(); - const { watch, setValue } = useFormContext(); + const { watch, setValue, clearErrors, setError } = useFormContext(); const currentTaskGroups = watch("taskGroups"); const taskGroups = useMemo( @@ -167,13 +192,22 @@ const ResourceAllocationByStage: React.FC = ({ grades, allTasks }) => { const makeUpdatePercentage = useCallback( (taskGroupId: TaskGroup["id"]) => (percentage?: number) => { if (percentage !== undefined) { - setValue("taskGroups", { + const updatedTaskGroups = { ...currentTaskGroups, [taskGroupId]: { ...currentTaskGroups[taskGroupId], percentAllocation: percentage, }, - }); + } + setValue("taskGroups", updatedTaskGroups); + + const keys = Object.keys(updatedTaskGroups) + if (keys.filter(k => updatedTaskGroups[k as any].percentAllocation < 0).length > 0 || + keys.reduce((acc, value) => acc + updatedTaskGroups[value as any].percentAllocation, 0) !== 1) { + setError("taskGroups", {message: "Task Groups value is not invalid", type: "invalid"}) + } else { + clearErrors("taskGroups") + } } }, [currentTaskGroups, setValue], @@ -219,8 +253,11 @@ const ResourceAllocationByStage: React.FC = ({ grades, allTasks }) => { renderValue={(val) => percentFormatter.format(val)} onChange={makeUpdatePercentage(tg.id)} convertValue={(inputValue) => Number(inputValue)} - cellSx={{ backgroundColor: "primary.lightest" }} + cellSx={{ + backgroundColor: "primary.lightest", + }} inputSx={{ width: "3rem" }} + error={currentTaskGroups[tg.id].percentAllocation < 0} /> {manhourFormatter.format( @@ -248,7 +285,11 @@ const ResourceAllocationByStage: React.FC = ({ grades, allTasks }) => { 0, )} - + acc + tg.percentAllocation, 0,) === 1 && leftBorderCellSx), + ...(Object.values(currentTaskGroups).reduce((acc, tg) => acc + tg.percentAllocation, 0,) !== 1 && errorCellSx) + }} + > {percentFormatter.format( Object.values(currentTaskGroups).reduce( (acc, tg) => acc + tg.percentAllocation, @@ -269,8 +310,8 @@ const ResourceAllocationByStage: React.FC = ({ grades, allTasks }) => { (acc, tg) => acc + tg.percentAllocation * - totalManhour * - manhourPercentageByGrade[column.id], + totalManhour * + manhourPercentageByGrade[column.id], 0, ); return ( diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 226482e..36beaed 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -127,7 +127,7 @@ const NavigationContent: React.FC = ({ abilities }) => { {icon: , label:"Project Claims Report", path: "/analytics/ProjectClaimsReport"}, {icon: , label:"Project P&L Report", path: "/analytics/ProjectPLReport"}, {icon: , label:"Financial Status Report", path: "/analytics/FinancialStatusReport"}, - {icon: , label:"EX02 - Project Cash Flow Report", path: "/analytics/EX02ProjectCashFlowReport"}, + {icon: , label:"Project Cash Flow Report", path: "/analytics/ProjectCashFlowReport"}, ], }, { diff --git a/src/components/TableCellEdit/TableCellEdit.tsx b/src/components/TableCellEdit/TableCellEdit.tsx index 0a366fc..6d88704 100644 --- a/src/components/TableCellEdit/TableCellEdit.tsx +++ b/src/components/TableCellEdit/TableCellEdit.tsx @@ -6,6 +6,7 @@ import React, { useState, } from "react"; import { Box, Input, SxProps, TableCell } from "@mui/material"; +import palette from "@/theme/devias-material-kit/palette"; interface Props { value: T; @@ -14,6 +15,7 @@ interface Props { convertValue: (inputValue: string) => T; cellSx?: SxProps; inputSx?: SxProps; + error?: Boolean; } const TableCellEdit = ({ @@ -23,8 +25,10 @@ const TableCellEdit = ({ onChange, cellSx, inputSx, + error, }: Props) => { const [editMode, setEditMode] = useState(false); + // const [afterEdit, setAfterEdit] = useState(false); const [input, setInput] = useState(""); const inputRef = useRef(null); @@ -40,6 +44,7 @@ const TableCellEdit = ({ const onBlur = useCallback(() => { setEditMode(false); + // setAfterEdit(true) onChange(convertValue(input)); setInput(""); }, [convertValue, input, onChange]); @@ -53,8 +58,8 @@ const TableCellEdit = ({ return ( @@ -76,7 +81,7 @@ const TableCellEdit = ({ onBlur={onBlur} type={typeof value === "number" ? "number" : "text"} /> - + {renderValue(value)} From 07c7623e12b9660fb357592663ffd92ed58abc16 Mon Sep 17 00:00:00 2001 From: "MSI\\derek" Date: Wed, 8 May 2024 15:04:57 +0800 Subject: [PATCH 25/37] change password fn --- .../(main)/settings/changepassword/page.tsx | 53 +++++++ src/app/(main)/settings/staff/user/page.tsx | 22 +++ .../ChangePassword/ChangePassword.tsx | 107 +++++++++++++ .../ChangePassword/ChangePasswordForm.tsx | 144 ++++++++++++++++++ .../ChangePassword/ChangePasswordLoading.tsx | 40 +++++ .../ChangePassword/ChangePasswordWrapper.tsx | 20 +++ src/components/ChangePassword/index.ts | 1 + 7 files changed, 387 insertions(+) create mode 100644 src/app/(main)/settings/changepassword/page.tsx create mode 100644 src/app/(main)/settings/staff/user/page.tsx create mode 100644 src/components/ChangePassword/ChangePassword.tsx create mode 100644 src/components/ChangePassword/ChangePasswordForm.tsx create mode 100644 src/components/ChangePassword/ChangePasswordLoading.tsx create mode 100644 src/components/ChangePassword/ChangePasswordWrapper.tsx create mode 100644 src/components/ChangePassword/index.ts diff --git a/src/app/(main)/settings/changepassword/page.tsx b/src/app/(main)/settings/changepassword/page.tsx new file mode 100644 index 0000000..b6b9a41 --- /dev/null +++ b/src/app/(main)/settings/changepassword/page.tsx @@ -0,0 +1,53 @@ +import { preloadClaims } from "@/app/api/claims"; +import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; +import ChangePassword from "@/components/ChangePassword"; +import StaffSearch from "@/components/StaffSearch"; +import TeamSearch from "@/components/TeamSearch"; +import UserGroupSearch from "@/components/UserGroupSearch"; +import UserSearch from "@/components/UserSearch"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import Add from "@mui/icons-material/Add"; +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 { Suspense } from "react"; + + +export const metadata: Metadata = { + title: "Change Password", + }; + + + const ChangePasswordPage: React.FC = async () => { + const { t } = await getServerI18n("User Group"); + // preloadTeamLeads(); + // preloadStaff(); + return ( + <> + + + {t("Change Password")} + + + {/* + }> + + + */} + + }> + + + + + ); + }; + + export default ChangePasswordPage; \ No newline at end of file diff --git a/src/app/(main)/settings/staff/user/page.tsx b/src/app/(main)/settings/staff/user/page.tsx new file mode 100644 index 0000000..11ef8f9 --- /dev/null +++ b/src/app/(main)/settings/staff/user/page.tsx @@ -0,0 +1,22 @@ +import { Edit } from "@mui/icons-material"; +import { Metadata } from "next"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import EditUser from "@/components/EditUser"; +import { Typography } from "@mui/material"; +import { Suspense } from "react"; + +const User: React.FC = async () => { + const { t } = await getServerI18n("user"); + + return ( + <> + {t("Edit User")} + + }> + + + + + ); +}; +export default User; diff --git a/src/components/ChangePassword/ChangePassword.tsx b/src/components/ChangePassword/ChangePassword.tsx new file mode 100644 index 0000000..33e19ff --- /dev/null +++ b/src/components/ChangePassword/ChangePassword.tsx @@ -0,0 +1,107 @@ +"use client"; +import { PasswordInputs, changePassword } from "@/app/api/user/actions"; +import { Grid } from "@mui/material"; +import { useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; +import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { Button, Stack, Tab, Tabs, TabsProps, Typography } from "@mui/material"; +import { Check, Close, Error } from "@mui/icons-material"; +import ChagnePasswordForm from "./ChangePasswordForm"; +import { ServerFetchError } from "@/app/utils/fetchUtil"; + +// interface Props { +// // auth?: auth[] +// // users?: UserResult[] +// } + +const ChangePassword: React.FC = () => { + const formProps = useForm(); + const [serverError, setServerError] = useState(""); + const router = useRouter(); + // const [tabIndex, setTabIndex] = useState(0); + const { t } = useTranslation(); + + const onSubmit = useCallback>( + async (data) => { + try { + let haveError = false; + // Minimum eight characters, at least one uppercase letter, one lowercase letter, one number and one special character: + let regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/ + + if (data.newPassword.length < 8 || data.newPassword.length > 20) { + haveError = true + formProps.setError("newPassword", { message: "The password requires 8-20 characters", type: "required" }) + } + if (!regex.test(data.newPassword)) { + haveError = true + formProps.setError("newPassword", { message: "A combination of uppercase letters, lowercase letters, numbers, and symbols is required.", type: "required" }) + } + if (data.password == data.newPassword) { + haveError = true + formProps.setError("newPassword", { message: "The new password cannot be the same as the old password", type: "required" }) + } + if (data.newPassword != data.newPasswordCheck) { + haveError = true + formProps.setError("newPassword", { message: "The new password has to be the same as the new password", type: "required" }) + formProps.setError("newPasswordCheck", { message: "The new password has to be the same as the new password", type: "required" }) + } + if (haveError) { + return + } + const postData = { + password: data.password, + newPassword: data.newPassword + } + // await changePassword(postData) + // router.replace("/home") + } catch (e) { + console.log(e) + setServerError(t("An error has occurred. Please try again later.")); + } + }, + [router] + ); + + const handleCancel = () => { + router.push(`/home`); + }; + + const onSubmitError = useCallback>( + (errors) => { + console.log(errors); + }, + [] + ); + + return ( + + + + + + + + + + ); +}; + +export default ChangePassword; diff --git a/src/components/ChangePassword/ChangePasswordForm.tsx b/src/components/ChangePassword/ChangePasswordForm.tsx new file mode 100644 index 0000000..19e2a29 --- /dev/null +++ b/src/components/ChangePassword/ChangePasswordForm.tsx @@ -0,0 +1,144 @@ +"use client"; +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Grid from "@mui/material/Grid"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useCallback, useState } from "react"; +import { PasswordInputs } from "@/app/api/user/actions"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { IconButton, InputAdornment } from "@mui/material"; + +const ChagnePasswordForm: React.FC = () => { + const { t } = useTranslation(); + + const [showNewPassword, setShowNewPassword] = useState(false); + const handleClickShowNewPassword = () => setShowNewPassword(!showNewPassword); + const handleMouseDownNewPassword = () => setShowNewPassword(!showNewPassword); + + const [showPassword, setShowPassword] = useState(false); + const handleClickShowPassword = () => setShowPassword(!showPassword); + const handleMouseDownPassword = () => setShowPassword(!showPassword); + + const { + register, + formState: { errors, defaultValues }, + control, + reset, + resetField, + setValue, + } = useFormContext(); + + // const resetGroup = useCallback(() => { + // console.log(defaultValues); + // if (defaultValues !== undefined) { + // resetField("description"); + // } + // }, [defaultValues]); + + return ( + + + + + {t("Group Info")} + + + + + + {showPassword ? : } + + + ) + }} + {...register("password", { + required: true, + })} + error={Boolean(errors.password)} + helperText={ + Boolean(errors.password) && + (errors.password?.message + ? t(errors.password.message) + : t("Please input correct password")) + } + /> + + + + + + {showNewPassword ? : } + + + ) + }} + {...register("newPassword")} + error={Boolean(errors.newPassword)} + helperText={ + Boolean(errors.newPassword) && + (errors.newPassword?.message + ? t(errors.newPassword.message) + : t("Please input correct newPassword")) + } + /> + + + + + {showNewPassword ? : } + + + ) + }} + {...register("newPasswordCheck")} + error={Boolean(errors.newPassword)} + helperText={ + Boolean(errors.newPassword) && + (errors.newPassword?.message + ? t(errors.newPassword.message) + : t("Please input correct newPassword")) + } + /> + + + + + + ); +}; +export default ChagnePasswordForm; diff --git a/src/components/ChangePassword/ChangePasswordLoading.tsx b/src/components/ChangePassword/ChangePasswordLoading.tsx new file mode 100644 index 0000000..30f29fd --- /dev/null +++ b/src/components/ChangePassword/ChangePasswordLoading.tsx @@ -0,0 +1,40 @@ +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Skeleton from "@mui/material/Skeleton"; +import Stack from "@mui/material/Stack"; +import React from "react"; + +// Can make this nicer +export const ChangePasswordLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + Change Password + + + + + + + + + + + ); +}; + +export default ChangePasswordLoading; diff --git a/src/components/ChangePassword/ChangePasswordWrapper.tsx b/src/components/ChangePassword/ChangePasswordWrapper.tsx new file mode 100644 index 0000000..30acb9d --- /dev/null +++ b/src/components/ChangePassword/ChangePasswordWrapper.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import ChangePasswordLoading from "./ChangePasswordLoading"; +import ChangePassword from "./ChangePassword"; + +interface SubComponents { + Loading: typeof ChangePasswordLoading; +} + +const ChangePasswordWrapper: React.FC & SubComponents = async () => { + // const records = await fetchAuth() + // const users = await fetchUser() + // console.log(users) + // const auth = records.records as auth[] + + return ; +}; + +ChangePasswordWrapper.Loading = ChangePasswordLoading; + +export default ChangePasswordWrapper; diff --git a/src/components/ChangePassword/index.ts b/src/components/ChangePassword/index.ts new file mode 100644 index 0000000..f7ec5db --- /dev/null +++ b/src/components/ChangePassword/index.ts @@ -0,0 +1 @@ +export { default } from "./ChangePasswordWrapper"; From a06d9644dc4b6204a90b4088d5fdec63b243149b Mon Sep 17 00:00:00 2001 From: "Mac\\David" Date: Wed, 8 May 2024 16:20:10 +0800 Subject: [PATCH 26/37] add unsubmitted time sheet chart --- package-lock.json | 283 +++++++++++++----- .../StaffUtilization/StaffUtilization.tsx | 241 ++++++++++++++- 2 files changed, 453 insertions(+), 71 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed756cc..e1f4abf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@faker-js/faker": "^8.4.1", "@fontsource/inter": "^5.0.16", "@fontsource/plus-jakarta-sans": "^5.0.18", + "@fullcalendar/react": "^6.1.11", "@mui/icons-material": "^5.15.0", "@mui/material": "^5.15.0", "@mui/material-nextjs": "^5.15.0", @@ -21,13 +22,14 @@ "@mui/x-date-pickers": "^6.18.7", "@unly/universal-language-detector": "^2.0.3", "apexcharts": "^3.45.2", - "axios": "^1.6.8", + "date-holidays": "^3.23.11", "dayjs": "^1.11.10", + "fullcalendar": "^6.1.11", "i18next": "^23.7.11", "i18next-resources-to-backend": "^1.2.0", "lodash": "^4.17.21", "next": "14.0.4", - "next-auth": "^4.24.5", + "next-auth": "^4.24.7", "next-pwa": "^5.6.0", "react": "^18", "react-apexcharts": "^1.4.1", @@ -2081,6 +2083,79 @@ "tslib": "^2.4.0" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.11.tgz", + "integrity": "sha512-TjG7c8sUz+Vkui2FyCNJ+xqyu0nq653Ibe99A66LoW95oBo6tVhhKIaG1Wh0GVKymYiqAQN/OEdYTuj4ay27kA==", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/core/node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.11.tgz", + "integrity": "sha512-hF5jJB7cgUIxWD5MVjj8IU407HISyLu7BWXcEIuTytkfr8oolOXeCazqnnjmRbnFOncoJQVstTtq6SIhaT32Xg==", + "peerDependencies": { + "@fullcalendar/core": "~6.1.11" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.11.tgz", + "integrity": "sha512-ynOKjzuPwEAMgTQ6R/Z2zvzIIqG4p8/Qmnhi1q0vzPZZxSIYx3rlZuvpEK2WGBZZ1XEafDOP/LGfbWoNZe+qdg==", + "peerDependencies": { + "@fullcalendar/core": "~6.1.11" + } + }, + "node_modules/@fullcalendar/list": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.11.tgz", + "integrity": "sha512-9Qx8uvik9pXD12u50FiHwNzlHv4wkhfsr+r03ycahW7vEeIAKCsIZGTkUfFP+96I5wHihrfLazu1cFQG4MPiuw==", + "peerDependencies": { + "@fullcalendar/core": "~6.1.11" + } + }, + "node_modules/@fullcalendar/multimonth": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/@fullcalendar/multimonth/-/multimonth-6.1.11.tgz", + "integrity": "sha512-7DbPC+AAlaKnquGVdw1Z85Q3nSZ4GZ1NcVIk4k7bLnqDlntwHPPsrDlSIzUWKcN0q5/u7jQHm4PU1m3LAl70Sg==", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.11" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.11" + } + }, + "node_modules/@fullcalendar/react": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.11.tgz", + "integrity": "sha512-Og0Tv0OiglTFp+b++yRyEhAeWnAmKkMLQ3iS0eJE1KDEov6QqGkoO+dUG4x8zp2w55IJqzik/a9iHi0s3oQDbA==", + "peerDependencies": { + "@fullcalendar/core": "~6.1.11", + "react": "^16.7.0 || ^17 || ^18", + "react-dom": "^16.7.0 || ^17 || ^18" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.11.tgz", + "integrity": "sha512-0seUHK/ferH89IeuCvV4Bib0zWjgK0nsptNdmAc9wDBxD/d9hm5Mdti0URJX6bDoRtsSfRDu5XsRcrzwoc+AUQ==", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.11" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.11" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -3648,8 +3723,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/aria-query": { "version": "5.3.0", @@ -3855,6 +3929,14 @@ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true }, + "node_modules/astronomia": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/astronomia/-/astronomia-4.1.1.tgz", + "integrity": "sha512-TcJD9lUC5eAo0/Ji7rnQauX/yQbi0yZWM+JsNr77W3OA5fsrgvuFgubLMFwfw4VlZ29cu9dG/yfJbfvuTSftjg==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/async": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", @@ -3869,11 +3951,6 @@ "has-symbols": "^1.0.3" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -3942,16 +4019,6 @@ "node": ">=4" } }, - "node_modules/axios": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", - "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/axobject-query": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", @@ -4136,6 +4203,17 @@ "node": ">=10.16.0" } }, + "node_modules/caldate": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/caldate/-/caldate-2.0.5.tgz", + "integrity": "sha512-JndhrUuDuE975KUhFqJaVR1OQkCHZqpOrJur/CFXEIEhWhBMjxO85cRSK8q4FW+B+yyPq6GYua2u4KvNzTcq0w==", + "dependencies": { + "moment-timezone": "^0.5.43" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -4337,17 +4415,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -4489,6 +4556,68 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "node_modules/date-bengali-revised": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/date-bengali-revised/-/date-bengali-revised-2.0.2.tgz", + "integrity": "sha512-q9iDru4+TSA9k4zfm0CFHJj6nBsxP7AYgWC/qodK/i7oOIlj5K2z5IcQDtESfs/Qwqt/xJYaP86tkazd/vRptg==", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/date-chinese": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/date-chinese/-/date-chinese-2.1.4.tgz", + "integrity": "sha512-WY+6+Qw92ZGWFvGtStmNQHEYpNa87b8IAQ5T8VKt4wqrn24lBXyyBnWI5jAIyy7h/KVwJZ06bD8l/b7yss82Ww==", + "dependencies": { + "astronomia": "^4.1.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/date-easter": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/date-easter/-/date-easter-1.0.3.tgz", + "integrity": "sha512-aOViyIgpM4W0OWUiLqivznwTtuMlD/rdUWhc5IatYnplhPiWrLv75cnifaKYhmQwUBLAMWLNG4/9mlLIbXoGBQ==", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/date-holidays": { + "version": "3.23.12", + "resolved": "https://registry.npmjs.org/date-holidays/-/date-holidays-3.23.12.tgz", + "integrity": "sha512-DLyP0PPVgNydgaTAY7SBS26+5h3KO1Z8FRKiAROkz0hAGNBLGAM48SMabfVa2ACRHH7Qw3LXYvlJkt9oa9WePA==", + "dependencies": { + "date-holidays-parser": "^3.4.4", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "prepin": "^1.0.3" + }, + "bin": { + "holidays2json": "scripts/holidays2json.cjs" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/date-holidays-parser": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/date-holidays-parser/-/date-holidays-parser-3.4.4.tgz", + "integrity": "sha512-R5aO4oT8H51ZKdvApqHrqYEiNBrqT6tRj2PFXNcZfqMI4nxY7KKKly0ZsmquR5gY+x9ldKR8SAMdozzIInaoXg==", + "dependencies": { + "astronomia": "^4.1.1", + "caldate": "^2.0.5", + "date-bengali-revised": "^2.0.2", + "date-chinese": "^2.1.4", + "date-easter": "^1.0.2", + "deepmerge": "^4.3.1", + "jalaali-js": "^1.2.6", + "moment-timezone": "^0.5.43" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.10", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", @@ -4626,14 +4755,6 @@ "rimraf": "bin.js" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5706,25 +5827,6 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, - "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -5802,6 +5904,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fullcalendar": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/fullcalendar/-/fullcalendar-6.1.11.tgz", + "integrity": "sha512-OOlx/+yFn9k5LnucRzcDmShONBecOVKNN6HHWe8jl7hGzQBmkxO+iD6eBokO6p24EY1PjATqhZkhJqHiCUgx3A==", + "dependencies": { + "@fullcalendar/core": "~6.1.11", + "@fullcalendar/daygrid": "~6.1.11", + "@fullcalendar/interaction": "~6.1.11", + "@fullcalendar/list": "~6.1.11", + "@fullcalendar/multimonth": "~6.1.11", + "@fullcalendar/timegrid": "~6.1.11" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6755,6 +6870,11 @@ "node": ">=8" } }, + "node_modules/jalaali-js": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.6.tgz", + "integrity": "sha512-io974va+Qyu+UfuVX3UIAgJlxLhAMx9Y8VMfh+IG00Js7hXQo1qNQuwSiSa0xxco0SVgx5HWNkaiCcV+aZ8WPw==" + }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -6800,9 +6920,9 @@ } }, "node_modules/jose": { - "version": "4.15.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", - "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==", + "version": "4.15.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", + "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -6816,7 +6936,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -7135,6 +7254,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "peer": true, "engines": { "node": ">= 0.6" } @@ -7143,6 +7263,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -7183,6 +7304,25 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.45", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz", + "integrity": "sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -7275,14 +7415,14 @@ } }, "node_modules/next-auth": { - "version": "4.24.6", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.6.tgz", - "integrity": "sha512-djQt3ZEaWEIxcsuh3HTW2uuzLfXMRjHH+ugAsichlQSbH4iA5MRcgMA2HvTNvsDTDLh44tyU72+/gWsxgTbAKg==", + "version": "4.24.7", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.7.tgz", + "integrity": "sha512-iChjE8ov/1K/z98gdKbn2Jw+2vLgJtVV39X+rCP5SGnVQuco7QOr19FRNGMIrD8d3LYhHWV9j9sKLzq1aDWWQQ==", "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", "cookie": "^0.5.0", - "jose": "^4.11.4", + "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", "preact": "^10.6.3", @@ -7993,6 +8133,14 @@ "node": ">= 0.8.0" } }, + "node_modules/prepin": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/prepin/-/prepin-1.0.3.tgz", + "integrity": "sha512-0XL2hreherEEvUy0fiaGEfN/ioXFV+JpImqIzQjxk6iBg4jQ2ARKqvC4+BmRD8w/pnpD+lbxvh0Ub+z7yBEjvA==", + "bin": { + "prepin": "bin/prepin.js" + } + }, "node_modules/prettier": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", @@ -8057,11 +8205,6 @@ "react-is": "^16.13.1" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/src/components/StaffUtilization/StaffUtilization.tsx b/src/components/StaffUtilization/StaffUtilization.tsx index 81dde4b..74dc3eb 100644 --- a/src/components/StaffUtilization/StaffUtilization.tsx +++ b/src/components/StaffUtilization/StaffUtilization.tsx @@ -124,6 +124,8 @@ const StaffUtilization: React.FC = () => { const [manHoursSpentPeriod, setManHoursSpentPeriod]: any[] = React.useState( firstDayOfWeekString + " to " + lastDayOfWeekString, ); + const [unsubmittedTimeSheetSelect, setUnsubmittedTimeSheetSelect]: any = + React.useState("Weekly"); const [teamTotalManhoursSpentSelect, setTeamTotalManhoursSpentSelect]: any = React.useState("Weekly"); const [staffGradeManhoursSpentSelect, setStaffGradeManhoursSpentSelect]: any = @@ -165,6 +167,10 @@ const StaffUtilization: React.FC = () => { individualStaffManhoursSpentPeriod, setIndividualStaffManhoursSpentPeriod, ]: any[] = React.useState(weekDates); + const [ + unsubmittedTimeSheetPeriod, + setUnsubmittedTimeSheetPeriod, + ]: any[] = React.useState(weekDates); const [ teamTotalManhoursSpentPlanData, setTeamTotalManhoursSpentPlanData, @@ -179,6 +185,8 @@ const StaffUtilization: React.FC = () => { React.useState(dayjs()); const [weeklyValueByIndividualStaff, setWeeklyValueByIndividualStaff] = React.useState(dayjs()); + const [weeklyUnsubmittedTimeSheet, setWeeklyUnsubmittedTimeSheet ] = + React.useState(dayjs()); const [staffGradeManhoursSpentValue, setStaffGradeManhoursSpentValue] = React.useState(dayjs()); const [totalManHoursMonthlyFromValue, setTotalManHoursMonthlyFromValue] = @@ -424,6 +432,56 @@ const StaffUtilization: React.FC = () => { ], }; + const unsubmittedTimeSheetOptions: ApexOptions = { + chart: { + height: 350, + type: "line", + }, + stroke: { + width: [1], + }, + plotOptions: { + bar: { + horizontal: true, + distributed: false, + }, + }, + dataLabels: { + enabled: true, + }, + xaxis: { + categories: [ + "001-Staff A", + "002-Staff B", + "005-Staff E", + "006-Staff F", + "007-Staff G", + ], + }, + yaxis: [ + { + title: { + text: "Staff", + }, + min: 0, + max: 12, + tickAmount: 5, + }, + ], + grid: { + borderColor: "#f1f1f1", + }, + annotations: {}, + series: [ + { + name: "Unsubmitted Time Sheet", + type: "bar", + color: "#00acb1", + data: [2, 2, 1, 5, 1], + }, + ], + }; + const teamTotalManhoursSpentOnClick = (r: any) => { setTeamTotalManhoursSpentSelect(r); if (r === "Weekly") { @@ -456,6 +514,10 @@ const StaffUtilization: React.FC = () => { // } }; + const unsubmittedTimeSheetOnClick = (r: any) => { + setUnsubmittedTimeSheetSelect(r); + }; + const selectWeeklyPeriod = (r: any) => { const selectDate = new Date(r); const firstDayOfWeek = new Date(); @@ -486,6 +548,21 @@ const StaffUtilization: React.FC = () => { setWeeklyValueByStaffGrade(dayjs(firstDayOfWeek)); }; + const selectWeeklyPeriodUnsubmittedTimeSheet = (r: any) => { + const selectDate = new Date(r); + const firstDayOfWeek = new Date(); + firstDayOfWeek.setDate(selectDate.getDate() - selectDate.getDay() + 0); + const weekDates: any[] = []; + for (let i = 0; i < 7; i++) { + const currentDate = new Date(firstDayOfWeek); + currentDate.setDate(firstDayOfWeek.getDate() + i); + const formattedDate = dayjs(currentDate).format("DD MMM (ddd)"); + weekDates.push(formattedDate); + } + setUnsubmittedTimeSheetPeriod(weekDates); + setWeeklyUnsubmittedTimeSheet(dayjs(firstDayOfWeek)); + }; + const selectWeeklyPeriodIndividualStaff = (r: any) => { const selectDate = new Date(r); const firstDayOfWeek = new Date(); @@ -589,6 +666,28 @@ const StaffUtilization: React.FC = () => { setTeamTotalManhoursByStaffGrade(weekDates); }; + const selectUnsubmittedTimeSheetMonthlyPeriodFrom = (r: any) => { + const monthDates: any[] = []; + const monthPlanData: any[] = []; + const monthActualData: any[] = []; + const selectFromDate = dayjs(r); + for ( + let date = selectFromDate.clone(); + date.isBefore(totalManHoursMonthlyToValue, "month"); + date = date.add(1, "month") + ) { + monthDates.push(date.format("MM-YYYY")); + monthPlanData.push(840); + monthActualData.push(Math.floor(Math.random() * (1200 - 840) + 840)); + } + monthDates.push(totalManHoursMonthlyToValue.format("MM-YYYY")); + monthPlanData.push(840); + monthActualData.push(Math.floor(Math.random() * (1200 - 840) + 840)); + // setTeamTotalManhoursSpentPlanData(monthPlanData) + // setTeamTotalManhoursSpentActualData(monthActualData) + setUnsubmittedTimeSheetPeriod(weekDates); + }; + const selectIndividualStaffMonthlyPeriodFrom = (r: any) => { const monthDates: any[] = []; const monthPlanData: any[] = []; @@ -611,6 +710,28 @@ const StaffUtilization: React.FC = () => { setIndividualStaffManhoursSpentPeriod(weekDates); }; + const selectUnsubmittedTimeSheetMonthlyPeriodTo = (r: any) => { + const monthDates: any[] = []; + const monthPlanData: any[] = []; + const monthActualData: any[] = []; + const selectToDate = dayjs(r); + for ( + let date = totalManHoursMonthlyFromValue.clone(); + date.isBefore(selectToDate, "month"); + date = date.add(1, "month") + ) { + monthDates.push(date.format("MM-YYYY")); + monthPlanData.push(840); + monthActualData.push(Math.floor(Math.random() * (1200 - 840) + 840)); + } + monthDates.push(selectToDate.format("MM-YYYY")); + monthPlanData.push(840); + monthActualData.push(Math.floor(Math.random() * (1200 - 840) + 840)); + // setTeamTotalManhoursSpentPlanData(monthPlanData) + // setTeamTotalManhoursSpentActualData(monthActualData) + setUnsubmittedTimeSheetPeriod(weekDates); + }; + const selectIndividualStaffMonthlyPeriodTo = (r: any) => { const monthDates: any[] = []; const monthPlanData: any[] = []; @@ -934,7 +1055,125 @@ const StaffUtilization: React.FC = () => { }} > - + + +
+
+ {unsubmittedTimeSheetSelect === "Weekly" && ( + <> + + + + )} + {unsubmittedTimeSheetSelect === "Monthly" && ( + <> + + + + )} +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ Date: Wed, 8 May 2024 18:50:47 +0800 Subject: [PATCH 28/37] update edits --- src/app/(main)/settings/skill/edit/page.tsx | 28 +++ src/app/api/group/actions.ts | 4 +- src/app/api/user/actions.ts | 8 +- src/app/api/user/index.ts | 5 +- src/components/EditSkill/EditSkill.tsx | 151 ++++++++++++ src/components/EditSkill/EditSkillForm.tsx | 114 +++++++++ src/components/EditSkill/EditSkillLoading.tsx | 40 ++++ src/components/EditSkill/EditSkillWrapper.tsx | 21 ++ src/components/EditSkill/index.ts | 1 + src/components/EditTeam/Allocation.tsx | 4 +- src/components/EditUser/AuthAllocation.tsx | 221 ++++++++++++++++++ src/components/EditUser/EditUser.tsx | 76 ++++-- src/components/EditUser/EditUserWrapper.tsx | 6 +- src/components/EditUser/UserDetail.tsx | 78 +------ .../EditUserGroup/EditUserGroup.tsx | 2 +- .../EditUserGroup/EditUserGroupWrapper.tsx | 2 - .../SearchResults/SearchResults.tsx | 5 +- src/components/SkillSearch/SkillSearch.tsx | 32 +-- src/components/StaffSearch/StaffSearch.tsx | 5 +- .../StaffSearch/StaffSearchWrapper.tsx | 12 +- 20 files changed, 677 insertions(+), 138 deletions(-) create mode 100644 src/app/(main)/settings/skill/edit/page.tsx create mode 100644 src/components/EditSkill/EditSkill.tsx create mode 100644 src/components/EditSkill/EditSkillForm.tsx create mode 100644 src/components/EditSkill/EditSkillLoading.tsx create mode 100644 src/components/EditSkill/EditSkillWrapper.tsx create mode 100644 src/components/EditSkill/index.ts create mode 100644 src/components/EditUser/AuthAllocation.tsx diff --git a/src/app/(main)/settings/skill/edit/page.tsx b/src/app/(main)/settings/skill/edit/page.tsx new file mode 100644 index 0000000..a2d9863 --- /dev/null +++ b/src/app/(main)/settings/skill/edit/page.tsx @@ -0,0 +1,28 @@ +import { Edit } from "@mui/icons-material"; +import { useSearchParams } from "next/navigation"; +// import EditStaff from "@/components/EditStaff"; +import { Suspense } from "react"; +import { I18nProvider, getServerI18n } from "@/i18n"; +// import EditStaffWrapper from "@/components/EditStaff/EditStaffWrapper"; +import { Metadata } from "next"; +import EditSkill from "@/components/EditSkill"; +import { Typography } from "@mui/material"; + + +const EditSkillPage: React.FC = async () => { + const { t } = await getServerI18n("staff"); + + return ( + <> + {t("Edit Skill")} + + }> + + + + {/* */} + + ); +}; + +export default EditSkillPage; diff --git a/src/app/api/group/actions.ts b/src/app/api/group/actions.ts index 204c1d9..6800b29 100644 --- a/src/app/api/group/actions.ts +++ b/src/app/api/group/actions.ts @@ -29,8 +29,8 @@ export interface record { records: auth[]; } -export const fetchAuth = cache(async (id?: number) => { - return serverFetchJson(`${BASE_API_URL}/group/auth/combo/${id ?? 0}`, { +export const fetchAuth = cache(async (target: string, id?: number) => { + return serverFetchJson(`${BASE_API_URL}/group/auth/${target}/${id ?? 0}`, { next: { tags: ["auth"] }, }); }); diff --git a/src/app/api/user/actions.ts b/src/app/api/user/actions.ts index fff3da4..77b58b5 100644 --- a/src/app/api/user/actions.ts +++ b/src/app/api/user/actions.ts @@ -7,8 +7,10 @@ import { UserDetail, UserResult } from "."; import { cache } from "react"; export interface UserInputs { - username: string; - email: string; + name: string; + email?: string; + addAuthIds?: number[]; + removeAuthIds?: number[]; } export interface PasswordInputs { @@ -40,7 +42,7 @@ export const deleteUser = async (id: number) => { }; export const changePassword = async (data: any) => { - return serverFetchJson(`${BASE_API_URL}/user/change-password`, { + return serverFetchWithNoContent(`${BASE_API_URL}/user/change-password`, { method: "PATCH", body: JSON.stringify(data), headers: { "Content-Type": "application/json" }, diff --git a/src/app/api/user/index.ts b/src/app/api/user/index.ts index 3151b64..f34292f 100644 --- a/src/app/api/user/index.ts +++ b/src/app/api/user/index.ts @@ -3,7 +3,6 @@ import { BASE_API_URL } from "@/config/api"; import { cache } from "react"; import "server-only"; - export interface UserResult { action: any; id: number; @@ -20,6 +19,7 @@ export interface UserResult { phone2: string; remarks: string; groupId: number; + auths: any } // export interface DetailedUser extends UserResult { @@ -28,9 +28,10 @@ export interface UserResult { // } export interface UserDetail { - authIds: number[]; data: UserResult; + authIds: number[]; groupIds: number[]; + auths: any[] } export const preloadUser = () => { diff --git a/src/components/EditSkill/EditSkill.tsx b/src/components/EditSkill/EditSkill.tsx new file mode 100644 index 0000000..a34b6e9 --- /dev/null +++ b/src/components/EditSkill/EditSkill.tsx @@ -0,0 +1,151 @@ +"use client"; +import { SkillResult } from "@/app/api/skill"; +import { + Button, + Card, + CardContent, + Grid, + Stack, + Tab, + Tabs, + TabsProps, + TextField, + Typography, +} from "@mui/material"; +import { + FieldErrors, + FormProvider, + SubmitErrorHandler, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Check, Close, Error, RestartAlt } from "@mui/icons-material"; +import EditSkillForm from "./EditSkillForm"; +import { CreateSkillInputs, saveSkill } from "@/app/api/skill/actions"; +import AuthAllocation from "../EditUser/AuthAllocation"; + +interface Props { + skills: SkillResult[]; +} + +const EditSkill: React.FC = async ({ skills }) => { + const { t } = useTranslation(); + const formProps = useForm(); + const [serverError, setServerError] = useState(""); + const router = useRouter(); + const searchParams = useSearchParams(); + const id = parseInt(searchParams.get("id") || "0"); + const [tabIndex, setTabIndex] = useState(0); + const [filteredSkill, setFilteredSkill] = useState(() => + skills.filter((s) => s.id === id)[0] as SkillResult + ); + const errors = formProps.formState.errors; + + const onSubmit = useCallback>( + async (data) => { + try { + console.log(data); + const postData = { + ...data, + id: id + } + await saveSkill(postData) + router.replace(`/settings/skill`) + } catch (e) { + console.log(e); + setServerError(t("An error has occurred. Please try again later.")); + } + }, + [router] + ); + + const handleCancel = () => { + router.back(); + }; + + const handleTabChange = useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + useEffect(() => { + formProps.reset({ + name: filteredSkill.name, + code: filteredSkill.code, + description: filteredSkill.description + }); + }, [skills]); + + const hasErrorsInTab = ( + tabIndex: number, + errors: FieldErrors + ) => { + switch (tabIndex) { + case 0: + return Object.keys(errors).length > 0; + default: + false; + } + }; + + return ( + <> + {serverError && ( + + {serverError} + + )} + + + + + ) : undefined + } + iconPosition="end" + /> + {/* */} + + {tabIndex === 0 && } + + + + + + + + + ); +}; +export default EditSkill; diff --git a/src/components/EditSkill/EditSkillForm.tsx b/src/components/EditSkill/EditSkillForm.tsx new file mode 100644 index 0000000..120d2e5 --- /dev/null +++ b/src/components/EditSkill/EditSkillForm.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { CreateSkillInputs } from "@/app/api/skill/actions"; +import { + Box, + Button, + Card, + CardContent, + Grid, + Stack, + Tab, + Tabs, + TabsProps, + TextField, + Typography, +} from "@mui/material"; +import { useSearchParams } from "next/navigation"; +import { + FieldErrors, + FormProvider, + SubmitErrorHandler, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +interface Props { + // users: UserResult[] +} + +const EditSkillForm: React.FC = async ({}) => { + const { t } = useTranslation(); + const searchParams = useSearchParams(); + const idString = searchParams.get("id"); + const { + register, + setValue, + getValues, + formState: { errors, defaultValues }, + reset, + resetField, + } = useFormContext(); + // const formProps = useForm({}); + + return ( + <> + + + + + {t("Skill Info")} + + + + + + + + + + + + + + + + + ); +}; +export default EditSkillForm; diff --git a/src/components/EditSkill/EditSkillLoading.tsx b/src/components/EditSkill/EditSkillLoading.tsx new file mode 100644 index 0000000..74e08af --- /dev/null +++ b/src/components/EditSkill/EditSkillLoading.tsx @@ -0,0 +1,40 @@ +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Skeleton from "@mui/material/Skeleton"; +import Stack from "@mui/material/Stack"; +import React from "react"; + +// Can make this nicer +export const EditSkillLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + Edit Skill + + + + + + + + + + + ); +}; + +export default EditSkillLoading; diff --git a/src/components/EditSkill/EditSkillWrapper.tsx b/src/components/EditSkill/EditSkillWrapper.tsx new file mode 100644 index 0000000..12d7a12 --- /dev/null +++ b/src/components/EditSkill/EditSkillWrapper.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import EditSkill from "./EditSkill"; +import EditSkillLoading from "./EditSkillLoading"; +import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; +import { useSearchParams } from "next/navigation"; +import { fetchSkill } from "@/app/api/skill"; + +interface SubComponents { + Loading: typeof EditSkillLoading; +} + +const EditSkillWrapper: React.FC & SubComponents = async () => { + const skills = await fetchSkill() + console.log(skills) + + return ; +}; + +EditSkillWrapper.Loading = EditSkillLoading; + +export default EditSkillWrapper; diff --git a/src/components/EditSkill/index.ts b/src/components/EditSkill/index.ts new file mode 100644 index 0000000..ba42dd8 --- /dev/null +++ b/src/components/EditSkill/index.ts @@ -0,0 +1 @@ +export { default } from "./EditSkillWrapper"; diff --git a/src/components/EditTeam/Allocation.tsx b/src/components/EditTeam/Allocation.tsx index 61e9e8f..f1386fe 100644 --- a/src/components/EditTeam/Allocation.tsx +++ b/src/components/EditTeam/Allocation.tsx @@ -16,8 +16,8 @@ import { Staff4TransferList, fetchStaffCombo } from "@/app/api/staff/actions"; import { StaffResult, StaffTeamTable } from "@/app/api/staff"; import SearchResults, { Column } from "../SearchResults"; import { Clear, PersonAdd, PersonRemove, Search } from "@mui/icons-material"; -import { Card } from "reactstrap"; import { + Card, Box, CardContent, Grid, @@ -49,8 +49,6 @@ const Allocation: React.FC = ({ allStaffs: staff, teamLead }) => { reset, resetField, } = useFormContext(); - - // let firstFilter: StaffResult[] = [] const initialStaffs = staff.map((s) => ({ ...s })); const [filteredStaff, setFilteredStaff] = useState(initialStaffs); diff --git a/src/components/EditUser/AuthAllocation.tsx b/src/components/EditUser/AuthAllocation.tsx new file mode 100644 index 0000000..afb44d5 --- /dev/null +++ b/src/components/EditUser/AuthAllocation.tsx @@ -0,0 +1,221 @@ +"use client"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Add, Clear, PersonAdd, PersonRemove, Remove, Search } from "@mui/icons-material"; +import { useTranslation } from "react-i18next"; +import { + FieldErrors, + FormProvider, + SubmitErrorHandler, + SubmitHandler, + useForm, + useFormContext, + } from "react-hook-form"; +import { + Box, + Card, + CardContent, + Grid, + IconButton, + InputAdornment, + Stack, + Tab, + Tabs, + TabsProps, + TextField, + Typography, + } from "@mui/material"; + import { differenceBy } from "lodash"; +import { UserInputs } from "@/app/api/user/actions"; +import { auth } from "@/app/api/group/actions"; +import SearchResults, { Column } from "../SearchResults"; + +export interface Props { + auths: auth[] + + } + +const AuthAllocation: React.FC = ({ auths }) => { + const { t } = useTranslation(); + const searchParams = useSearchParams(); + const id = parseInt(searchParams.get("id") || "0"); + const { + setValue, + getValues, + formState: { defaultValues }, + reset, + resetField, + } = useFormContext(); + const initialAuths = auths.map((u) => ({ ...u })).sort((a, b) => a.id - b.id); + const [filteredAuths, setFilteredAuths] = useState(initialAuths); + const [selectedAuths, setSelectedAuths] = useState( + () => { + return filteredAuths.filter( + (s) => getValues("addAuthIds")?.includes(s.id) + ); + } + ); + const [removeAuthIds, setRemoveAuthIds] = useState([]); + + // Adding / Removing Auth + const addAuth = useCallback((auth: auth) => { + setSelectedAuths((a) => [...a, auth]); + }, []); + const removeAuth = useCallback((auth: auth) => { + setSelectedAuths((a) => a.filter((a) => a.id !== auth.id)); + setRemoveAuthIds((prevIds) => [...prevIds, auth.id]); + }, []); + + const clearAuth = useCallback(() => { + if (defaultValues !== undefined) { + resetField("addAuthIds"); + setSelectedAuths( + initialAuths.filter((auth) => defaultValues.addAuthIds?.includes(auth.id)) + ); + } + }, [defaultValues]); + + // Sync with form + useEffect(() => { + setValue( + "addAuthIds", + selectedAuths.map((a) => a.id) + ); + setValue( + "removeAuthIds", + removeAuthIds + ); + }, [selectedAuths, removeAuthIds, setValue]); + + + const AuthPoolColumns = useMemo[]>( + () => [ + { + label: t("Add"), + name: "id", + onClick: addAuth, + buttonIcon: , + }, + { label: t("authority"), name: "authority" }, + { label: t("Auth Name"), name: "name" }, + // { label: t("Current Position"), name: "currentPosition" }, + ], + [addAuth, t] + ); + + const allocatedAuthColumns = useMemo[]>( + () => [ + { + label: t("Remove"), + name: "id", + onClick: removeAuth, + buttonIcon: , + }, + { label: t("authority"), name: "authority" }, + { label: t("Auth Name"), name: "name" }, + ], + [removeAuth, selectedAuths, t] + ); + + const [query, setQuery] = React.useState(""); + const onQueryInputChange = React.useCallback< + React.ChangeEventHandler + >((e) => { + setQuery(e.target.value); + }, []); + const clearQueryInput = React.useCallback(() => { + setQuery(""); + }, []); + + React.useEffect(() => { + // setFilteredStaff( + // initialStaffs.filter((s) => { + // const q = query.toLowerCase(); + // // s.staffId.toLowerCase().includes(q) + // // const q = query.toLowerCase(); + // // return s.name.toLowerCase().includes(q); + // // s.code.toString().includes(q) || + // // (s.brNo != null && s.brNo.toLowerCase().includes(q)) + // }) + // ); + }, [auths, query]); + + const resetAuth = React.useCallback(() => { + clearQueryInput(); + clearAuth(); + }, [clearQueryInput, clearAuth]); + + const formProps = useForm({}); + + // Tab related + const [tabIndex, setTabIndex] = React.useState(0); + const handleTabChange = React.useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + +return ( + <> + + + + + + {t("Authority")} + + + + + + + + + + ), + }} + /> + + + + + + + + {tabIndex === 0 && ( + + )} + {tabIndex === 1 && ( + + )} + + + + + + + ); + +} +export default AuthAllocation \ No newline at end of file diff --git a/src/components/EditUser/EditUser.tsx b/src/components/EditUser/EditUser.tsx index db52fac..853de6a 100644 --- a/src/components/EditUser/EditUser.tsx +++ b/src/components/EditUser/EditUser.tsx @@ -1,6 +1,6 @@ "use client"; import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; import SearchResults, { Column } from "../SearchResults"; // import { TeamResult } from "@/app/api/team"; import { useTranslation } from "react-i18next"; @@ -26,9 +26,11 @@ import { } from "react-hook-form"; import { Check, Close, Error, RestartAlt } from "@mui/icons-material"; import { StaffResult } from "@/app/api/staff"; -import { editUser, fetchUserDetails } from "@/app/api/user/actions"; +import { UserInputs, editUser, fetchUserDetails } from "@/app/api/user/actions"; import UserDetail from "./UserDetail"; import { UserResult } from "@/app/api/user"; +import { auth, fetchAuth } from "@/app/api/group/actions"; +import AuthAllocation from "./AuthAllocation"; interface Props { // users: UserResult[] @@ -36,11 +38,14 @@ interface Props { const EditUser: React.FC = async ({ }) => { const { t } = useTranslation(); - const formProps = useForm(); + const formProps = useForm(); const searchParams = useSearchParams(); const id = parseInt(searchParams.get("id") || "0"); const [tabIndex, setTabIndex] = useState(0); const router = useRouter(); + const [serverError, setServerError] = useState(""); + const [data, setData] = useState(); + const [auths, setAuths] = useState(); const handleTabChange = useCallback>( (_e, newValue) => { @@ -49,38 +54,45 @@ const EditUser: React.FC = async ({ }) => { [] ); - const [serverError, setServerError] = useState(""); - const [data, setData] = useState(); + const errors = formProps.formState.errors; const fetchUserDetail = async () => { console.log(id); try { + // fetch user info const userDetail = await fetchUserDetails(id); console.log(userDetail); const _data = userDetail.data as UserResult; console.log(_data); setData(_data); + //fetch user auths + const authDetail = await fetchAuth("user", id); + setAuths(authDetail.records) + const addAuthIds = authDetail.records.filter((item) => item.v === 1).map((item) => item.id).sort((a, b) => a - b); + formProps.reset({ - username: _data.username, - firstname: _data.firstname, - lastname: _data.lastname, - title: _data.title, - department: _data.department, + name: _data.username, email: _data.email, - phone1: _data.phone1, - phone2: _data.phone2, - remarks: _data.remarks, + addAuthIds: addAuthIds || [] }); } catch (error) { console.log(error); setServerError(t("An error has occurred. Please try again later.")); } - }; + } useEffect(() => { fetchUserDetail(); }, []); + // useEffect(() => { + // const thisUser = users.filter((item) => item.id === id) + // formProps.reset({ + // username: thisUser[0].username, + // email: thisUser[0].email, + // }); + // }, []); + const hasErrorsInTab = ( tabIndex: number, errors: FieldErrors @@ -97,14 +109,16 @@ const EditUser: React.FC = async ({ }) => { router.back(); }; - const onSubmit = useCallback>( + const onSubmit = useCallback>( async (data) => { try { console.log(data); const tempData = { - username: data.username, + name: data.name, email: data.email, - locked: false + locked: false, + addAuthIds: data.addAuthIds || [], + removeAuthIds: data.removeAuthIds || [], } console.log(tempData); await editUser(id, tempData); @@ -116,7 +130,7 @@ const EditUser: React.FC = async ({ }) => { }, [router] ); - const onSubmitError = useCallback>( + const onSubmitError = useCallback>( (errors) => { console.log(errors); }, @@ -136,7 +150,31 @@ const EditUser: React.FC = async ({ }) => { component="form" onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} > - + + + + ) : undefined + } + iconPosition="end" + /> + + + + {tabIndex == 0 && } + {tabIndex === 1 && } @@ -228,10 +302,10 @@ const InvoiceSearch: React.FC = ({ issuedInvoice, receivedInvoice }) => { criteria={searchCriteria2} onSearch={(query) => { console.log(query) - setFilteredIssuedInvoices( - issuedInvoice.filter( + setFilteredReceivedInvoices( + receivedInvoice.filter( (s) => - (isDateInRange(s.invoiceDate, query.receiptDate ?? undefined, query.receiptDateTo ?? undefined)) || + (isDateInRange(s.receiptDate, query.receiptDate ?? undefined, query.receiptDateTo ?? undefined)) || (s.invoiceNo === query.invoiceNo) || (s.projectCode === query.projectCode) ), @@ -253,7 +327,10 @@ const InvoiceSearch: React.FC = ({ issuedInvoice, receivedInvoice }) => { } { tabIndex == 1 && -

Todo

+ + items={filteredReceivedInvoices} + columns={columns2} + /> } diff --git a/src/components/InvoiceSearch/InvoiceSearchWrapper.tsx b/src/components/InvoiceSearch/InvoiceSearchWrapper.tsx index 7a4cfcf..8680599 100644 --- a/src/components/InvoiceSearch/InvoiceSearchWrapper.tsx +++ b/src/components/InvoiceSearch/InvoiceSearchWrapper.tsx @@ -17,7 +17,7 @@ interface SubComponents { const InvoiceSearchWrapper: React.FC & SubComponents = async () => { const issuedInvoices = await fetchIssuedInvoices() - // const receivedInvoices = await fetchReceivedInvoices() + const receivedInvoices = await fetchReceivedInvoices() const convertedIssedInvoices = issuedInvoices.map((invoice)=>{ return{ @@ -33,10 +33,21 @@ const InvoiceSearchWrapper: React.FC & SubComponents = async () => { } }) + const convertedReceivedInvoices = receivedInvoices.map((invoice)=>{ + return{ + id: invoice.id, + invoiceNo: invoice.invoiceNo, + projectCode: invoice.projectCode, + projectName: invoice.projectName, + team: invoice.team, + receiptDate: convertDateArrayToString(invoice.receiptDate, INPUT_DATE_FORMAT, false)!!, + receivedAmount: moneyFormatter.format(invoice.receivedAmount) + } + }) return }; From 4a9ff12af73c502480acf403c1642b71853272b3 Mon Sep 17 00:00:00 2001 From: Wayne Date: Sun, 12 May 2024 21:15:47 +0900 Subject: [PATCH 37/37] Add mobile timesheet input --- .../DateHoursTable/DateHoursList.tsx | 205 ++++++++++++++++++ .../DateHoursTable/DateHoursTable.tsx | 196 +++++++++++++++++ src/components/DateHoursTable/index.ts | 1 + .../FullscreenModal/FullscreenModal.tsx | 46 ++++ src/components/FullscreenModal/index.ts | 1 + src/components/LeaveModal/LeaveModal.tsx | 109 +++++++--- src/components/LeaveTable/LeaveEditModal.tsx | 132 +++++++++++ src/components/LeaveTable/LeaveEntryTable.tsx | 4 +- src/components/LeaveTable/LeaveTable.tsx | 133 ++---------- .../LeaveTable/MobileLeaveEntry.tsx | 179 +++++++++++++++ .../LeaveTable/MobileLeaveTable.tsx | 35 +++ .../TimesheetModal/TimesheetModal.tsx | 105 ++++++--- .../TimesheetTable/MobileTimesheetEntry.tsx | 40 ++++ .../TimesheetTable/MobileTimesheetTable.tsx | 37 ++++ .../TimesheetTable/TimesheetTable.tsx | 146 ++----------- .../UserWorkspacePage/UserWorkspacePage.tsx | 2 + 16 files changed, 1056 insertions(+), 315 deletions(-) create mode 100644 src/components/DateHoursTable/DateHoursList.tsx create mode 100644 src/components/DateHoursTable/DateHoursTable.tsx create mode 100644 src/components/DateHoursTable/index.ts create mode 100644 src/components/FullscreenModal/FullscreenModal.tsx create mode 100644 src/components/FullscreenModal/index.ts create mode 100644 src/components/LeaveTable/LeaveEditModal.tsx create mode 100644 src/components/LeaveTable/MobileLeaveEntry.tsx create mode 100644 src/components/LeaveTable/MobileLeaveTable.tsx create mode 100644 src/components/TimesheetTable/MobileTimesheetEntry.tsx create mode 100644 src/components/TimesheetTable/MobileTimesheetTable.tsx diff --git a/src/components/DateHoursTable/DateHoursList.tsx b/src/components/DateHoursTable/DateHoursList.tsx new file mode 100644 index 0000000..75b991b --- /dev/null +++ b/src/components/DateHoursTable/DateHoursList.tsx @@ -0,0 +1,205 @@ +import { + RecordLeaveInput, + RecordTimesheetInput, +} from "@/app/api/timesheets/actions"; +import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; +import { ArrowBack, Check } from "@mui/icons-material"; +import { + Box, + Button, + Card, + CardActionArea, + CardContent, + Stack, + Typography, +} from "@mui/material"; +import dayjs from "dayjs"; +import React, { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + LEAVE_DAILY_MAX_HOURS, + TIMESHEET_DAILY_MAX_HOURS, +} from "@/app/api/timesheets/utils"; + +interface Props { + days: string[]; + leaveEntries: RecordLeaveInput; + timesheetEntries: RecordTimesheetInput; + EntryComponent: React.FunctionComponent< + EntryComponentProps & { date: string } + >; + entryComponentProps: EntryComponentProps; +} + +function DateHoursList({ + days, + leaveEntries, + timesheetEntries, + EntryComponent, + entryComponentProps, +}: Props) { + const { + t, + i18n: { language }, + } = useTranslation("home"); + + const [selectedDate, setSelectedDate] = useState(""); + const isDateSelected = selectedDate !== ""; + + const makeSelectDate = useCallback( + (date: string) => () => { + setSelectedDate(date); + }, + [], + ); + + const onDateDone = useCallback>( + (e) => { + setSelectedDate(""); + e.preventDefault(); + }, + [], + ); + + return ( + <> + {isDateSelected ? ( + + ) : ( + + {days.map((day, index) => { + const dayJsObj = dayjs(day); + const leaves = leaveEntries[day]; + const leaveHours = + leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; + + const timesheet = timesheetEntries[day]; + const timesheetHours = + timesheet?.reduce( + (acc, entry) => + acc + (entry.inputHours || 0) + (entry.otHours || 0), + 0, + ) || 0; + + const dailyTotal = leaveHours + timesheetHours; + + const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS; + const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS; + + return ( + + + + + {shortDateFormatter(language).format(dayJsObj.toDate())} + + + + + {t("Timesheet Hours")} + + + {manhourFormatter.format(timesheetHours)} + + + + + {t("Leave Hours")} + + + {manhourFormatter.format(leaveHours)} + + {leaveExceeded && ( + + {t("Leave hours cannot be more than {{hours}}", { + hours: LEAVE_DAILY_MAX_HOURS, + })} + + )} + + + + + {t("Daily Total Hours")} + + + {manhourFormatter.format(timesheetHours + leaveHours)} + + {dailyTotalExceeded && ( + + {t( + "The daily total hours cannot be more than {{hours}}", + { + hours: TIMESHEET_DAILY_MAX_HOURS, + }, + )} + + )} + + + + + + ); + })} + + )} + + {isDateSelected ? ( + + ) : ( + + )} + + + ); +} + +export default DateHoursList; diff --git a/src/components/DateHoursTable/DateHoursTable.tsx b/src/components/DateHoursTable/DateHoursTable.tsx new file mode 100644 index 0000000..cd897b1 --- /dev/null +++ b/src/components/DateHoursTable/DateHoursTable.tsx @@ -0,0 +1,196 @@ +import { + RecordLeaveInput, + RecordTimesheetInput, +} from "@/app/api/timesheets/actions"; +import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; +import { Info, KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; +import { + Box, + Collapse, + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, +} from "@mui/material"; +import dayjs from "dayjs"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + LEAVE_DAILY_MAX_HOURS, + TIMESHEET_DAILY_MAX_HOURS, +} from "@/app/api/timesheets/utils"; + +interface Props { + days: string[]; + leaveEntries: RecordLeaveInput; + timesheetEntries: RecordTimesheetInput; + EntryTableComponent: React.FunctionComponent< + EntryTableProps & { day: string } + >; + entryTableProps: EntryTableProps; +} + +function DateHoursTable({ + days, + EntryTableComponent, + entryTableProps, + leaveEntries, + timesheetEntries, +}: Props) { + const { t } = useTranslation("home"); + + return ( + + + + + + {t("Date")} + {t("Timesheet Hours")} + {t("Leave Hours")} + {t("Daily Total Hours")} + + + + {days.map((day, index) => { + return ( + + ); + })} + +
+
+ ); +} + +function DayRow({ + day, + leaveEntries, + timesheetEntries, + entryTableProps, + EntryTableComponent, +}: { + day: string; + leaveEntries: RecordLeaveInput; + timesheetEntries: RecordTimesheetInput; + EntryTableComponent: React.FunctionComponent< + EntryTableProps & { day: string } + >; + entryTableProps: EntryTableProps; +}) { + const { + t, + i18n: { language }, + } = useTranslation("home"); + const dayJsObj = dayjs(day); + const [open, setOpen] = useState(false); + + const leaves = leaveEntries[day]; + const leaveHours = + leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; + + const timesheet = timesheetEntries[day]; + const timesheetHours = + timesheet?.reduce( + (acc, entry) => acc + (entry.inputHours || 0) + (entry.otHours || 0), + 0, + ) || 0; + + const dailyTotal = leaveHours + timesheetHours; + + const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS; + const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS; + + return ( + <> + + + setOpen(!open)} + > + {open ? : } + + + + {shortDateFormatter(language).format(dayJsObj.toDate())} + + {/* Timesheet */} + {manhourFormatter.format(timesheetHours)} + {/* Leave total */} + + + {manhourFormatter.format(leaveHours)} + {leaveExceeded && ( + + + + )} + + + {/* Daily total */} + + + {manhourFormatter.format(dailyTotal)} + {dailyTotalExceeded && ( + + + + )} + + + + + + + {} + + + + + ); +} + +export default DateHoursTable; diff --git a/src/components/DateHoursTable/index.ts b/src/components/DateHoursTable/index.ts new file mode 100644 index 0000000..9acba4b --- /dev/null +++ b/src/components/DateHoursTable/index.ts @@ -0,0 +1 @@ +export { default } from "./DateHoursTable"; diff --git a/src/components/FullscreenModal/FullscreenModal.tsx b/src/components/FullscreenModal/FullscreenModal.tsx new file mode 100644 index 0000000..11aa36b --- /dev/null +++ b/src/components/FullscreenModal/FullscreenModal.tsx @@ -0,0 +1,46 @@ +import { Close } from "@mui/icons-material"; +import { + Box, + IconButton, + Modal, + ModalProps, + Paper, + Slide, +} from "@mui/material"; + +interface Props extends ModalProps { + closeModal: () => void; +} + +const FullscreenModal: React.FC = ({ + children, + closeModal, + ...props +}) => { + return ( + + + + + + + + + + {children} + + + + + ); +}; + +export default FullscreenModal; diff --git a/src/components/FullscreenModal/index.ts b/src/components/FullscreenModal/index.ts new file mode 100644 index 0000000..5cc4ad7 --- /dev/null +++ b/src/components/FullscreenModal/index.ts @@ -0,0 +1 @@ +export { default } from "./FullscreenModal"; diff --git a/src/components/LeaveModal/LeaveModal.tsx b/src/components/LeaveModal/LeaveModal.tsx index c55c1c0..a6a551c 100644 --- a/src/components/LeaveModal/LeaveModal.tsx +++ b/src/components/LeaveModal/LeaveModal.tsx @@ -9,15 +9,23 @@ import { ModalProps, SxProps, Typography, + useMediaQuery, + useTheme, } from "@mui/material"; import { useTranslation } from "react-i18next"; import { Check, Close } from "@mui/icons-material"; import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; -import { RecordLeaveInput, saveLeave } from "@/app/api/timesheets/actions"; +import { + 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"; interface Props { isOpen: boolean; @@ -25,6 +33,7 @@ interface Props { username: string; defaultLeaveRecords?: RecordLeaveInput; leaveTypes: LeaveType[]; + timesheetRecords: RecordTimesheetInput; } const modalSx: SxProps = { @@ -34,7 +43,7 @@ const modalSx: SxProps = { transform: "translate(-50%, -50%)", width: { xs: "calc(100% - 2rem)", sm: "90%" }, maxHeight: "90%", - maxWidth: 1200, + maxWidth: 1400, }; const LeaveModal: React.FC = ({ @@ -42,6 +51,7 @@ const LeaveModal: React.FC = ({ onClose, username, defaultLeaveRecords, + timesheetRecords, leaveTypes, }) => { const { t } = useTranslation("home"); @@ -90,47 +100,80 @@ const LeaveModal: React.FC = ({ const onModalClose = useCallback>( (_, reason) => { if (reason !== "backdropClick") { - onClose(); + onCancel(); } }, - [onClose], + [onCancel], ); + const theme = useTheme(); + const matches = useMediaQuery(theme.breakpoints.up("sm")); + return ( - - - - + {matches ? ( + // Desktop version + + + + + {t("Record Leave")} + + + + + + + + + + + + ) : ( + // Mobile version + + - + {t("Record Leave")} - - - - - - - - - - - + + + + )} + ); }; diff --git a/src/components/LeaveTable/LeaveEditModal.tsx b/src/components/LeaveTable/LeaveEditModal.tsx new file mode 100644 index 0000000..c176930 --- /dev/null +++ b/src/components/LeaveTable/LeaveEditModal.tsx @@ -0,0 +1,132 @@ +import { LeaveType } from "@/app/api/timesheets"; +import { LeaveEntry } from "@/app/api/timesheets/actions"; +import { Check, Delete } from "@mui/icons-material"; +import { + Box, + Button, + FormControl, + InputLabel, + MenuItem, + Modal, + ModalProps, + Paper, + Select, + SxProps, + TextField, +} from "@mui/material"; +import React, { useCallback, useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +export interface Props extends Omit { + onSave: (leaveEntry: LeaveEntry) => void; + onDelete?: () => void; + leaveTypes: LeaveType[]; + defaultValues?: Partial; +} + +const modalSx: SxProps = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: "90%", + maxHeight: "90%", + padding: 3, + display: "flex", + flexDirection: "column", + gap: 2, +}; +const LeaveEditModal: React.FC = ({ + onSave, + onDelete, + open, + onClose, + leaveTypes, + defaultValues, +}) => { + const { t } = useTranslation("home"); + const { register, control, reset, getValues, trigger, formState } = + useForm(); + + useEffect(() => { + reset(defaultValues ?? { leaveTypeId: leaveTypes[0].id, id: Date.now() }); + }, [defaultValues, leaveTypes, reset]); + + const saveHandler = useCallback(async () => { + const valid = await trigger(); + if (valid) { + onSave(getValues()); + } + }, [getValues, onSave, trigger]); + + const closeHandler = useCallback>( + (...args) => { + onClose?.(...args); + reset(); + }, + [onClose, reset], + ); + + return ( + + + + {t("Leave Type")} + ( + + )} + /> + + value > 0, + })} + error={Boolean(formState.errors.inputHours)} + /> + + + {onDelete && ( + + )} + + + + + ); +}; + +export default LeaveEditModal; diff --git a/src/components/LeaveTable/LeaveEntryTable.tsx b/src/components/LeaveTable/LeaveEntryTable.tsx index dd8fda7..9fb8172 100644 --- a/src/components/LeaveTable/LeaveEntryTable.tsx +++ b/src/components/LeaveTable/LeaveEntryTable.tsx @@ -169,8 +169,8 @@ const EntryInputTable: React.FC = ({ day, leaveTypes }) => { }, { field: "inputHours", - headerName: t("Hours"), - width: 100, + headerName: t("Leave Hours"), + width: 150, editable: true, type: "number", valueFormatter(params) { diff --git a/src/components/LeaveTable/LeaveTable.tsx b/src/components/LeaveTable/LeaveTable.tsx index 12097c5..ce71d12 100644 --- a/src/components/LeaveTable/LeaveTable.tsx +++ b/src/components/LeaveTable/LeaveTable.tsx @@ -1,136 +1,31 @@ -import { RecordLeaveInput, LeaveEntry } from "@/app/api/timesheets/actions"; -import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; -import { KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; import { - Box, - Collapse, - IconButton, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Typography, -} from "@mui/material"; -import dayjs from "dayjs"; -import React, { useState } from "react"; + RecordLeaveInput, + RecordTimesheetInput, +} from "@/app/api/timesheets/actions"; +import React from "react"; import { useFormContext } from "react-hook-form"; -import { useTranslation } from "react-i18next"; import LeaveEntryTable from "./LeaveEntryTable"; import { LeaveType } from "@/app/api/timesheets"; -import { LEAVE_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils"; +import DateHoursTable from "../DateHoursTable"; interface Props { leaveTypes: LeaveType[]; + timesheetRecords: RecordTimesheetInput; } -const LeaveTable: React.FC = ({ leaveTypes }) => { - const { t } = useTranslation("home"); - +const LeaveTable: React.FC = ({ leaveTypes, timesheetRecords }) => { const { watch } = useFormContext(); const currentInput = watch(); const days = Object.keys(currentInput); return ( - - - - - - {t("Date")} - {t("Daily Total Hours")} - - - - {days.map((day, index) => { - const entries = currentInput[day]; - return ( - - ); - })} - -
-
- ); -}; - -const DayRow: React.FC<{ - day: string; - entries: LeaveEntry[]; - leaveTypes: LeaveType[]; -}> = ({ day, entries, leaveTypes }) => { - const { - t, - i18n: { language }, - } = useTranslation("home"); - const dayJsObj = dayjs(day); - const [open, setOpen] = useState(false); - - const totalHours = entries.reduce((acc, entry) => acc + entry.inputHours, 0); - - return ( - <> - - - setOpen(!open)} - > - {open ? : } - - - - {shortDateFormatter(language).format(dayJsObj.toDate())} - - LEAVE_DAILY_MAX_HOURS ? "error.main" : undefined, - }} - > - {manhourFormatter.format(totalHours)} - {totalHours > LEAVE_DAILY_MAX_HOURS && ( - - {t("(the daily total hours cannot be more than {{hours}})", { - hours: LEAVE_DAILY_MAX_HOURS, - })} - - )} - - - - - - - - - - - - + ); }; diff --git a/src/components/LeaveTable/MobileLeaveEntry.tsx b/src/components/LeaveTable/MobileLeaveEntry.tsx new file mode 100644 index 0000000..90fd19a --- /dev/null +++ b/src/components/LeaveTable/MobileLeaveEntry.tsx @@ -0,0 +1,179 @@ +import { LeaveType } from "@/app/api/timesheets"; +import { LeaveEntry, RecordLeaveInput } from "@/app/api/timesheets/actions"; +import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; +import { Add, Edit } from "@mui/icons-material"; +import { + Box, + Button, + Card, + CardContent, + IconButton, + Typography, +} from "@mui/material"; +import dayjs from "dayjs"; +import React, { useCallback, useMemo, useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import LeaveEditModal, { Props as LeaveEditModalProps } from "./LeaveEditModal"; + +interface Props { + date: string; + leaveTypes: LeaveType[]; +} + +const MobileLeaveEntry: React.FC = ({ date, leaveTypes }) => { + const { + t, + i18n: { language }, + } = useTranslation("home"); + const dayJsObj = dayjs(date); + + const leaveTypeMap = useMemo<{ [id: LeaveType["id"]]: LeaveType }>(() => { + return leaveTypes.reduce( + (acc, leaveType) => ({ ...acc, [leaveType.id]: leaveType }), + {}, + ); + }, [leaveTypes]); + + const { watch, setValue } = useFormContext(); + const currentEntries = watch(date); + + // Edit modal + const [editModalProps, setEditModalProps] = useState< + Partial + >({}); + const [editModalOpen, setEditModalOpen] = useState(false); + + const openEditModal = useCallback( + (defaultValues?: LeaveEntry) => () => { + setEditModalProps({ + defaultValues, + onDelete: defaultValues + ? () => { + setValue( + date, + currentEntries.filter((entry) => entry.id !== defaultValues.id), + ); + setEditModalOpen(false); + } + : undefined, + }); + setEditModalOpen(true); + }, + [currentEntries, date, setValue], + ); + + const closeEditModal = useCallback(() => { + setEditModalOpen(false); + }, []); + + const onSaveEntry = useCallback( + (entry: LeaveEntry) => { + const existingEntry = currentEntries.find((e) => e.id === entry.id); + if (existingEntry) { + setValue( + date, + currentEntries.map((e) => ({ + ...(e.id === existingEntry.id ? entry : e), + })), + ); + } else { + setValue(date, [...currentEntries, entry]); + } + setEditModalOpen(false); + }, + [currentEntries, date, setValue], + ); + + return ( + + + {shortDateFormatter(language).format(dayJsObj.toDate())} + + {currentEntries.length ? ( + currentEntries.map((entry, index) => { + return ( + + + + + + {leaveTypeMap[entry.leaveTypeId].name} + + + {manhourFormatter.format(entry.inputHours)} + + + + + + + {entry.remark && ( + + + {t("Remark")} + + {entry.remark} + + )} + + + ); + }) + ) : ( + + {t("Add some leave entries!")} + + )} + + + + + + ); +}; + +export default MobileLeaveEntry; diff --git a/src/components/LeaveTable/MobileLeaveTable.tsx b/src/components/LeaveTable/MobileLeaveTable.tsx new file mode 100644 index 0000000..0bafd30 --- /dev/null +++ b/src/components/LeaveTable/MobileLeaveTable.tsx @@ -0,0 +1,35 @@ +import { + RecordLeaveInput, + RecordTimesheetInput, +} from "@/app/api/timesheets/actions"; +import React from "react"; +import { useFormContext } from "react-hook-form"; +import { LeaveType } from "@/app/api/timesheets"; +import MobileLeaveEntry from "./MobileLeaveEntry"; +import DateHoursList from "../DateHoursTable/DateHoursList"; + +interface Props { + leaveTypes: LeaveType[]; + timesheetRecords: RecordTimesheetInput; +} + +const MobileLeaveTable: React.FC = ({ + timesheetRecords, + leaveTypes, +}) => { + const { watch } = useFormContext(); + const currentInput = watch(); + const days = Object.keys(currentInput); + + return ( + + ); +}; + +export default MobileLeaveTable; diff --git a/src/components/TimesheetModal/TimesheetModal.tsx b/src/components/TimesheetModal/TimesheetModal.tsx index c819fc1..0c0ead9 100644 --- a/src/components/TimesheetModal/TimesheetModal.tsx +++ b/src/components/TimesheetModal/TimesheetModal.tsx @@ -9,18 +9,23 @@ import { ModalProps, SxProps, Typography, + useMediaQuery, + useTheme, } 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"; interface Props { isOpen: boolean; @@ -29,6 +34,7 @@ interface Props { assignedProjects: AssignedProject[]; username: string; defaultTimesheets?: RecordTimesheetInput; + leaveRecords: RecordLeaveInput; } const modalSx: SxProps = { @@ -38,7 +44,7 @@ const modalSx: SxProps = { transform: "translate(-50%, -50%)", width: { xs: "calc(100% - 2rem)", sm: "90%" }, maxHeight: "90%", - maxWidth: 1200, + maxWidth: 1400, }; const TimesheetModal: React.FC = ({ @@ -48,6 +54,7 @@ const TimesheetModal: React.FC = ({ assignedProjects, username, defaultTimesheets, + leaveRecords, }) => { const { t } = useTranslation("home"); @@ -101,44 +108,76 @@ const TimesheetModal: React.FC = ({ [onClose], ); + const theme = useTheme(); + const matches = useMediaQuery(theme.breakpoints.up("sm")); + return ( - - - - + {matches ? ( + // Desktop version + + + + + {t("Timesheet Input")} + + + + + + + + + + + + ) : ( + // Mobile version + + - + {t("Timesheet Input")} - - - - - - - - - - - + + + + )} + ); }; diff --git a/src/components/TimesheetTable/MobileTimesheetEntry.tsx b/src/components/TimesheetTable/MobileTimesheetEntry.tsx new file mode 100644 index 0000000..efaf465 --- /dev/null +++ b/src/components/TimesheetTable/MobileTimesheetEntry.tsx @@ -0,0 +1,40 @@ +import { TimeEntry, RecordTimesheetInput } from "@/app/api/timesheets/actions"; +import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; +import { Add, Edit } from "@mui/icons-material"; +import { + Box, + Button, + Card, + CardContent, + IconButton, + Typography, +} from "@mui/material"; +import dayjs from "dayjs"; +import React from "react"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; + +interface Props { + date: string; + allProjects: ProjectWithTasks[]; + assignedProjects: AssignedProject[]; +} + +const MobileTimesheetEntry: React.FC = ({ + date, + allProjects, + assignedProjects, +}) => { + const { + t, + i18n: { language }, + } = useTranslation("home"); + const dayJsObj = dayjs(date); + const { watch, setValue } = useFormContext(); + const currentEntries = watch(date); + + return null; +}; + +export default MobileTimesheetEntry; diff --git a/src/components/TimesheetTable/MobileTimesheetTable.tsx b/src/components/TimesheetTable/MobileTimesheetTable.tsx new file mode 100644 index 0000000..21fd3aa --- /dev/null +++ b/src/components/TimesheetTable/MobileTimesheetTable.tsx @@ -0,0 +1,37 @@ +import { + RecordLeaveInput, + RecordTimesheetInput, +} from "@/app/api/timesheets/actions"; +import React from "react"; +import { useFormContext } from "react-hook-form"; +import DateHoursList from "../DateHoursTable/DateHoursList"; +import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; +import MobileTimesheetEntry from "./MobileTimesheetEntry"; + +interface Props { + allProjects: ProjectWithTasks[]; + assignedProjects: AssignedProject[]; + leaveRecords: RecordLeaveInput; +} + +const MobileTimesheetTable: React.FC = ({ + allProjects, + assignedProjects, + leaveRecords, +}) => { + const { watch } = useFormContext(); + const currentInput = watch(); + const days = Object.keys(currentInput); + + return ( + + ); +}; + +export default MobileTimesheetTable; diff --git a/src/components/TimesheetTable/TimesheetTable.tsx b/src/components/TimesheetTable/TimesheetTable.tsx index 4a39162..659c488 100644 --- a/src/components/TimesheetTable/TimesheetTable.tsx +++ b/src/components/TimesheetTable/TimesheetTable.tsx @@ -1,146 +1,36 @@ -import { RecordTimesheetInput, TimeEntry } from "@/app/api/timesheets/actions"; -import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; -import { KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; import { - Box, - Collapse, - IconButton, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Typography, -} from "@mui/material"; -import dayjs from "dayjs"; -import React, { useState } from "react"; + RecordLeaveInput, + RecordTimesheetInput, +} from "@/app/api/timesheets/actions"; +import React from "react"; import { useFormContext } from "react-hook-form"; -import { useTranslation } from "react-i18next"; import EntryInputTable from "./EntryInputTable"; import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; -import { TIMESHEET_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils"; +import DateHoursTable from "../DateHoursTable"; interface Props { allProjects: ProjectWithTasks[]; assignedProjects: AssignedProject[]; + leaveRecords: RecordLeaveInput; } -const TimesheetTable: React.FC = ({ allProjects, assignedProjects }) => { - const { t } = useTranslation("home"); - +const TimesheetTable: React.FC = ({ + allProjects, + assignedProjects, + leaveRecords, +}) => { const { watch } = useFormContext(); const currentInput = watch(); const days = Object.keys(currentInput); return ( - - - - - - {t("Date")} - {t("Daily Total Hours")} - - - - {days.map((day, index) => { - const entries = currentInput[day]; - return ( - - ); - })} - -
-
- ); -}; - -const DayRow: React.FC<{ - day: string; - entries: TimeEntry[]; - allProjects: ProjectWithTasks[]; - assignedProjects: AssignedProject[]; -}> = ({ day, entries, allProjects, assignedProjects }) => { - const { - t, - i18n: { language }, - } = useTranslation("home"); - const dayJsObj = dayjs(day); - const [open, setOpen] = useState(false); - - const totalHours = entries.reduce( - (acc, entry) => acc + (entry.inputHours || 0) + (entry.otHours || 0), - 0, - ); - - return ( - <> - - - setOpen(!open)} - > - {open ? : } - - - - {shortDateFormatter(language).format(dayJsObj.toDate())} - - TIMESHEET_DAILY_MAX_HOURS ? "error.main" : undefined, - }} - > - {manhourFormatter.format(totalHours)} - {totalHours > TIMESHEET_DAILY_MAX_HOURS && ( - - {t("(the daily total hours cannot be more than {{hours}})", { - hours: TIMESHEET_DAILY_MAX_HOURS, - })} - - )} - - - - - - - - - - - - + ); }; diff --git a/src/components/UserWorkspacePage/UserWorkspacePage.tsx b/src/components/UserWorkspacePage/UserWorkspacePage.tsx index e5a1d1a..062e233 100644 --- a/src/components/UserWorkspacePage/UserWorkspacePage.tsx +++ b/src/components/UserWorkspacePage/UserWorkspacePage.tsx @@ -88,12 +88,14 @@ const UserWorkspacePage: React.FC = ({ assignedProjects={assignedProjects} username={username} defaultTimesheets={defaultTimesheets} + leaveRecords={defaultLeaveRecords} /> {assignedProjects.length > 0 ? (