From 812d73011c420251f5173ad35286c379f61c5237 Mon Sep 17 00:00:00 2001 From: Wayne Date: Thu, 1 Feb 2024 14:07:44 +0900 Subject: [PATCH 1/2] Add staff allocation and api calls --- src/app/(main)/tasks/create/page.tsx | 2 + src/app/api/tasks/actions.ts | 19 ++ src/app/api/tasks/index.ts | 42 +-- src/app/logout/page.tsx | 7 + src/app/utils/fetchUtil.ts | 46 ++++ .../CreateProject/CreateProject.tsx | 4 + .../CreateProject/ResourceMilestone.tsx | 31 +++ .../CreateProject/StaffAllocation.tsx | 252 ++++++++++++++++++ .../CreateTaskTemplate/CreateTaskTemplate.tsx | 99 +++++-- .../CreateTaskTemplateWrapper.tsx | 11 + src/components/CreateTaskTemplate/index.ts | 2 +- src/components/LogoutPage/LogoutPage.tsx | 17 ++ src/components/LogoutPage/index.ts | 1 + .../ProjectSearch/ProjectSearch.tsx | 16 +- src/components/SearchBox/SearchBox.tsx | 8 +- .../SearchResults/SearchResults.tsx | 54 +++- .../TaskTemplateSearch/TaskTemplateSearch.tsx | 38 ++- .../TransferList/MultiSelectList.tsx | 62 +++-- src/components/TransferList/TransferList.tsx | 62 ++++- src/config/api.ts | 4 +- src/config/authConfig.ts | 2 +- src/middleware.ts | 2 +- 22 files changed, 686 insertions(+), 95 deletions(-) create mode 100644 src/app/api/tasks/actions.ts create mode 100644 src/app/logout/page.tsx create mode 100644 src/app/utils/fetchUtil.ts create mode 100644 src/components/CreateProject/ResourceMilestone.tsx create mode 100644 src/components/CreateProject/StaffAllocation.tsx create mode 100644 src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx create mode 100644 src/components/LogoutPage/LogoutPage.tsx create mode 100644 src/components/LogoutPage/index.ts diff --git a/src/app/(main)/tasks/create/page.tsx b/src/app/(main)/tasks/create/page.tsx index 5877958..656139f 100644 --- a/src/app/(main)/tasks/create/page.tsx +++ b/src/app/(main)/tasks/create/page.tsx @@ -1,3 +1,4 @@ +import { preloadAllTasks } from "@/app/api/tasks"; import CreateTaskTemplate from "@/components/CreateTaskTemplate"; import { getServerI18n } from "@/i18n"; import Typography from "@mui/material/Typography"; @@ -9,6 +10,7 @@ export const metadata: Metadata = { const Projects: React.FC = async () => { const { t } = await getServerI18n("tasks"); + preloadAllTasks(); return ( <> diff --git a/src/app/api/tasks/actions.ts b/src/app/api/tasks/actions.ts new file mode 100644 index 0000000..59c1737 --- /dev/null +++ b/src/app/api/tasks/actions.ts @@ -0,0 +1,19 @@ +"use server"; + +import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { TaskTemplate } from "."; + +export interface NewTaskTemplateFormInputs { + code: string; + name: string; + taskIds: number[]; +} + +export const saveTaskTemplate = async (data: NewTaskTemplateFormInputs) => { + return serverFetchJson(`${BASE_API_URL}/tasks/templates/new`, { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); +}; diff --git a/src/app/api/tasks/index.ts b/src/app/api/tasks/index.ts index 1333756..a9dae82 100644 --- a/src/app/api/tasks/index.ts +++ b/src/app/api/tasks/index.ts @@ -1,7 +1,21 @@ +import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; import { cache } from "react"; import "server-only"; -export interface TaskTemplateResult { +export interface TaskGroup { + id: number; + name: string; +} + +export interface Task { + id: number; + name: string; + description: string | null; + taskGroup: TaskGroup | null; +} + +export interface TaskTemplate { id: number; code: string; name: string; @@ -12,23 +26,13 @@ export const preloadTaskTemplates = () => { }; export const fetchTaskTemplates = cache(async () => { - return mockProjects; + return serverFetchJson(`${BASE_API_URL}/tasks/templates`); }); -const mockProjects: TaskTemplateResult[] = [ - { - id: 1, - code: "Pre-001", - name: "Pre-contract Template", - }, - { - id: 2, - code: "Post-001", - name: "Post-contract Template", - }, - { - id: 3, - code: "Full-001", - name: "Full Project Template", - }, -]; +export const preloadAllTasks = () => { + fetchAllTasks(); +}; + +export const fetchAllTasks = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/tasks`); +}); diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx new file mode 100644 index 0000000..cbaeefd --- /dev/null +++ b/src/app/logout/page.tsx @@ -0,0 +1,7 @@ +import LogoutPage from "@/components/LogoutPage"; + +const Logout: React.FC = async () => { + return ; +}; + +export default Logout; diff --git a/src/app/utils/fetchUtil.ts b/src/app/utils/fetchUtil.ts new file mode 100644 index 0000000..718dd30 --- /dev/null +++ b/src/app/utils/fetchUtil.ts @@ -0,0 +1,46 @@ +import { SessionWithTokens, authOptions } from "@/config/authConfig"; +import { getServerSession } from "next-auth"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +export const serverFetch: typeof fetch = async (input, init) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = await getServerSession(authOptions); + const accessToken = session?.accessToken; + + return fetch(input, { + ...init, + headers: { + ...init?.headers, + ...(accessToken + ? { + Authorization: `Bearer ${accessToken}`, + } + : {}), + }, + }); +}; + +type FetchParams = Parameters; + +export async function serverFetchJson(...args: FetchParams) { + const response = await serverFetch(...args); + if (response.ok) { + return response.json() as T; + } else { + switch (response.status) { + case 401: + signOutUser(); + default: + throw Error("Something went wrong fetching data in server."); + } + } +} + +export const signOutUser = () => { + const headersList = headers(); + const referer = headersList.get("referer"); + redirect( + `/logout${referer ? `?callbackUrl=${encodeURIComponent(referer)}` : ""}`, + ); +}; diff --git a/src/components/CreateProject/CreateProject.tsx b/src/components/CreateProject/CreateProject.tsx index c8bce51..e118304 100644 --- a/src/components/CreateProject/CreateProject.tsx +++ b/src/components/CreateProject/CreateProject.tsx @@ -11,6 +11,8 @@ import React, { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import ProjectClientDetails from "./ProjectClientDetails"; import TaskSetup from "./TaskSetup"; +import StaffAllocation from "./StaffAllocation"; +import ResourceMilestone from "./ResourceMilestone"; const CreateProject: React.FC = () => { const [tabIndex, setTabIndex] = useState(0); @@ -38,6 +40,8 @@ const CreateProject: React.FC = () => { {tabIndex === 0 && } {tabIndex === 1 && } + {tabIndex === 2 && } + {tabIndex === 3 && } + + + + ); +}; + +export default ResourceMilestone; diff --git a/src/components/CreateProject/StaffAllocation.tsx b/src/components/CreateProject/StaffAllocation.tsx new file mode 100644 index 0000000..c408b57 --- /dev/null +++ b/src/components/CreateProject/StaffAllocation.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import React from "react"; +import RestartAlt from "@mui/icons-material/RestartAlt"; +import SearchResults, { Column } from "../SearchResults"; +import { Search, Clear, PersonAdd, PersonRemove } from "@mui/icons-material"; +import { + Stack, + Typography, + Grid, + TextField, + InputAdornment, + IconButton, + FormControl, + InputLabel, + Select, + MenuItem, + Box, + Button, + Card, + CardActions, + CardContent, + TabsProps, + Tab, + Tabs, +} from "@mui/material"; +import differenceBy from "lodash/differenceBy"; + +interface StaffResult { + id: string; + name: string; + team: string; + grade: string; + title: string; +} + +const mockStaffs: StaffResult[] = [ + { + name: "Albert", + grade: "1", + id: "1", + team: "ABC", + title: "Associate Quantity Surveyor", + }, + { + name: "Bernard", + grade: "2", + id: "2", + team: "ABC", + title: "Quantity Surveyor", + }, + { + name: "Carl", + grade: "3", + id: "3", + team: "XYZ", + title: "Senior Quantity Surveyor", + }, + { name: "Denis", grade: "4", id: "4", team: "ABC", title: "Manager" }, + { name: "Edward", grade: "5", id: "5", team: "ABC", title: "Director" }, + { name: "Fred", grade: "1", id: "6", team: "XYZ", title: "General Laborer" }, + { name: "Gordon", grade: "2", id: "7", team: "ABC", title: "Inspector" }, + { + name: "Heather", + grade: "3", + id: "8", + team: "XYZ", + title: "Field Engineer", + }, + { name: "Ivan", grade: "4", id: "9", team: "ABC", title: "Senior Manager" }, + { + name: "Jackson", + grade: "5", + id: "10", + team: "XYZ", + title: "Senior Director", + }, + { + name: "Kurt", + grade: "1", + id: "11", + team: "ABC", + title: "Construction Assistant", + }, + { name: "Lawrence", grade: "2", id: "12", team: "ABC", title: "Operator" }, +]; + +interface Props { + allStaff?: StaffResult[]; + initiallySelectedStaff: StaffResult[]; +} + +const StaffAllocation: React.FC = ({ + allStaff = mockStaffs, + initiallySelectedStaff, +}) => { + const { t } = useTranslation(); + const [filteredStaff, setFilteredStaff] = React.useState(allStaff); + const [selectedStaff, setSelectedStaff] = React.useState< + typeof filteredStaff + >(initiallySelectedStaff); + const filters = React.useMemo<(keyof StaffResult)[]>( + () => ["team", "grade"], + [], + ); + const addStaff = React.useCallback((staff: StaffResult) => { + setSelectedStaff((staffs) => [...staffs, staff]); + }, []); + const removeStaff = React.useCallback((staff: StaffResult) => { + setSelectedStaff((staffs) => staffs.filter((s) => s.id !== staff.id)); + }, []); + + const staffPoolColumns = React.useMemo[]>( + () => [ + { + label: t("Add"), + name: "id", + onClick: addStaff, + buttonIcon: , + }, + { label: t("Staff ID"), name: "id" }, + { label: t("Staff Name"), name: "name" }, + { label: t("Team"), name: "team" }, + { label: t("Grade"), name: "grade" }, + { label: t("Title"), name: "title" }, + ], + [addStaff, t], + ); + + const allocatedStaffColumns = React.useMemo[]>( + () => [ + { + label: t("Remove"), + name: "id", + onClick: removeStaff, + buttonIcon: , + }, + { label: t("Staff ID"), name: "id" }, + { label: t("Staff Name"), name: "name" }, + { label: t("Team"), name: "team" }, + { label: t("Grade"), name: "grade" }, + { label: t("Title"), name: "title" }, + ], + [removeStaff, 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( + allStaff.filter( + (staff) => + staff.name.toLowerCase().includes(query) || + staff.id.toLowerCase().includes(query) || + staff.title.toLowerCase().includes(query), + ), + ); + }, [allStaff, query]); + + const [tabIndex, setTabIndex] = React.useState(0); + const handleTabChange = React.useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [], + ); + + return ( + + + + + {t("Staff Allocation")} + + + + + + + + + + ), + }} + /> + + {filters.map((filter, idx) => { + const label = staffPoolColumns.find( + (c) => c.name === filter, + )!.label; + + return ( + + + {label} + + + + ); + })} + + + + + + + {tabIndex === 0 && ( + + )} + {tabIndex === 1 && ( + + )} + + + + + + + + ); +}; + +export default StaffAllocation; diff --git a/src/components/CreateTaskTemplate/CreateTaskTemplate.tsx b/src/components/CreateTaskTemplate/CreateTaskTemplate.tsx index ba6cded..2f5543e 100644 --- a/src/components/CreateTaskTemplate/CreateTaskTemplate.tsx +++ b/src/components/CreateTaskTemplate/CreateTaskTemplate.tsx @@ -13,8 +13,18 @@ import Close from "@mui/icons-material/Close"; import { useRouter } from "next/navigation"; import React from "react"; import Stack from "@mui/material/Stack"; +import { Task } from "@/app/api/tasks"; +import { + NewTaskTemplateFormInputs, + saveTaskTemplate, +} from "@/app/api/tasks/actions"; +import { SubmitHandler, useForm } from "react-hook-form"; -const CreateTaskTemplate = () => { +interface Props { + tasks: Task[]; +} + +const CreateTaskTemplate: React.FC = ({ tasks }) => { const { t } = useTranslation(); const router = useRouter(); @@ -22,8 +32,40 @@ const CreateTaskTemplate = () => { router.back(); }; + const items = React.useMemo( + () => + tasks.map((task) => ({ + id: task.id, + label: task.name, + group: task.taskGroup || undefined, + })), + [tasks], + ); + + const [serverError, setServerError] = React.useState(""); + + const { + register, + handleSubmit, + setValue, + formState: { errors, isSubmitting }, + } = useForm(); + + const onSubmit: SubmitHandler = React.useCallback( + async (data) => { + try { + setServerError(""); + await saveTaskTemplate(data); + router.replace("/tasks"); + } catch (e) { + setServerError(t("An error has occurred. Please try again later.")); + } + }, + [router, t], + ); + return ( - <> + {t("Task List Setup")} @@ -34,40 +76,61 @@ const CreateTaskTemplate = () => { marginBlockEnd={1} > - + - + {}} + onChange={(selectedItems) => { + setValue( + "taskIds", + selectedItems.map((item) => item.id), + ); + }} allItemsLabel={t("Task Pool")} selectedItemsLabel={t("Task List Template")} /> + {serverError && ( + + {serverError} + + )} - - + ); }; diff --git a/src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx b/src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx new file mode 100644 index 0000000..77888a2 --- /dev/null +++ b/src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import CreateTaskTemplate from "./CreateTaskTemplate"; +import { fetchAllTasks } from "@/app/api/tasks"; + +const CreateTaskTemplateWrapper: React.FC = async () => { + const tasks = await fetchAllTasks(); + + return ; +}; + +export default CreateTaskTemplateWrapper; diff --git a/src/components/CreateTaskTemplate/index.ts b/src/components/CreateTaskTemplate/index.ts index c5cffd5..e52ead3 100644 --- a/src/components/CreateTaskTemplate/index.ts +++ b/src/components/CreateTaskTemplate/index.ts @@ -1 +1 @@ -export { default } from "./CreateTaskTemplate"; +export { default } from "./CreateTaskTemplateWrapper"; diff --git a/src/components/LogoutPage/LogoutPage.tsx b/src/components/LogoutPage/LogoutPage.tsx new file mode 100644 index 0000000..7cc220d --- /dev/null +++ b/src/components/LogoutPage/LogoutPage.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { signOut } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +const LogoutPage = () => { + const params = useSearchParams(); + const callbackUrl = params.get("callbackUrl"); + useEffect(() => { + signOut({ redirect: true, callbackUrl: callbackUrl || "/" }); + }); + + return null; +}; + +export default LogoutPage; diff --git a/src/components/LogoutPage/index.ts b/src/components/LogoutPage/index.ts new file mode 100644 index 0000000..8efcfe6 --- /dev/null +++ b/src/components/LogoutPage/index.ts @@ -0,0 +1 @@ +export { default } from "./LogoutPage"; diff --git a/src/components/ProjectSearch/ProjectSearch.tsx b/src/components/ProjectSearch/ProjectSearch.tsx index 8c102c3..436a4fb 100644 --- a/src/components/ProjectSearch/ProjectSearch.tsx +++ b/src/components/ProjectSearch/ProjectSearch.tsx @@ -1,10 +1,11 @@ "use client"; import { ProjectResult } from "@/app/api/projects"; -import React, { useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import SearchBox, { Criterion } from "../SearchBox"; import { useTranslation } from "react-i18next"; import SearchResults, { Column } from "../SearchResults"; +import EditNote from "@mui/icons-material/EditNote"; interface Props { projects: ProjectResult[]; @@ -45,16 +46,25 @@ const ProjectSearch: React.FC = ({ projects }) => { [t], ); + const onProjectClick = useCallback((project: ProjectResult) => { + console.log(project); + }, []); + const columns = useMemo[]>( () => [ - { name: "id", label: t("Details") }, + { + name: "id", + label: t("Details"), + onClick: onProjectClick, + buttonIcon: , + }, { name: "code", label: t("Project Code") }, { name: "name", label: t("Project Name") }, { name: "category", label: t("Project Category") }, { name: "team", label: t("Team") }, { name: "client", label: t("Client") }, ], - [t], + [t, onProjectClick], ); return ( diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index 24c03e8..f5dd50c 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -35,9 +35,14 @@ export type Criterion = TextCriterion | SelectCriterion; interface Props { criteria: Criterion[]; onSearch: (inputs: Record) => void; + onReset?: () => void; } -function SearchBox({ criteria, onSearch }: Props) { +function SearchBox({ + criteria, + onSearch, + onReset, +}: Props) { const { t } = useTranslation("common"); const defaultInputs = useMemo( () => @@ -68,6 +73,7 @@ function SearchBox({ criteria, onSearch }: Props) { const handleReset = () => { setInputs(defaultInputs); + onReset?.(); }; const handleSearch = () => { diff --git a/src/components/SearchResults/SearchResults.tsx b/src/components/SearchResults/SearchResults.tsx index f62280c..4c82280 100644 --- a/src/components/SearchResults/SearchResults.tsx +++ b/src/components/SearchResults/SearchResults.tsx @@ -12,23 +12,42 @@ import TablePagination, { } from "@mui/material/TablePagination"; import TableRow from "@mui/material/TableRow"; import IconButton from "@mui/material/IconButton"; -import EditNote from "@mui/icons-material/EditNote"; -interface ResultWithId { +export interface ResultWithId { id: string | number; } -export interface Column { +interface BaseColumn { name: keyof T; label: string; } +interface ColumnWithAction extends BaseColumn { + onClick: (item: T) => void; + buttonIcon: React.ReactNode; +} + +export type Column = + | BaseColumn + | ColumnWithAction; + interface Props { items: T[]; columns: Column[]; + noWrapper?: boolean; } -function SearchResults({ items, columns }: Props) { +function isActionColumn( + column: Column, +): column is ColumnWithAction { + return Boolean((column as ColumnWithAction).onClick); +} + +function SearchResults({ + items, + columns, + noWrapper, +}: Props) { const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); @@ -46,14 +65,14 @@ function SearchResults({ items, columns }: Props) { setPage(0); }; - return ( - + const table = ( + <> - {columns.map((column) => ( - + {columns.map((column, idx) => ( + {column.label} ))} @@ -65,12 +84,17 @@ function SearchResults({ items, columns }: Props) { .map((item) => { return ( - {columns.map(({ name: columnName }) => { + {columns.map((column, idx) => { + const columnName = column.name; + return ( - - {columnName === "id" ? ( - - + + {isActionColumn(column) ? ( + column.onClick(item)} + > + {column.buttonIcon} ) : ( <>{item[columnName]} @@ -93,8 +117,10 @@ function SearchResults({ items, columns }: Props) { onPageChange={handleChangePage} onRowsPerPageChange={handleChangeRowsPerPage} /> - + ); + + return noWrapper ? table : {table}; } export default SearchResults; diff --git a/src/components/TaskTemplateSearch/TaskTemplateSearch.tsx b/src/components/TaskTemplateSearch/TaskTemplateSearch.tsx index cdc10c1..e606e03 100644 --- a/src/components/TaskTemplateSearch/TaskTemplateSearch.tsx +++ b/src/components/TaskTemplateSearch/TaskTemplateSearch.tsx @@ -1,24 +1,23 @@ "use client"; -import { TaskTemplateResult } from "@/app/api/tasks"; -import React, { useMemo, useState } from "react"; +import { TaskTemplate } from "@/app/api/tasks"; +import React, { useCallback, useMemo, useState } from "react"; import SearchBox, { Criterion } from "../SearchBox"; import { useTranslation } from "react-i18next"; import SearchResults, { Column } from "../SearchResults"; +import EditNote from "@mui/icons-material/EditNote"; interface Props { - taskTemplates: TaskTemplateResult[]; + taskTemplates: TaskTemplate[]; } -type SearchQuery = Partial>; +type SearchQuery = Partial>; type SearchParamNames = keyof SearchQuery; const TaskTemplateSearch: React.FC = ({ taskTemplates }) => { const { t } = useTranslation("tasks"); - // If task searching is done on the server-side, then no need for this. const [filteredTemplates, setFilteredTemplates] = useState(taskTemplates); - const searchCriteria: Criterion[] = useMemo( () => [ { label: t("Task Template Code"), paramName: "code", type: "text" }, @@ -26,14 +25,26 @@ const TaskTemplateSearch: React.FC = ({ taskTemplates }) => { ], [t], ); + const onReset = useCallback(() => { + setFilteredTemplates(taskTemplates); + }, [taskTemplates]); + + const onTaskClick = useCallback((taskTemplate: TaskTemplate) => { + console.log(taskTemplate); + }, []); - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ - { name: "id", label: t("Details") }, + { + name: "id", + label: t("Details"), + onClick: onTaskClick, + buttonIcon: , + }, { name: "code", label: t("Task Template Code") }, { name: "name", label: t("Task Template Name") }, ], - [t], + [onTaskClick, t], ); return ( @@ -41,8 +52,15 @@ const TaskTemplateSearch: React.FC = ({ taskTemplates }) => { { - console.log(query); + setFilteredTemplates( + taskTemplates.filter( + (task) => + task.code.toLowerCase().includes(query.code) && + task.name.toLowerCase().includes(query.name), + ), + ); }} + onReset={onReset} /> diff --git a/src/components/TransferList/MultiSelectList.tsx b/src/components/TransferList/MultiSelectList.tsx index 8549088..b74cfe2 100644 --- a/src/components/TransferList/MultiSelectList.tsx +++ b/src/components/TransferList/MultiSelectList.tsx @@ -15,8 +15,11 @@ import { Stack, Typography, } from "@mui/material"; -import React, { useCallback, useState } from "react"; -import { LabelWithId, TransferListProps } from "./TransferList"; +import React, { useCallback, useEffect, useState } from "react"; +import { LabelGroup, LabelWithId, TransferListProps } from "./TransferList"; +import { useTranslation } from "react-i18next"; +import uniqBy from "lodash/uniqBy"; +import groupBy from "lodash/groupBy"; export const MultiSelectList: React.FC = ({ allItems, @@ -33,7 +36,7 @@ export const MultiSelectList: React.FC = ({ ); }, [allItems]); const compareFn = React.useCallback( - (a: string, b: string) => sortMap[a].index - sortMap[b].index, + (a: number, b: number) => sortMap[a].index - sortMap[b].index, [sortMap], ); const [selectedItems, setSelectedItems] = useState( @@ -45,7 +48,7 @@ export const MultiSelectList: React.FC = ({ const { target: { value }, } = event; - setSelectedItems(typeof value === "string" ? [value] : value); + setSelectedItems(typeof value === "string" ? [Number(value)] : value); }, []); const handleToggleAll = useCallback( @@ -59,6 +62,23 @@ export const MultiSelectList: React.FC = ({ [allItems, selectedItems.length], ); + useEffect(() => { + onChange(selectedItems.map((item) => sortMap[item])); + }, [onChange, selectedItems, sortMap]); + + const { t } = useTranslation(); + const groups: LabelGroup[] = uniqBy( + [ + ...allItems.reduce((acc, item) => { + return item.group ? [...acc, item.group] : acc; + }, []), + // Items with no group + { id: 0, name: t("Ungrouped") }, + ], + "id", + ); + const groupedItems = groupBy(allItems, (item) => item.group?.id ?? 0); + return ( @@ -121,18 +141,28 @@ export const MultiSelectList: React.FC = ({ - {allItems.map((item) => { - return ( - - - - {item.label} - - - ); + {groups.flatMap((group) => { + const groupItems = groupedItems[group.id]; + if (!groupItems) return null; + + return [ + + {group.name} + , + ...groupItems.map((item) => { + return ( + + + + {item.label} + + + ); + }), + ]; })} diff --git a/src/components/TransferList/TransferList.tsx b/src/components/TransferList/TransferList.tsx index 93771a7..7bb8d60 100644 --- a/src/components/TransferList/TransferList.tsx +++ b/src/components/TransferList/TransferList.tsx @@ -16,16 +16,25 @@ import Stack from "@mui/material/Stack"; import Paper from "@mui/material/Paper"; import Typography from "@mui/material/Typography"; import ListSubheader from "@mui/material/ListSubheader"; +import groupBy from "lodash/groupBy"; +import uniqBy from "lodash/uniqBy"; +import { useTranslation } from "react-i18next"; + +export interface LabelGroup { + id: number; + name: string; +} export interface LabelWithId { - id: string; + id: number; label: string; + group?: LabelGroup; } export interface TransferListProps { allItems: LabelWithId[]; initiallySelectedItems: LabelWithId[]; - onChange: () => void; + onChange: (selectedItems: LabelWithId[]) => void; allItemsLabel: string; selectedItemsLabel: string; } @@ -48,6 +57,19 @@ const ItemList: React.FC = ({ handleToggle, handleToggleAll, }) => { + const { t } = useTranslation(); + const groups: LabelGroup[] = uniqBy( + [ + ...items.reduce((acc, item) => { + return item.group ? [...acc, item.group] : acc; + }, []), + // Items with no group + { id: 0, name: t("Ungrouped") }, + ], + "id", + ); + const groupedItems = groupBy(items, (item) => item.group?.id ?? 0); + return ( = ({ } > - {items.map((item) => { + {groups.map((group) => { + const groupItems = groupedItems[group.id]; + if (!groupItems) return null; + return ( - - - - - - + + + {group.name} + + {groupItems.map((item) => { + return ( + + + + + + + ); + })} + ); })} @@ -167,6 +207,10 @@ const TransferList: React.FC = ({ setCheckedList(difference(checkedList, rightListChecked)); }; + React.useEffect(() => { + onChange(rightList); + }, [onChange, rightList]); + return ( Date: Thu, 1 Feb 2024 14:53:29 +0900 Subject: [PATCH 2/2] Small style fixes --- src/app/(main)/layout.tsx | 2 +- src/app/(main)/projects/page.tsx | 1 - src/components/AppBar/NavigationToggle.tsx | 4 ++-- src/components/SearchBox/SearchBox.tsx | 2 +- src/theme/devias-material-kit/components.ts | 4 ++-- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index b5c4117..b93ed10 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -27,7 +27,7 @@ export default async function MainLayout({ diff --git a/src/app/(main)/projects/page.tsx b/src/app/(main)/projects/page.tsx index 90116b6..1fe1800 100644 --- a/src/app/(main)/projects/page.tsx +++ b/src/app/(main)/projects/page.tsx @@ -1,6 +1,5 @@ import { preloadProjects } from "@/app/api/projects"; import ProjectSearch from "@/components/ProjectSearch"; -import ProgressByClientSearch from "@/components/ProgressByClientSearch"; import { getServerI18n } from "@/i18n"; import Add from "@mui/icons-material/Add"; import Button from "@mui/material/Button"; diff --git a/src/components/AppBar/NavigationToggle.tsx b/src/components/AppBar/NavigationToggle.tsx index 9241fc6..9f61753 100644 --- a/src/components/AppBar/NavigationToggle.tsx +++ b/src/components/AppBar/NavigationToggle.tsx @@ -18,7 +18,7 @@ const NavigationToggle: React.FC = () => { return ( <> - + { keepMounted: true, }} > - + ({ return ( - {t("Search Criteria")} + {t("Search Criteria")} {criteria.map((c) => { return ( diff --git a/src/theme/devias-material-kit/components.ts b/src/theme/devias-material-kit/components.ts index d1a360e..ee432e8 100644 --- a/src/theme/devias-material-kit/components.ts +++ b/src/theme/devias-material-kit/components.ts @@ -189,7 +189,7 @@ const components: ThemeOptions["components"] = { }, [`&.Mui-focused`]: { backgroundColor: "transparent", - borderColor: "palette.primary.main", + borderColor: palette.primary.main, boxShadow: `${palette.primary.main} 0 0 0 2px`, }, [`&.Mui-error`]: { @@ -216,7 +216,7 @@ const components: ThemeOptions["components"] = { [`&.Mui-focused`]: { backgroundColor: "transparent", [`& .MuiOutlinedInput-notchedOutline`]: { - borderColor: "palette.primary.main", + borderColor: palette.primary.main, boxShadow: `${palette.primary.main} 0 0 0 2px`, }, }, -- 2.24.1.windows.2