| @@ -5,6 +5,7 @@ const withPWA = require("next-pwa")({ | |||||
| dest: "public", | dest: "public", | ||||
| register: true, | register: true, | ||||
| skipWaiting: true, | skipWaiting: true, | ||||
| disable: process.env.NODE_ENV === 'development' | |||||
| }); | }); | ||||
| const nextConfig = { | const nextConfig = { | ||||
| @@ -1,3 +1,5 @@ | |||||
| import { fetchProjectCategories } from "@/app/api/projects"; | |||||
| import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | |||||
| import CreateProject from "@/components/CreateProject"; | import CreateProject from "@/components/CreateProject"; | ||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | import { I18nProvider, getServerI18n } from "@/i18n"; | ||||
| import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
| @@ -10,6 +12,11 @@ export const metadata: Metadata = { | |||||
| const Projects: React.FC = async () => { | const Projects: React.FC = async () => { | ||||
| const { t } = await getServerI18n("projects"); | const { t } = await getServerI18n("projects"); | ||||
| // Preload necessary dependencies | |||||
| fetchAllTasks(); | |||||
| fetchTaskTemplates(); | |||||
| fetchProjectCategories(); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Typography variant="h4">{t("Create Project")}</Typography> | <Typography variant="h4">{t("Create Project")}</Typography> | ||||
| @@ -8,7 +8,7 @@ export interface CreateProjectInputs { | |||||
| // Project details | // Project details | ||||
| projectCode: string; | projectCode: string; | ||||
| projectName: string; | projectName: string; | ||||
| projectCategory: string; | |||||
| projectCategoryId: number; | |||||
| projectDescription: string; | projectDescription: string; | ||||
| // Client details | // Client details | ||||
| @@ -10,7 +10,13 @@ export interface ProjectResult { | |||||
| client: string; | client: string; | ||||
| } | } | ||||
| export interface ProjectCategory { | |||||
| id: number; | |||||
| label: string; | |||||
| } | |||||
| export const preloadProjects = () => { | export const preloadProjects = () => { | ||||
| fetchProjectCategories(); | |||||
| fetchProjects(); | fetchProjects(); | ||||
| }; | }; | ||||
| @@ -18,6 +24,15 @@ export const fetchProjects = cache(async () => { | |||||
| return mockProjects; | return mockProjects; | ||||
| }); | }); | ||||
| export const fetchProjectCategories = cache(async () => { | |||||
| return mockProjectCategories; | |||||
| }); | |||||
| const mockProjectCategories: ProjectCategory[] = [ | |||||
| { id: 1, label: "Confirmed Project" }, | |||||
| { id: 2, label: "Project to be bidded" }, | |||||
| ]; | |||||
| const mockProjects: ProjectResult[] = [ | const mockProjects: ProjectResult[] = [ | ||||
| { | { | ||||
| id: 1, | id: 1, | ||||
| @@ -19,6 +19,7 @@ export interface TaskTemplate { | |||||
| id: number; | id: number; | ||||
| code: string; | code: string; | ||||
| name: string; | name: string; | ||||
| tasks: Task[]; | |||||
| } | } | ||||
| export const preloadTaskTemplates = () => { | export const preloadTaskTemplates = () => { | ||||
| @@ -13,7 +13,7 @@ import ProjectClientDetails from "./ProjectClientDetails"; | |||||
| import TaskSetup from "./TaskSetup"; | import TaskSetup from "./TaskSetup"; | ||||
| import StaffAllocation from "./StaffAllocation"; | import StaffAllocation from "./StaffAllocation"; | ||||
| import ResourceMilestone from "./ResourceMilestone"; | import ResourceMilestone from "./ResourceMilestone"; | ||||
| import { Task } from "@/app/api/tasks"; | |||||
| import { Task, TaskTemplate } from "@/app/api/tasks"; | |||||
| import { | import { | ||||
| FieldErrors, | FieldErrors, | ||||
| FormProvider, | FormProvider, | ||||
| @@ -23,9 +23,12 @@ import { | |||||
| } from "react-hook-form"; | } from "react-hook-form"; | ||||
| import { CreateProjectInputs } from "@/app/api/projects/actions"; | import { CreateProjectInputs } from "@/app/api/projects/actions"; | ||||
| import { Error } from "@mui/icons-material"; | import { Error } from "@mui/icons-material"; | ||||
| import { ProjectCategory } from "@/app/api/projects"; | |||||
| export interface Props { | export interface Props { | ||||
| allTasks: Task[]; | allTasks: Task[]; | ||||
| projectCategories: ProjectCategory[]; | |||||
| taskTemplates: TaskTemplate[]; | |||||
| } | } | ||||
| const hasErrorsInTab = ( | const hasErrorsInTab = ( | ||||
| @@ -40,7 +43,11 @@ const hasErrorsInTab = ( | |||||
| } | } | ||||
| }; | }; | ||||
| const CreateProject: React.FC<Props> = ({ allTasks }) => { | |||||
| const CreateProject: React.FC<Props> = ({ | |||||
| allTasks, | |||||
| projectCategories, | |||||
| taskTemplates, | |||||
| }) => { | |||||
| const [tabIndex, setTabIndex] = useState(0); | const [tabIndex, setTabIndex] = useState(0); | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| @@ -101,8 +108,19 @@ const CreateProject: React.FC<Props> = ({ allTasks }) => { | |||||
| <Tab label={t("Staff Allocation")} iconPosition="end" /> | <Tab label={t("Staff Allocation")} iconPosition="end" /> | ||||
| <Tab label={t("Resource and Milestone")} iconPosition="end" /> | <Tab label={t("Resource and Milestone")} iconPosition="end" /> | ||||
| </Tabs> | </Tabs> | ||||
| {<ProjectClientDetails isActive={tabIndex === 0} />} | |||||
| {<TaskSetup allTasks={allTasks} isActive={tabIndex === 1} />} | |||||
| { | |||||
| <ProjectClientDetails | |||||
| projectCategories={projectCategories} | |||||
| isActive={tabIndex === 0} | |||||
| /> | |||||
| } | |||||
| { | |||||
| <TaskSetup | |||||
| allTasks={allTasks} | |||||
| taskTemplates={taskTemplates} | |||||
| isActive={tabIndex === 1} | |||||
| /> | |||||
| } | |||||
| {<StaffAllocation isActive={tabIndex === 2} />} | {<StaffAllocation isActive={tabIndex === 2} />} | ||||
| {<ResourceMilestone allTasks={allTasks} isActive={tabIndex === 3} />} | {<ResourceMilestone allTasks={allTasks} isActive={tabIndex === 3} />} | ||||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | <Stack direction="row" justifyContent="flex-end" gap={1}> | ||||
| @@ -1,10 +1,19 @@ | |||||
| import { fetchAllTasks } from "@/app/api/tasks"; | |||||
| import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | |||||
| import CreateProject from "./CreateProject"; | import CreateProject from "./CreateProject"; | ||||
| import { fetchProjectCategories } from "@/app/api/projects"; | |||||
| const CreateProjectWrapper: React.FC = async () => { | const CreateProjectWrapper: React.FC = async () => { | ||||
| const tasks = await fetchAllTasks(); | const tasks = await fetchAllTasks(); | ||||
| const taskTemplates = await fetchTaskTemplates(); | |||||
| const projectCategories = await fetchProjectCategories(); | |||||
| return <CreateProject allTasks={tasks} />; | |||||
| return ( | |||||
| <CreateProject | |||||
| allTasks={tasks} | |||||
| projectCategories={projectCategories} | |||||
| taskTemplates={taskTemplates} | |||||
| /> | |||||
| ); | |||||
| }; | }; | ||||
| export default CreateProjectWrapper; | export default CreateProjectWrapper; | ||||
| @@ -15,16 +15,24 @@ import { useTranslation } from "react-i18next"; | |||||
| import CardActions from "@mui/material/CardActions"; | import CardActions from "@mui/material/CardActions"; | ||||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | import RestartAlt from "@mui/icons-material/RestartAlt"; | ||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| import { useFormContext } from "react-hook-form"; | |||||
| import { Controller, useFormContext } from "react-hook-form"; | |||||
| import { CreateProjectInputs } from "@/app/api/projects/actions"; | import { CreateProjectInputs } from "@/app/api/projects/actions"; | ||||
| import { ProjectCategory } from "@/app/api/projects"; | |||||
| const ProjectClientDetails: React.FC<{ isActive: boolean }> = ({ | |||||
| interface Props { | |||||
| isActive: boolean; | |||||
| projectCategories: ProjectCategory[]; | |||||
| } | |||||
| const ProjectClientDetails: React.FC<Props> = ({ | |||||
| isActive, | isActive, | ||||
| projectCategories, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const { | const { | ||||
| register, | register, | ||||
| formState: { errors }, | formState: { errors }, | ||||
| control, | |||||
| } = useFormContext<CreateProjectInputs>(); | } = useFormContext<CreateProjectInputs>(); | ||||
| return ( | return ( | ||||
| @@ -55,14 +63,23 @@ const ProjectClientDetails: React.FC<{ isActive: boolean }> = ({ | |||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <FormControl fullWidth> | <FormControl fullWidth> | ||||
| <InputLabel>{t("Project Category")}</InputLabel> | <InputLabel>{t("Project Category")}</InputLabel> | ||||
| <Select | |||||
| label={t("Project Category")} | |||||
| value={"Temporary Project"} | |||||
| > | |||||
| <MenuItem value={"Temporary Project"}> | |||||
| {t("Temporary Project")} | |||||
| </MenuItem> | |||||
| </Select> | |||||
| <Controller | |||||
| defaultValue={projectCategories[0].id} | |||||
| control={control} | |||||
| name="projectCategoryId" | |||||
| render={({ field }) => ( | |||||
| <Select label={t("Project Category")} {...field}> | |||||
| {projectCategories.map((category, index) => ( | |||||
| <MenuItem | |||||
| key={`${category.id}-${index}`} | |||||
| value={category.id} | |||||
| > | |||||
| {t(category.label)} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| )} | |||||
| /> | |||||
| </FormControl> | </FormControl> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| @@ -81,11 +81,15 @@ const ResourceMilestone: React.FC<Props> = ({ | |||||
| ))} | ))} | ||||
| </Select> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| <ResourceSection | |||||
| tasks={currentTasks} | |||||
| manhourBreakdownByGrade={defaultManhourBreakdownByGrade} | |||||
| /> | |||||
| <MilestoneSection taskGroupId={currentTaskGroupId} /> | |||||
| {/* MUI X-Grid will throw an error if it is rendered without any dimensions; so not rendering when not active */} | |||||
| {isActive && ( | |||||
| <ResourceSection | |||||
| tasks={currentTasks} | |||||
| manhourBreakdownByGrade={defaultManhourBreakdownByGrade} | |||||
| /> | |||||
| )} | |||||
| {/* MUI X-Grid will throw an error if it is rendered without any dimensions; so not rendering when not active */} | |||||
| {isActive && <MilestoneSection taskGroupId={currentTaskGroupId} />} | |||||
| <CardActions sx={{ justifyContent: "flex-end" }}> | <CardActions sx={{ justifyContent: "flex-end" }}> | ||||
| <Button variant="text" startIcon={<RestartAlt />}> | <Button variant="text" startIcon={<RestartAlt />}> | ||||
| {t("Reset")} | {t("Reset")} | ||||
| @@ -7,31 +7,65 @@ import Typography from "@mui/material/Typography"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import TransferList from "../TransferList"; | import TransferList from "../TransferList"; | ||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| import React, { useMemo } from "react"; | |||||
| import React, { useCallback, useMemo, useState } from "react"; | |||||
| import CardActions from "@mui/material/CardActions"; | import CardActions from "@mui/material/CardActions"; | ||||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | import RestartAlt from "@mui/icons-material/RestartAlt"; | ||||
| import FormControl from "@mui/material/FormControl"; | import FormControl from "@mui/material/FormControl"; | ||||
| import Select from "@mui/material/Select"; | |||||
| import Select, { SelectChangeEvent } from "@mui/material/Select"; | |||||
| import MenuItem from "@mui/material/MenuItem"; | import MenuItem from "@mui/material/MenuItem"; | ||||
| import InputLabel from "@mui/material/InputLabel"; | import InputLabel from "@mui/material/InputLabel"; | ||||
| import { Task } from "@/app/api/tasks"; | |||||
| import { Task, TaskTemplate } from "@/app/api/tasks"; | |||||
| import { useFormContext } from "react-hook-form"; | import { useFormContext } from "react-hook-form"; | ||||
| import { CreateProjectInputs } from "@/app/api/projects/actions"; | import { CreateProjectInputs } from "@/app/api/projects/actions"; | ||||
| import isNumber from "lodash/isNumber"; | |||||
| interface Props { | interface Props { | ||||
| allTasks: Task[]; | allTasks: Task[]; | ||||
| taskTemplates: TaskTemplate[]; | |||||
| isActive: boolean; | isActive: boolean; | ||||
| } | } | ||||
| const TaskSetup: React.FC<Props> = ({ allTasks: tasks, isActive }) => { | |||||
| const TaskSetup: React.FC<Props> = ({ | |||||
| allTasks: tasks, | |||||
| taskTemplates, | |||||
| isActive, | |||||
| }) => { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const { getValues, setValue } = useFormContext<CreateProjectInputs>(); | |||||
| const currentTasks = getValues("tasks"); | |||||
| const { setValue, watch } = useFormContext<CreateProjectInputs>(); | |||||
| const currentTasks = watch("tasks"); | |||||
| const items = useMemo( | |||||
| () => tasks.map((t) => ({ id: t.id, label: t.name, group: t.taskGroup })), | |||||
| [tasks], | |||||
| const onReset = useCallback(() => { | |||||
| setValue("tasks", {}); | |||||
| }, [setValue]); | |||||
| const [selectedTaskTemplateId, setSelectedTaskTemplateId] = useState< | |||||
| "All" | number | |||||
| >("All"); | |||||
| const onSelectTaskTemplate = useCallback( | |||||
| (e: SelectChangeEvent<number | "All">) => { | |||||
| if (e.target.value === "All" || isNumber(e.target.value)) { | |||||
| setSelectedTaskTemplateId(e.target.value); | |||||
| onReset(); | |||||
| } | |||||
| }, | |||||
| [onReset], | |||||
| ); | ); | ||||
| const items = useMemo(() => { | |||||
| const taskList = | |||||
| selectedTaskTemplateId === "All" | |||||
| ? tasks | |||||
| : taskTemplates.find( | |||||
| (template) => template.id === selectedTaskTemplateId, | |||||
| )?.tasks || tasks; | |||||
| return taskList.map((t) => ({ | |||||
| id: t.id, | |||||
| label: t.name, | |||||
| group: t.taskGroup, | |||||
| })); | |||||
| }, [tasks, selectedTaskTemplateId, taskTemplates]); | |||||
| const selectedItems = useMemo(() => { | const selectedItems = useMemo(() => { | ||||
| return tasks | return tasks | ||||
| .filter((t) => currentTasks[t.id]) | .filter((t) => currentTasks[t.id]) | ||||
| @@ -53,20 +87,24 @@ const TaskSetup: React.FC<Props> = ({ allTasks: tasks, isActive }) => { | |||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <FormControl fullWidth> | <FormControl fullWidth> | ||||
| <InputLabel>{t("Task List Source")}</InputLabel> | <InputLabel>{t("Task List Source")}</InputLabel> | ||||
| <Select | |||||
| <Select<"All" | number> | |||||
| label={t("Task List Source")} | label={t("Task List Source")} | ||||
| value={"M1009 - Consultancy Project X Temp"} | |||||
| value={selectedTaskTemplateId} | |||||
| onChange={onSelectTaskTemplate} | |||||
| > | > | ||||
| <MenuItem value={"M1009 - Consultancy Project X Temp"}> | |||||
| {"M1009 - Consultancy Project X Temp"} | |||||
| </MenuItem> | |||||
| <MenuItem value={"All"}>{t("All tasks")}</MenuItem> | |||||
| {taskTemplates.map((template, index) => ( | |||||
| <MenuItem key={`${template.id}-${index}`} value={template.id}> | |||||
| {template.name} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| </Grid> | </Grid> | ||||
| </Grid> | </Grid> | ||||
| <TransferList | <TransferList | ||||
| allItems={items} | allItems={items} | ||||
| initiallySelectedItems={selectedItems} | |||||
| selectedItems={selectedItems} | |||||
| onChange={(selectedTasks) => { | onChange={(selectedTasks) => { | ||||
| const newTasks = selectedTasks.reduce<CreateProjectInputs["tasks"]>( | const newTasks = selectedTasks.reduce<CreateProjectInputs["tasks"]>( | ||||
| (acc, item) => { | (acc, item) => { | ||||
| @@ -85,7 +123,7 @@ const TaskSetup: React.FC<Props> = ({ allTasks: tasks, isActive }) => { | |||||
| selectedItemsLabel={t("Project Task List")} | selectedItemsLabel={t("Project Task List")} | ||||
| /> | /> | ||||
| <CardActions sx={{ justifyContent: "flex-end" }}> | <CardActions sx={{ justifyContent: "flex-end" }}> | ||||
| <Button variant="text" startIcon={<RestartAlt />}> | |||||
| <Button variant="text" startIcon={<RestartAlt />} onClick={onReset}> | |||||
| {t("Reset")} | {t("Reset")} | ||||
| </Button> | </Button> | ||||
| </CardActions> | </CardActions> | ||||
| @@ -10,10 +10,10 @@ const LoginPage = () => { | |||||
| <Grid item sm sx={{ backgroundColor: 'neutral.900'}}> | <Grid item sm sx={{ backgroundColor: 'neutral.900'}}> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={12} sm={8} lg={5}> | <Grid item xs={12} sm={8} lg={5}> | ||||
| <Box sx={{ width: '100%', padding: 5, paddingBlockStart: 10, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', svg: { maxHeight: 120 } }}> | |||||
| <Logo /> | |||||
| </Box> | |||||
| <Paper square sx={{ height: "100%" }}> | <Paper square sx={{ height: "100%" }}> | ||||
| <Box sx={{ width: '100%', padding: 5, paddingBlockStart: 10, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', svg: { maxHeight: 120 } }}> | |||||
| <Logo /> | |||||
| </Box> | |||||
| <LoginForm /> | <LoginForm /> | ||||
| </Paper> | </Paper> | ||||
| </Grid> | </Grid> | ||||
| @@ -1,23 +1,24 @@ | |||||
| "use client"; | "use client"; | ||||
| import { ProjectResult } from "@/app/api/projects"; | |||||
| import { ProjectCategory, ProjectResult } from "@/app/api/projects"; | |||||
| import React, { useCallback, useMemo, useState } from "react"; | import React, { useCallback, useMemo, useState } from "react"; | ||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import SearchResults, { Column } from "../SearchResults"; | import SearchResults, { Column } from "../SearchResults"; | ||||
| import EditNote from "@mui/icons-material/EditNote"; | import EditNote from "@mui/icons-material/EditNote"; | ||||
| import uniq from 'lodash/uniq'; | |||||
| interface Props { | interface Props { | ||||
| projects: ProjectResult[]; | projects: ProjectResult[]; | ||||
| projectCategories: ProjectCategory[]; | |||||
| } | } | ||||
| type SearchQuery = Partial<Omit<ProjectResult, "id">>; | type SearchQuery = Partial<Omit<ProjectResult, "id">>; | ||||
| type SearchParamNames = keyof SearchQuery; | type SearchParamNames = keyof SearchQuery; | ||||
| const ProjectSearch: React.FC<Props> = ({ projects }) => { | |||||
| const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||||
| const { t } = useTranslation("projects"); | const { t } = useTranslation("projects"); | ||||
| // If project searching is done on the server-side, then no need for this. | |||||
| const [filteredProjects, setFilteredProjects] = useState(projects); | const [filteredProjects, setFilteredProjects] = useState(projects); | ||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | ||||
| @@ -28,22 +29,22 @@ const ProjectSearch: React.FC<Props> = ({ projects }) => { | |||||
| label: t("Client name"), | label: t("Client name"), | ||||
| paramName: "client", | paramName: "client", | ||||
| type: "select", | type: "select", | ||||
| options: ["Client A", "Client B", "Client C"], | |||||
| options: uniq(projects.map((project) => project.client)), | |||||
| }, | }, | ||||
| { | { | ||||
| label: t("Project category"), | label: t("Project category"), | ||||
| paramName: "category", | paramName: "category", | ||||
| type: "select", | type: "select", | ||||
| options: ["Confirmed Project", "Project to be bidded"], | |||||
| options: projectCategories.map((category) => category.label), | |||||
| }, | }, | ||||
| { | { | ||||
| label: t("Team"), | label: t("Team"), | ||||
| paramName: "team", | paramName: "team", | ||||
| type: "select", | type: "select", | ||||
| options: ["TW", "WY"], | |||||
| options: uniq(projects.map((project) => project.team)), | |||||
| }, | }, | ||||
| ], | ], | ||||
| [t], | |||||
| [t, projectCategories, projects], | |||||
| ); | ); | ||||
| const onReset = useCallback(() => { | const onReset = useCallback(() => { | ||||
| @@ -1,4 +1,4 @@ | |||||
| import { fetchProjects } from "@/app/api/projects"; | |||||
| import { fetchProjectCategories, fetchProjects } from "@/app/api/projects"; | |||||
| import React from "react"; | import React from "react"; | ||||
| import ProjectSearch from "./ProjectSearch"; | import ProjectSearch from "./ProjectSearch"; | ||||
| import ProjectSearchLoading from "./ProjectSearchLoading"; | import ProjectSearchLoading from "./ProjectSearchLoading"; | ||||
| @@ -8,9 +8,10 @@ interface SubComponents { | |||||
| } | } | ||||
| const ProjectSearchWrapper: React.FC & SubComponents = async () => { | const ProjectSearchWrapper: React.FC & SubComponents = async () => { | ||||
| const projectCategories = await fetchProjectCategories(); | |||||
| const projects = await fetchProjects(); | const projects = await fetchProjects(); | ||||
| return <ProjectSearch projects={projects} />; | |||||
| return <ProjectSearch projects={projects} projectCategories={projectCategories} />; | |||||
| }; | }; | ||||
| ProjectSearchWrapper.Loading = ProjectSearchLoading; | ProjectSearchWrapper.Loading = ProjectSearchLoading; | ||||
| @@ -11,11 +11,11 @@ import { | |||||
| ListSubheader, | ListSubheader, | ||||
| MenuItem, | MenuItem, | ||||
| Select, | Select, | ||||
| SelectProps, | |||||
| SelectChangeEvent, | |||||
| Stack, | Stack, | ||||
| Typography, | Typography, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import React, { useCallback, useEffect, useState } from "react"; | |||||
| import React, { useCallback } from "react"; | |||||
| import { LabelGroup, LabelWithId, TransferListProps } from "./TransferList"; | import { LabelGroup, LabelWithId, TransferListProps } from "./TransferList"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import uniqBy from "lodash/uniqBy"; | import uniqBy from "lodash/uniqBy"; | ||||
| @@ -23,7 +23,7 @@ import groupBy from "lodash/groupBy"; | |||||
| export const MultiSelectList: React.FC<TransferListProps> = ({ | export const MultiSelectList: React.FC<TransferListProps> = ({ | ||||
| allItems, | allItems, | ||||
| initiallySelectedItems, | |||||
| selectedItems, | |||||
| selectedItemsLabel, | selectedItemsLabel, | ||||
| allItemsLabel, | allItemsLabel, | ||||
| onChange, | onChange, | ||||
| @@ -39,33 +39,31 @@ export const MultiSelectList: React.FC<TransferListProps> = ({ | |||||
| (a: number, b: number) => sortMap[a].index - sortMap[b].index, | (a: number, b: number) => sortMap[a].index - sortMap[b].index, | ||||
| [sortMap], | [sortMap], | ||||
| ); | ); | ||||
| const [selectedItems, setSelectedItems] = useState( | |||||
| initiallySelectedItems.map((item) => item.id), | |||||
| const handleChange = useCallback( | |||||
| (event: SelectChangeEvent<number[]>) => { | |||||
| const { | |||||
| target: { value }, | |||||
| } = event; | |||||
| const selectedValues = | |||||
| typeof value === "string" ? [Number(value)] : value; | |||||
| onChange(allItems.filter((item) => selectedValues.includes(item.id))); | |||||
| }, | |||||
| [allItems, onChange], | |||||
| ); | ); | ||||
| const handleChange = useCallback< | |||||
| NonNullable<SelectProps<typeof selectedItems>["onChange"]> | |||||
| >((event) => { | |||||
| const { | |||||
| target: { value }, | |||||
| } = event; | |||||
| setSelectedItems(typeof value === "string" ? [Number(value)] : value); | |||||
| }, []); | |||||
| const handleToggleAll = useCallback( | const handleToggleAll = useCallback( | ||||
| () => () => { | () => () => { | ||||
| if (selectedItems.length === allItems.length) { | if (selectedItems.length === allItems.length) { | ||||
| setSelectedItems([]); | |||||
| onChange([]); | |||||
| } else { | } else { | ||||
| setSelectedItems(allItems.map((item) => item.id)); | |||||
| onChange(allItems); | |||||
| } | } | ||||
| }, | }, | ||||
| [allItems, selectedItems.length], | |||||
| [allItems, onChange, selectedItems.length], | |||||
| ); | ); | ||||
| useEffect(() => { | |||||
| onChange(selectedItems.map((item) => sortMap[item])); | |||||
| }, [onChange, selectedItems, sortMap]); | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const groups: LabelGroup[] = uniqBy( | const groups: LabelGroup[] = uniqBy( | ||||
| [ | [ | ||||
| @@ -85,7 +83,7 @@ export const MultiSelectList: React.FC<TransferListProps> = ({ | |||||
| <InputLabel>{selectedItemsLabel}</InputLabel> | <InputLabel>{selectedItemsLabel}</InputLabel> | ||||
| <Select | <Select | ||||
| multiple | multiple | ||||
| value={selectedItems} | |||||
| value={selectedItems.map((item) => item.id)} | |||||
| onChange={handleChange} | onChange={handleChange} | ||||
| renderValue={(values) => { | renderValue={(values) => { | ||||
| return ( | return ( | ||||
| @@ -153,7 +151,11 @@ export const MultiSelectList: React.FC<TransferListProps> = ({ | |||||
| return ( | return ( | ||||
| <MenuItem key={item.id} value={item.id} disableRipple> | <MenuItem key={item.id} value={item.id} disableRipple> | ||||
| <Checkbox | <Checkbox | ||||
| checked={selectedItems.includes(item.id)} | |||||
| checked={Boolean( | |||||
| selectedItems.find( | |||||
| (selected) => selected.id === item.id, | |||||
| ), | |||||
| )} | |||||
| disableRipple | disableRipple | ||||
| /> | /> | ||||
| <ListItemText sx={{ whiteSpace: "normal" }}> | <ListItemText sx={{ whiteSpace: "normal" }}> | ||||
| @@ -33,7 +33,7 @@ export interface LabelWithId { | |||||
| export interface TransferListProps { | export interface TransferListProps { | ||||
| allItems: LabelWithId[]; | allItems: LabelWithId[]; | ||||
| initiallySelectedItems: LabelWithId[]; | |||||
| selectedItems: LabelWithId[]; | |||||
| onChange: (selectedItems: LabelWithId[]) => void; | onChange: (selectedItems: LabelWithId[]) => void; | ||||
| allItemsLabel: string; | allItemsLabel: string; | ||||
| selectedItemsLabel: string; | selectedItemsLabel: string; | ||||
| @@ -144,7 +144,7 @@ const ItemList: React.FC<ItemListProps> = ({ | |||||
| const TransferList: React.FC<TransferListProps> = ({ | const TransferList: React.FC<TransferListProps> = ({ | ||||
| allItems, | allItems, | ||||
| initiallySelectedItems, | |||||
| selectedItems, | |||||
| allItemsLabel, | allItemsLabel, | ||||
| selectedItemsLabel, | selectedItemsLabel, | ||||
| onChange, | onChange, | ||||
| @@ -163,12 +163,15 @@ const TransferList: React.FC<TransferListProps> = ({ | |||||
| const [checkedList, setCheckedList] = React.useState<LabelWithId[]>([]); | const [checkedList, setCheckedList] = React.useState<LabelWithId[]>([]); | ||||
| const [leftList, setLeftList] = React.useState<LabelWithId[]>( | const [leftList, setLeftList] = React.useState<LabelWithId[]>( | ||||
| differenceBy(allItems, initiallySelectedItems, "id"), | |||||
| ); | |||||
| const [rightList, setRightList] = React.useState<LabelWithId[]>( | |||||
| initiallySelectedItems, | |||||
| differenceBy(allItems, selectedItems, "id"), | |||||
| ); | ); | ||||
| React.useEffect(() => { | |||||
| setLeftList(differenceBy(allItems, selectedItems, "id")); | |||||
| }, [allItems, selectedItems]); | |||||
| const rightList = selectedItems; | |||||
| const leftListChecked = intersection(checkedList, leftList); | const leftListChecked = intersection(checkedList, leftList); | ||||
| const rightListChecked = intersection(checkedList, rightList); | const rightListChecked = intersection(checkedList, rightList); | ||||
| @@ -196,23 +199,17 @@ const TransferList: React.FC<TransferListProps> = ({ | |||||
| ); | ); | ||||
| const handleCheckedRight = () => { | const handleCheckedRight = () => { | ||||
| setRightList([...rightList, ...leftListChecked].sort(compareFn)); | |||||
| onChange([...selectedItems, ...leftListChecked].sort(compareFn)); | |||||
| setLeftList(differenceBy(leftList, leftListChecked, "id").sort(compareFn)); | setLeftList(differenceBy(leftList, leftListChecked, "id").sort(compareFn)); | ||||
| setCheckedList(differenceBy(checkedList, leftListChecked, "id")); | setCheckedList(differenceBy(checkedList, leftListChecked, "id")); | ||||
| }; | }; | ||||
| const handleCheckedLeft = () => { | const handleCheckedLeft = () => { | ||||
| setLeftList([...leftList, ...rightListChecked].sort(compareFn)); | setLeftList([...leftList, ...rightListChecked].sort(compareFn)); | ||||
| setRightList( | |||||
| differenceBy(rightList, rightListChecked, "id").sort(compareFn), | |||||
| ); | |||||
| onChange(differenceBy(rightList, rightListChecked, "id").sort(compareFn)); | |||||
| setCheckedList(differenceBy(checkedList, rightListChecked, "id")); | setCheckedList(differenceBy(checkedList, rightListChecked, "id")); | ||||
| }; | }; | ||||
| React.useEffect(() => { | |||||
| onChange(rightList); | |||||
| }, [onChange, rightList]); | |||||
| return ( | return ( | ||||
| <Stack spacing={2} direction="row" alignItems="center" position="relative"> | <Stack spacing={2} direction="row" alignItems="center" position="relative"> | ||||
| <ItemList | <ItemList | ||||