| @@ -1,8 +1,9 @@ | |||
| { | |||
| "extends": ["next/core-web-vitals", "prettier"], | |||
| "plugins": ["prettier"], | |||
| "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"], | |||
| "plugins": ["prettier", "@typescript-eslint"], | |||
| "rules": { | |||
| "prettier/prettier": "warn", | |||
| "no-unused-vars": "warn" | |||
| "no-unused-vars": "off", | |||
| "@typescript-eslint/no-unused-vars": "warn" | |||
| } | |||
| } | |||
| @@ -23,6 +23,7 @@ | |||
| "dayjs": "^1.11.10", | |||
| "i18next": "^23.7.11", | |||
| "i18next-resources-to-backend": "^1.2.0", | |||
| "lodash": "^4.17.21", | |||
| "next": "14.0.4", | |||
| "next-auth": "^4.24.5", | |||
| "react": "^18", | |||
| @@ -32,9 +33,12 @@ | |||
| "react-intl": "^6.5.5" | |||
| }, | |||
| "devDependencies": { | |||
| "@types/lodash": "^4.14.202", | |||
| "@types/node": "^20", | |||
| "@types/react": "^18", | |||
| "@types/react-dom": "^18", | |||
| "@typescript-eslint/eslint-plugin": "^6.18.1", | |||
| "@typescript-eslint/parser": "^6.18.1", | |||
| "autoprefixer": "^10.4.16", | |||
| "eslint": "^8", | |||
| "eslint-config-next": "14.0.4", | |||
| @@ -4,6 +4,8 @@ import { authOptions } from "@/config/authConfig"; | |||
| import { redirect } from "next/navigation"; | |||
| import Box from "@mui/material/Box"; | |||
| import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import Breadcrumb from "@/components/Breadcrumb"; | |||
| export default async function MainLayout({ | |||
| children, | |||
| @@ -26,9 +28,13 @@ export default async function MainLayout({ | |||
| component="main" | |||
| sx={{ | |||
| marginInlineStart: { xs: 0, lg: NAVIGATION_CONTENT_WIDTH }, | |||
| padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" }, | |||
| }} | |||
| > | |||
| {children} | |||
| <Stack spacing={2}> | |||
| <Breadcrumb /> | |||
| {children} | |||
| </Stack> | |||
| </Box> | |||
| </> | |||
| ); | |||
| @@ -0,0 +1,21 @@ | |||
| import CreateProject from "@/components/CreateProject"; | |||
| import { getServerI18n } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Metadata } from "next"; | |||
| export const metadata: Metadata = { | |||
| title: "Create Project", | |||
| }; | |||
| const Projects: React.FC = async () => { | |||
| const { t } = await getServerI18n("projects"); | |||
| return ( | |||
| <> | |||
| <Typography variant="h4">{t("Create Project")}</Typography> | |||
| <CreateProject /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Projects; | |||
| @@ -1,11 +1,47 @@ | |||
| import { preloadProjects } from "@/app/api/projects"; | |||
| import ProjectSearch from "@/components/ProjectSearch"; | |||
| 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 { Metadata } from "next"; | |||
| import Link from "next/link"; | |||
| import { Suspense } from "react"; | |||
| export const metadata: Metadata = { | |||
| title: "Projects", | |||
| }; | |||
| const Projects: React.FC = async () => { | |||
| return "Projects"; | |||
| const { t } = await getServerI18n("projects"); | |||
| preloadProjects(); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| > | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Projects")} | |||
| </Typography> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Add />} | |||
| LinkComponent={Link} | |||
| href="/projects/create" | |||
| > | |||
| {t("Create Project")} | |||
| </Button> | |||
| </Stack> | |||
| <Suspense fallback={<ProjectSearch.Loading />}> | |||
| <ProjectSearch /> | |||
| </Suspense> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Projects; | |||
| @@ -0,0 +1,21 @@ | |||
| import CreateTaskTemplate from "@/components/CreateTaskTemplate"; | |||
| import { getServerI18n } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Metadata } from "next"; | |||
| export const metadata: Metadata = { | |||
| title: "Create Task Template", | |||
| }; | |||
| const Projects: React.FC = async () => { | |||
| const { t } = await getServerI18n("tasks"); | |||
| return ( | |||
| <> | |||
| <Typography variant="h4">{t("Create Task Template")}</Typography> | |||
| <CreateTaskTemplate /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Projects; | |||
| @@ -1,11 +1,47 @@ | |||
| import { preloadTaskTemplates } from "@/app/api/tasks"; | |||
| import TaskTemplateSearch from "@/components/TaskTemplateSearch"; | |||
| 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 { Metadata } from "next"; | |||
| import Link from "next/link"; | |||
| import { Suspense } from "react"; | |||
| export const metadata: Metadata = { | |||
| title: "Tasks", | |||
| }; | |||
| const Tasks: React.FC = async () => { | |||
| return "Tasks"; | |||
| const TaskTemplates: React.FC = async () => { | |||
| const { t } = await getServerI18n("projects"); | |||
| preloadTaskTemplates(); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| > | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Task Template")} | |||
| </Typography> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Add />} | |||
| LinkComponent={Link} | |||
| href="/tasks/create" | |||
| > | |||
| {t("Create Template")} | |||
| </Button> | |||
| </Stack> | |||
| <Suspense fallback={<TaskTemplateSearch.Loading />}> | |||
| <TaskTemplateSearch /> | |||
| </Suspense> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Tasks; | |||
| export default TaskTemplates; | |||
| @@ -0,0 +1,46 @@ | |||
| import { cache } from "react"; | |||
| import "server-only"; | |||
| export interface ProjectResult { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| category: "Confirmed Project" | "Project to be bidded"; | |||
| team: string; | |||
| client: string; | |||
| } | |||
| export const preloadProjects = () => { | |||
| fetchProjects(); | |||
| }; | |||
| export const fetchProjects = cache(async () => { | |||
| return mockProjects; | |||
| }); | |||
| const mockProjects: ProjectResult[] = [ | |||
| { | |||
| id: 1, | |||
| code: "M1001", | |||
| name: "Consultancy Project A", | |||
| category: "Confirmed Project", | |||
| team: "TW", | |||
| client: "Client A", | |||
| }, | |||
| { | |||
| id: 2, | |||
| code: "M1002", | |||
| name: "Consultancy Project B", | |||
| category: "Project to be bidded", | |||
| team: "WY", | |||
| client: "Client B", | |||
| }, | |||
| { | |||
| id: 3, | |||
| code: "S1001", | |||
| name: "Consultancy Project C", | |||
| category: "Confirmed Project", | |||
| team: "WY", | |||
| client: "Client C", | |||
| }, | |||
| ]; | |||
| @@ -0,0 +1,34 @@ | |||
| import { cache } from "react"; | |||
| import "server-only"; | |||
| export interface TaskTemplateResult { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| } | |||
| export const preloadTaskTemplates = () => { | |||
| fetchTaskTemplates(); | |||
| }; | |||
| export const fetchTaskTemplates = cache(async () => { | |||
| return mockProjects; | |||
| }); | |||
| 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", | |||
| }, | |||
| ]; | |||
| @@ -14,7 +14,7 @@ export interface AppBarProps { | |||
| const AppBar: React.FC<AppBarProps> = ({ avatarImageSrc, profileName }) => { | |||
| return ( | |||
| <I18nProvider namespaces={["common"]}> | |||
| <MUIAppBar position="sticky"> | |||
| <MUIAppBar position="sticky" color="default" elevation={4}> | |||
| <Toolbar> | |||
| <NavigationToggle /> | |||
| <Box | |||
| @@ -0,0 +1,51 @@ | |||
| "use client"; | |||
| import Breadcrumbs from "@mui/material/Breadcrumbs"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import Link from "next/link"; | |||
| import MUILink from "@mui/material/Link"; | |||
| import { usePathname } from "next/navigation"; | |||
| const pathToLabelMap: { [path: string]: string } = { | |||
| "": "Overview", | |||
| "/projects": "Projects", | |||
| "/projects/create": "Create Project", | |||
| "/tasks": "Task Template", | |||
| "/tasks/create": "Create Task Template", | |||
| }; | |||
| const Breadcrumb = () => { | |||
| const pathname = usePathname(); | |||
| const segments = pathname.split("/"); | |||
| return ( | |||
| <Breadcrumbs> | |||
| {segments.map((segment, index) => { | |||
| const href = segments.slice(0, index + 1).join("/"); | |||
| const label = pathToLabelMap[href] || segment; | |||
| if (index === segments.length - 1) { | |||
| return ( | |||
| <Typography key={index} color="text.primary"> | |||
| {label} | |||
| </Typography> | |||
| ); | |||
| } else { | |||
| return ( | |||
| <MUILink | |||
| underline="hover" | |||
| color="inherit" | |||
| key={index} | |||
| component={Link} | |||
| href={href || "/"} | |||
| > | |||
| {label} | |||
| </MUILink> | |||
| ); | |||
| } | |||
| })} | |||
| </Breadcrumbs> | |||
| ); | |||
| }; | |||
| export default Breadcrumb; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./Breadcrumb"; | |||
| @@ -0,0 +1,53 @@ | |||
| "use client"; | |||
| import Check from "@mui/icons-material/Check"; | |||
| import Close from "@mui/icons-material/Close"; | |||
| import Button from "@mui/material/Button"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import Tab from "@mui/material/Tab"; | |||
| import Tabs, { TabsProps } from "@mui/material/Tabs"; | |||
| import { useRouter } from "next/navigation"; | |||
| import React, { useCallback, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import ProjectClientDetails from "./ProjectClientDetails"; | |||
| import TaskSetup from "./TaskSetup"; | |||
| const CreateProject: React.FC = () => { | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| const { t } = useTranslation(); | |||
| const router = useRouter(); | |||
| const handleCancel = () => { | |||
| router.back(); | |||
| }; | |||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | |||
| (_e, newValue) => { | |||
| setTabIndex(newValue); | |||
| }, | |||
| [], | |||
| ); | |||
| return ( | |||
| <> | |||
| <Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable"> | |||
| <Tab label={t("Project and Client Details")} /> | |||
| <Tab label={t("Project Task Setup")} /> | |||
| <Tab label={t("Staff Allocation")} /> | |||
| <Tab label={t("Resource and Milestone")} /> | |||
| </Tabs> | |||
| {tabIndex === 0 && <ProjectClientDetails />} | |||
| {tabIndex === 1 && <TaskSetup />} | |||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||
| <Button variant="outlined" startIcon={<Close />} onClick={handleCancel}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button variant="contained" startIcon={<Check />}> | |||
| {t("Confirm")} | |||
| </Button> | |||
| </Stack> | |||
| </> | |||
| ); | |||
| }; | |||
| export default CreateProject; | |||
| @@ -0,0 +1,110 @@ | |||
| "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 FormControl from "@mui/material/FormControl"; | |||
| import Grid from "@mui/material/Grid"; | |||
| import InputLabel from "@mui/material/InputLabel"; | |||
| import MenuItem from "@mui/material/MenuItem"; | |||
| import Select from "@mui/material/Select"; | |||
| import TextField from "@mui/material/TextField"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import CardActions from "@mui/material/CardActions"; | |||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||
| import Button from "@mui/material/Button"; | |||
| const ProjectClientDetails: React.FC = () => { | |||
| const { t } = useTranslation(); | |||
| return ( | |||
| <Card> | |||
| <CardContent component={Stack} spacing={4}> | |||
| <Box> | |||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||
| {t("Project Details")} | |||
| </Typography> | |||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||
| <Grid item xs={6}> | |||
| <TextField label={t("Project Code")} fullWidth /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField label={t("Project Subcode")} fullWidth /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField label={t("Project Name")} fullWidth /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <FormControl fullWidth> | |||
| <InputLabel>{t("Project Category")}</InputLabel> | |||
| <Select | |||
| label={t("Project Category")} | |||
| value={"Temporary Project"} | |||
| > | |||
| <MenuItem value={"Temporary Project"}> | |||
| {t("Temporary Project")} | |||
| </MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <FormControl fullWidth> | |||
| <InputLabel>{t("Team Lead")}</InputLabel> | |||
| <Select label={t("Team Lead")} value={"00539 - Ming CHAN (MC)"}> | |||
| <MenuItem value={"00539 - Ming CHAN (MC)"}> | |||
| {"00539 - Ming CHAN (MC)"} | |||
| </MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField label={t("Project Description")} fullWidth /> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| <Box> | |||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||
| {t("Client Details")} | |||
| </Typography> | |||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||
| <Grid item xs={6}> | |||
| <TextField label={t("Client Code and Name")} fullWidth /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField label={t("Client Lead Name")} fullWidth /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField label={t("Client Lead Phone Number")} fullWidth /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField label={t("Client Lead Email")} fullWidth /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <FormControl fullWidth> | |||
| <InputLabel>{t("Client Subsidiary")}</InputLabel> | |||
| <Select | |||
| label={t("Client Subsidiary")} | |||
| value={"Test Subsidiary"} | |||
| > | |||
| <MenuItem value={"Test Subsidiary"}> | |||
| {t("Test Subsidiary")} | |||
| </MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button variant="text" startIcon={<RestartAlt />}> | |||
| {t("Reset")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default ProjectClientDetails; | |||
| @@ -0,0 +1,70 @@ | |||
| "use client"; | |||
| import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import Grid from "@mui/material/Grid"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import TransferList from "../TransferList"; | |||
| import Button from "@mui/material/Button"; | |||
| import React from "react"; | |||
| import CardActions from "@mui/material/CardActions"; | |||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||
| import FormControl from "@mui/material/FormControl"; | |||
| import Select from "@mui/material/Select"; | |||
| import MenuItem from "@mui/material/MenuItem"; | |||
| import InputLabel from "@mui/material/InputLabel"; | |||
| const TaskSetup = () => { | |||
| const { t } = useTranslation(); | |||
| return ( | |||
| <Card> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||
| {t("Task List Setup")} | |||
| </Typography> | |||
| <Grid | |||
| container | |||
| spacing={2} | |||
| columns={{ xs: 6, sm: 12 }} | |||
| marginBlockEnd={1} | |||
| > | |||
| <Grid item xs={6}> | |||
| <FormControl fullWidth> | |||
| <InputLabel>{t("Task List Source")}</InputLabel> | |||
| <Select | |||
| label={t("Task List Source")} | |||
| value={"M1009 - Consultancy Project X Temp"} | |||
| > | |||
| <MenuItem value={"M1009 - Consultancy Project X Temp"}> | |||
| {"M1009 - Consultancy Project X Temp"} | |||
| </MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| </Grid> | |||
| </Grid> | |||
| <TransferList | |||
| allItems={[ | |||
| { id: "1", label: "Task 1" }, | |||
| { id: "2", label: "Task 2" }, | |||
| { id: "3", label: "Task 3" }, | |||
| { id: "4", label: "Task 4" }, | |||
| { id: "5", label: "Task 5" }, | |||
| ]} | |||
| initiallySelectedItems={[]} | |||
| onChange={() => {}} | |||
| allItemsLabel={t("Task Pool")} | |||
| selectedItemsLabel={t("Project Task List")} | |||
| /> | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button variant="text" startIcon={<RestartAlt />}> | |||
| {t("Reset")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default TaskSetup; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./CreateProject"; | |||
| @@ -0,0 +1,74 @@ | |||
| "use client"; | |||
| 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 { useTranslation } from "react-i18next"; | |||
| import TransferList from "../TransferList"; | |||
| import Button from "@mui/material/Button"; | |||
| import Check from "@mui/icons-material/Check"; | |||
| import Close from "@mui/icons-material/Close"; | |||
| import { useRouter } from "next/navigation"; | |||
| import React from "react"; | |||
| import Stack from "@mui/material/Stack"; | |||
| const CreateTaskTemplate = () => { | |||
| const { t } = useTranslation(); | |||
| const router = useRouter(); | |||
| const handleCancel = () => { | |||
| router.back(); | |||
| }; | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
| <Typography variant="overline">{t("Task List Setup")}</Typography> | |||
| <Grid | |||
| container | |||
| spacing={2} | |||
| columns={{ xs: 6, sm: 12 }} | |||
| marginBlockEnd={1} | |||
| > | |||
| <Grid item xs={6}> | |||
| <TextField label={t("Task Template Code")} fullWidth /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField label={t("Task Template Name")} fullWidth /> | |||
| </Grid> | |||
| </Grid> | |||
| <TransferList | |||
| allItems={[ | |||
| { id: "1", label: "Task 1: Super long task name that will overflow to the next line" }, | |||
| { id: "2", label: "Task 2" }, | |||
| { id: "3", label: "Task 3" }, | |||
| { id: "4", label: "Task 4" }, | |||
| { id: "5", label: "Task 5" }, | |||
| { id: "6", label: "Task 6" }, | |||
| { id: "7", label: "Task 7" }, | |||
| { id: "8", label: "Task 8" }, | |||
| { id: "9", label: "Task 9" }, | |||
| ]} | |||
| initiallySelectedItems={[]} | |||
| onChange={() => {}} | |||
| allItemsLabel={t("Task Pool")} | |||
| selectedItemsLabel={t("Task List Template")} | |||
| /> | |||
| </CardContent> | |||
| </Card> | |||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||
| <Button variant="outlined" startIcon={<Close />} onClick={handleCancel}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button variant="contained" startIcon={<Check />}> | |||
| {t("Confirm")} | |||
| </Button> | |||
| </Stack> | |||
| </> | |||
| ); | |||
| }; | |||
| export default CreateTaskTemplate; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./CreateTaskTemplate"; | |||
| @@ -0,0 +1,76 @@ | |||
| "use client"; | |||
| import { ProjectResult } from "@/app/api/projects"; | |||
| import React, { useMemo, useState } from "react"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults"; | |||
| interface Props { | |||
| projects: ProjectResult[]; | |||
| } | |||
| type SearchQuery = Partial<Omit<ProjectResult, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const ProjectSearch: React.FC<Props> = ({ 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 searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| { label: t("Project code"), paramName: "code", type: "text" }, | |||
| { label: t("Project name"), paramName: "name", type: "text" }, | |||
| { | |||
| label: t("Client name"), | |||
| paramName: "client", | |||
| type: "select", | |||
| options: ["A", "B"], | |||
| }, | |||
| { | |||
| label: t("Project category"), | |||
| paramName: "category", | |||
| type: "select", | |||
| options: ["A", "B"], | |||
| }, | |||
| { | |||
| label: t("Team"), | |||
| paramName: "team", | |||
| type: "select", | |||
| options: ["A", "B"], | |||
| }, | |||
| ], | |||
| [t], | |||
| ); | |||
| const columns = useMemo<Column<ProjectResult>[]>( | |||
| () => [ | |||
| { name: "id", label: t("Details") }, | |||
| { 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], | |||
| ); | |||
| return ( | |||
| <> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={(query) => { | |||
| console.log(query); | |||
| }} | |||
| /> | |||
| <SearchResults<ProjectResult> | |||
| items={filteredProjects} | |||
| columns={columns} | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default ProjectSearch; | |||
| @@ -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 ProjectSearchLoading: React.FC = () => { | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton | |||
| variant="rounded" | |||
| height={50} | |||
| width={100} | |||
| sx={{ alignSelf: "flex-end" }} | |||
| /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| </> | |||
| ); | |||
| }; | |||
| export default ProjectSearchLoading; | |||
| @@ -0,0 +1,18 @@ | |||
| import { fetchProjects } from "@/app/api/projects"; | |||
| import React from "react"; | |||
| import ProjectSearch from "./ProjectSearch"; | |||
| import ProjectSearchLoading from "./ProjectSearchLoading"; | |||
| interface SubComponents { | |||
| Loading: typeof ProjectSearchLoading; | |||
| } | |||
| const ProjectSearchWrapper: React.FC & SubComponents = async () => { | |||
| const projects = await fetchProjects(); | |||
| return <ProjectSearch projects={projects} />; | |||
| }; | |||
| ProjectSearchWrapper.Loading = ProjectSearchLoading; | |||
| export default ProjectSearchWrapper; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./ProjectSearchWrapper"; | |||
| @@ -0,0 +1,135 @@ | |||
| "use client"; | |||
| import Grid from "@mui/material/Grid"; | |||
| import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import React, { useCallback, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import TextField from "@mui/material/TextField"; | |||
| import FormControl from "@mui/material/FormControl"; | |||
| import InputLabel from "@mui/material/InputLabel"; | |||
| import Select, { SelectChangeEvent } from "@mui/material/Select"; | |||
| import MenuItem from "@mui/material/MenuItem"; | |||
| import CardActions from "@mui/material/CardActions"; | |||
| import Button from "@mui/material/Button"; | |||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||
| import Search from "@mui/icons-material/Search"; | |||
| interface BaseCriterion<T extends string> { | |||
| label: string; | |||
| paramName: T; | |||
| } | |||
| interface TextCriterion<T extends string> extends BaseCriterion<T> { | |||
| type: "text"; | |||
| } | |||
| interface SelectCriterion<T extends string> extends BaseCriterion<T> { | |||
| type: "select"; | |||
| options: string[]; | |||
| } | |||
| export type Criterion<T extends string> = TextCriterion<T> | SelectCriterion<T>; | |||
| interface Props<T extends string> { | |||
| criteria: Criterion<T>[]; | |||
| onSearch: (inputs: Record<T, string>) => void; | |||
| } | |||
| function SearchBox<T extends string>({ criteria, onSearch }: Props<T>) { | |||
| const { t } = useTranslation("common"); | |||
| const defaultInputs = useMemo( | |||
| () => | |||
| criteria.reduce<Record<T, string>>( | |||
| (acc, c) => { | |||
| return { ...acc, [c.paramName]: c.type === "select" ? "All" : "" }; | |||
| }, | |||
| {} as Record<T, string>, | |||
| ), | |||
| [criteria], | |||
| ); | |||
| const [inputs, setInputs] = useState(defaultInputs); | |||
| const makeInputChangeHandler = useCallback( | |||
| (paramName: T): React.ChangeEventHandler<HTMLInputElement> => { | |||
| return (e) => { | |||
| setInputs((i) => ({ ...i, [paramName]: e.target.value })); | |||
| }; | |||
| }, | |||
| [], | |||
| ); | |||
| const makeSelectChangeHandler = useCallback((paramName: T) => { | |||
| return (e: SelectChangeEvent) => { | |||
| setInputs((i) => ({ ...i, [paramName]: e.target.value })); | |||
| }; | |||
| }, []); | |||
| const handleReset = () => { | |||
| setInputs(defaultInputs); | |||
| }; | |||
| const handleSearch = () => { | |||
| onSearch(inputs); | |||
| }; | |||
| return ( | |||
| <Card> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
| <Typography variant="overline">{t("Search Criteria")}</Typography> | |||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||
| {criteria.map((c) => { | |||
| return ( | |||
| <Grid key={c.paramName} item xs={6}> | |||
| {c.type === "text" && ( | |||
| <TextField | |||
| label={c.label} | |||
| fullWidth | |||
| onChange={makeInputChangeHandler(c.paramName)} | |||
| value={inputs[c.paramName]} | |||
| /> | |||
| )} | |||
| {c.type === "select" && ( | |||
| <FormControl fullWidth> | |||
| <InputLabel>{c.label}</InputLabel> | |||
| <Select | |||
| label={c.label} | |||
| onChange={makeSelectChangeHandler(c.paramName)} | |||
| value={inputs[c.paramName]} | |||
| > | |||
| <MenuItem value={"All"}>{t("All")}</MenuItem> | |||
| {c.options.map((option) => ( | |||
| <MenuItem key={option} value={option}> | |||
| {option} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| )} | |||
| </Grid> | |||
| ); | |||
| })} | |||
| </Grid> | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button | |||
| variant="text" | |||
| startIcon={<RestartAlt />} | |||
| onClick={handleReset} | |||
| > | |||
| {t("Reset")} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Search />} | |||
| onClick={handleSearch} | |||
| > | |||
| {t("Search")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| } | |||
| export default SearchBox; | |||
| @@ -0,0 +1,2 @@ | |||
| export { default } from "./SearchBox"; | |||
| export type { Criterion } from "./SearchBox"; | |||
| @@ -0,0 +1,100 @@ | |||
| "use client"; | |||
| import React from "react"; | |||
| import Paper from "@mui/material/Paper"; | |||
| import Table from "@mui/material/Table"; | |||
| import TableBody from "@mui/material/TableBody"; | |||
| import TableCell from "@mui/material/TableCell"; | |||
| import TableContainer from "@mui/material/TableContainer"; | |||
| import TableHead from "@mui/material/TableHead"; | |||
| import TablePagination, { | |||
| TablePaginationProps, | |||
| } 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 { | |||
| id: string | number; | |||
| } | |||
| export interface Column<T extends ResultWithId> { | |||
| name: keyof T; | |||
| label: string; | |||
| } | |||
| interface Props<T extends ResultWithId> { | |||
| items: T[]; | |||
| columns: Column<T>[]; | |||
| } | |||
| function SearchResults<T extends ResultWithId>({ items, columns }: Props<T>) { | |||
| const [page, setPage] = React.useState(0); | |||
| const [rowsPerPage, setRowsPerPage] = React.useState(10); | |||
| const handleChangePage: TablePaginationProps["onPageChange"] = ( | |||
| _event, | |||
| newPage, | |||
| ) => { | |||
| setPage(newPage); | |||
| }; | |||
| const handleChangeRowsPerPage: TablePaginationProps["onRowsPerPageChange"] = ( | |||
| event, | |||
| ) => { | |||
| setRowsPerPage(+event.target.value); | |||
| setPage(0); | |||
| }; | |||
| return ( | |||
| <Paper sx={{ overflow: "hidden" }}> | |||
| <TableContainer sx={{ maxHeight: 440 }}> | |||
| <Table stickyHeader> | |||
| <TableHead> | |||
| <TableRow> | |||
| {columns.map((column) => ( | |||
| <TableCell key={column.name.toString()}> | |||
| {column.label} | |||
| </TableCell> | |||
| ))} | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {items | |||
| .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) | |||
| .map((item) => { | |||
| return ( | |||
| <TableRow hover tabIndex={-1} key={item.id}> | |||
| {columns.map(({ name: columnName }) => { | |||
| return ( | |||
| <TableCell key={columnName.toString()}> | |||
| {columnName === "id" ? ( | |||
| <IconButton color="primary"> | |||
| <EditNote /> | |||
| </IconButton> | |||
| ) : ( | |||
| <>{item[columnName]}</> | |||
| )} | |||
| </TableCell> | |||
| ); | |||
| })} | |||
| </TableRow> | |||
| ); | |||
| })} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| <TablePagination | |||
| rowsPerPageOptions={[10, 25, 100]} | |||
| component="div" | |||
| count={items.length} | |||
| rowsPerPage={rowsPerPage} | |||
| page={page} | |||
| onPageChange={handleChangePage} | |||
| onRowsPerPageChange={handleChangeRowsPerPage} | |||
| /> | |||
| </Paper> | |||
| ); | |||
| } | |||
| export default SearchResults; | |||
| @@ -0,0 +1,2 @@ | |||
| export { default } from "./SearchResults"; | |||
| export type { Column } from "./SearchResults"; | |||
| @@ -0,0 +1,52 @@ | |||
| "use client"; | |||
| import { TaskTemplateResult } from "@/app/api/tasks"; | |||
| import React, { useMemo, useState } from "react"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults"; | |||
| interface Props { | |||
| taskTemplates: TaskTemplateResult[]; | |||
| } | |||
| type SearchQuery = Partial<Omit<TaskTemplateResult, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const TaskTemplateSearch: React.FC<Props> = ({ 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<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| { label: t("Task Template Code"), paramName: "code", type: "text" }, | |||
| { label: t("Task Template Name"), paramName: "name", type: "text" }, | |||
| ], | |||
| [t], | |||
| ); | |||
| const columns = useMemo<Column<TaskTemplateResult>[]>( | |||
| () => [ | |||
| { name: "id", label: t("Details") }, | |||
| { name: "code", label: t("Task Template Code") }, | |||
| { name: "name", label: t("Task Template Name") }, | |||
| ], | |||
| [t], | |||
| ); | |||
| return ( | |||
| <> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={(query) => { | |||
| console.log(query); | |||
| }} | |||
| /> | |||
| <SearchResults items={filteredTemplates} columns={columns} /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default TaskTemplateSearch; | |||
| @@ -0,0 +1,38 @@ | |||
| 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 TaskTemplateSearchLoading: React.FC = () => { | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton | |||
| variant="rounded" | |||
| height={50} | |||
| width={100} | |||
| sx={{ alignSelf: "flex-end" }} | |||
| /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| </> | |||
| ); | |||
| }; | |||
| export default TaskTemplateSearchLoading; | |||
| @@ -0,0 +1,18 @@ | |||
| import { fetchTaskTemplates } from "@/app/api/tasks"; | |||
| import React from "react"; | |||
| import TaskTemplateSearch from "./TaskTemplateSearch"; | |||
| import TaskTemplateSearchLoading from "./TaskTemplateSearchLoading"; | |||
| interface SubComponents { | |||
| Loading: typeof TaskTemplateSearchLoading; | |||
| } | |||
| const TaskTemplateSearchWrapper: React.FC & SubComponents = async () => { | |||
| const taskTemplates = await fetchTaskTemplates(); | |||
| return <TaskTemplateSearch taskTemplates={taskTemplates} />; | |||
| }; | |||
| TaskTemplateSearchWrapper.Loading = TaskTemplateSearchLoading; | |||
| export default TaskTemplateSearchWrapper; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./TaskTemplateSearchWrapper"; | |||
| @@ -0,0 +1,143 @@ | |||
| "use client"; | |||
| import { | |||
| Box, | |||
| Checkbox, | |||
| Divider, | |||
| FormControl, | |||
| InputLabel, | |||
| ListItemIcon, | |||
| ListItemText, | |||
| ListSubheader, | |||
| MenuItem, | |||
| Select, | |||
| SelectProps, | |||
| Stack, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import React, { useCallback, useState } from "react"; | |||
| import { LabelWithId, TransferListProps } from "./TransferList"; | |||
| export const MultiSelectList: React.FC<TransferListProps> = ({ | |||
| allItems, | |||
| initiallySelectedItems, | |||
| selectedItemsLabel, | |||
| allItemsLabel, | |||
| onChange, | |||
| }) => { | |||
| // Keep a map for the original order of items | |||
| const sortMap = React.useMemo(() => { | |||
| return allItems.reduce<{ [id: string]: LabelWithId & { index: number } }>( | |||
| (acc, item, index) => ({ ...acc, [item.id]: { ...item, index } }), | |||
| {}, | |||
| ); | |||
| }, [allItems]); | |||
| const compareFn = React.useCallback( | |||
| (a: string, b: string) => sortMap[a].index - sortMap[b].index, | |||
| [sortMap], | |||
| ); | |||
| const [selectedItems, setSelectedItems] = useState( | |||
| initiallySelectedItems.map((item) => item.id), | |||
| ); | |||
| const handleChange = useCallback< | |||
| NonNullable<SelectProps<typeof selectedItems>["onChange"]> | |||
| >((event) => { | |||
| const { | |||
| target: { value }, | |||
| } = event; | |||
| setSelectedItems(typeof value === "string" ? [value] : value); | |||
| }, []); | |||
| const handleToggleAll = useCallback( | |||
| () => () => { | |||
| if (selectedItems.length === allItems.length) { | |||
| setSelectedItems([]); | |||
| } else { | |||
| setSelectedItems(allItems.map((item) => item.id)); | |||
| } | |||
| }, | |||
| [allItems, selectedItems.length], | |||
| ); | |||
| return ( | |||
| <Box> | |||
| <FormControl fullWidth> | |||
| <InputLabel>{selectedItemsLabel}</InputLabel> | |||
| <Select | |||
| multiple | |||
| value={selectedItems} | |||
| onChange={handleChange} | |||
| renderValue={(values) => { | |||
| return ( | |||
| <Stack spacing={2}> | |||
| {values.toSorted(compareFn).map((value) => ( | |||
| <Typography key={value} whiteSpace="normal"> | |||
| {sortMap[value].label} | |||
| </Typography> | |||
| ))} | |||
| </Stack> | |||
| ); | |||
| }} | |||
| MenuProps={{ | |||
| slotProps: { | |||
| paper: { | |||
| sx: { maxHeight: 400 }, | |||
| }, | |||
| }, | |||
| anchorOrigin: { | |||
| vertical: "top", | |||
| horizontal: "left", | |||
| }, | |||
| transformOrigin: { | |||
| vertical: "top", | |||
| horizontal: "left", | |||
| }, | |||
| }} | |||
| > | |||
| <ListSubheader disableGutters sx={{ zIndex: 1 }}> | |||
| <Stack | |||
| direction="row" | |||
| paddingY={1} | |||
| paddingX={3} | |||
| onClick={handleToggleAll()} | |||
| > | |||
| <ListItemIcon> | |||
| <Checkbox | |||
| disableRipple | |||
| checked={ | |||
| selectedItems.length === allItems.length && | |||
| allItems.length !== 0 | |||
| } | |||
| indeterminate={ | |||
| selectedItems.length !== allItems.length && | |||
| selectedItems.length !== 0 | |||
| } | |||
| /> | |||
| </ListItemIcon> | |||
| <Stack> | |||
| <Typography variant="subtitle2">{allItemsLabel}</Typography> | |||
| <Typography variant="caption">{`${selectedItems.length}/${allItems.length} selected`}</Typography> | |||
| </Stack> | |||
| </Stack> | |||
| <Divider /> | |||
| </ListSubheader> | |||
| {allItems.map((item) => { | |||
| return ( | |||
| <MenuItem key={item.id} value={item.id} disableRipple> | |||
| <Checkbox | |||
| checked={selectedItems.includes(item.id)} | |||
| disableRipple | |||
| /> | |||
| <ListItemText sx={{ whiteSpace: "normal" }}> | |||
| {item.label} | |||
| </ListItemText> | |||
| </MenuItem> | |||
| ); | |||
| })} | |||
| </Select> | |||
| </FormControl> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default MultiSelectList; | |||
| @@ -0,0 +1,214 @@ | |||
| "use client"; | |||
| import * as React from "react"; | |||
| import List from "@mui/material/List"; | |||
| import ListItem from "@mui/material/ListItem"; | |||
| import ListItemText from "@mui/material/ListItemText"; | |||
| import ListItemIcon from "@mui/material/ListItemIcon"; | |||
| import Checkbox from "@mui/material/Checkbox"; | |||
| import IconButton from "@mui/material/Fab"; | |||
| import Divider from "@mui/material/Divider"; | |||
| import ChevronLeft from "@mui/icons-material/ChevronLeft"; | |||
| import ChevronRight from "@mui/icons-material/ChevronRight"; | |||
| import intersection from "lodash/intersection"; | |||
| import difference from "lodash/difference"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import Paper from "@mui/material/Paper"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import ListSubheader from "@mui/material/ListSubheader"; | |||
| export interface LabelWithId { | |||
| id: string; | |||
| label: string; | |||
| } | |||
| export interface TransferListProps { | |||
| allItems: LabelWithId[]; | |||
| initiallySelectedItems: LabelWithId[]; | |||
| onChange: () => void; | |||
| allItemsLabel: string; | |||
| selectedItemsLabel: string; | |||
| } | |||
| interface ItemListProps { | |||
| items: LabelWithId[]; | |||
| checkedItems: LabelWithId[]; | |||
| label: string; | |||
| handleToggleAll: ( | |||
| items: LabelWithId[], | |||
| checkedItems: LabelWithId[], | |||
| ) => React.MouseEventHandler; | |||
| handleToggle: (item: LabelWithId) => React.MouseEventHandler; | |||
| } | |||
| const ItemList: React.FC<ItemListProps> = ({ | |||
| items, | |||
| checkedItems, | |||
| label, | |||
| handleToggle, | |||
| handleToggleAll, | |||
| }) => { | |||
| return ( | |||
| <Paper sx={{ width: "100%" }} variant="outlined"> | |||
| <List | |||
| sx={{ | |||
| height: 400, | |||
| bgcolor: "background.paper", | |||
| overflow: "auto", | |||
| }} | |||
| disablePadding | |||
| dense | |||
| component="ul" | |||
| subheader={ | |||
| <ListSubheader | |||
| disableGutters | |||
| component="li" | |||
| onClick={handleToggleAll(items, checkedItems)} | |||
| > | |||
| <Stack direction="row" paddingY={1} paddingX={2}> | |||
| <ListItemIcon> | |||
| <Checkbox | |||
| checked={ | |||
| checkedItems.length === items.length && items.length !== 0 | |||
| } | |||
| indeterminate={ | |||
| checkedItems.length !== items.length && | |||
| checkedItems.length !== 0 | |||
| } | |||
| disabled={items.length === 0} | |||
| /> | |||
| </ListItemIcon> | |||
| <Stack> | |||
| <Typography variant="subtitle2">{label}</Typography> | |||
| <Typography variant="caption">{`${checkedItems.length}/${items.length} selected`}</Typography> | |||
| </Stack> | |||
| </Stack> | |||
| <Divider /> | |||
| </ListSubheader> | |||
| } | |||
| > | |||
| {items.map((item) => { | |||
| return ( | |||
| <ListItem key={item.id} onClick={handleToggle(item)}> | |||
| <ListItemIcon> | |||
| <Checkbox checked={checkedItems.includes(item)} tabIndex={-1} /> | |||
| </ListItemIcon> | |||
| <ListItemText primary={item.label} /> | |||
| </ListItem> | |||
| ); | |||
| })} | |||
| </List> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| const TransferList: React.FC<TransferListProps> = ({ | |||
| allItems, | |||
| initiallySelectedItems, | |||
| allItemsLabel, | |||
| selectedItemsLabel, | |||
| onChange, | |||
| }) => { | |||
| // Keep a map for the original order of items | |||
| const sortMap = React.useMemo(() => { | |||
| return allItems.reduce<{ [id: string]: number }>( | |||
| (acc, item, index) => ({ ...acc, [item.id]: index }), | |||
| {}, | |||
| ); | |||
| }, [allItems]); | |||
| const compareFn = React.useCallback( | |||
| (a: LabelWithId, b: LabelWithId) => sortMap[a.id] - sortMap[b.id], | |||
| [sortMap], | |||
| ); | |||
| const [checkedList, setCheckedList] = React.useState<LabelWithId[]>([]); | |||
| const [leftList, setLeftList] = React.useState<LabelWithId[]>( | |||
| difference(allItems, initiallySelectedItems), | |||
| ); | |||
| const [rightList, setRightList] = React.useState<LabelWithId[]>( | |||
| initiallySelectedItems, | |||
| ); | |||
| const leftListChecked = intersection(checkedList, leftList); | |||
| const rightListChecked = intersection(checkedList, rightList); | |||
| const handleToggle = React.useCallback( | |||
| (value: LabelWithId) => () => { | |||
| const isChecked = checkedList.includes(value); | |||
| const newCheckedList = isChecked | |||
| ? difference(checkedList, [value]) | |||
| : [...checkedList, value]; | |||
| setCheckedList(newCheckedList); | |||
| }, | |||
| [checkedList], | |||
| ); | |||
| const handleToggleAll = React.useCallback( | |||
| (items: LabelWithId[], checkedItems: LabelWithId[]) => () => { | |||
| if (checkedItems.length === items.length) { | |||
| setCheckedList(difference(checkedList, checkedItems)); | |||
| } else { | |||
| setCheckedList([...checkedList, ...items]); | |||
| } | |||
| }, | |||
| [checkedList], | |||
| ); | |||
| const handleCheckedRight = () => { | |||
| setRightList([...rightList, ...leftListChecked].sort(compareFn)); | |||
| setLeftList(difference(leftList, leftListChecked).sort(compareFn)); | |||
| setCheckedList(difference(checkedList, leftListChecked)); | |||
| }; | |||
| const handleCheckedLeft = () => { | |||
| setLeftList([...leftList, ...rightListChecked].sort(compareFn)); | |||
| setRightList(difference(rightList, rightListChecked).sort(compareFn)); | |||
| setCheckedList(difference(checkedList, rightListChecked)); | |||
| }; | |||
| return ( | |||
| <Stack spacing={2} direction="row" alignItems="center" position="relative"> | |||
| <ItemList | |||
| items={leftList} | |||
| checkedItems={leftListChecked} | |||
| label={allItemsLabel} | |||
| handleToggleAll={handleToggleAll} | |||
| handleToggle={handleToggle} | |||
| /> | |||
| <ItemList | |||
| items={rightList} | |||
| checkedItems={rightListChecked} | |||
| label={selectedItemsLabel} | |||
| handleToggleAll={handleToggleAll} | |||
| handleToggle={handleToggle} | |||
| /> | |||
| <Stack | |||
| spacing={1} | |||
| position="absolute" | |||
| margin="0 !important" | |||
| left="50%" | |||
| sx={{ transform: "translateX(-50%)" }} | |||
| > | |||
| <IconButton | |||
| color="secondary" | |||
| size="small" | |||
| onClick={handleCheckedRight} | |||
| disabled={leftListChecked.length === 0} | |||
| > | |||
| <ChevronRight /> | |||
| </IconButton> | |||
| <IconButton | |||
| color="secondary" | |||
| size="small" | |||
| onClick={handleCheckedLeft} | |||
| disabled={rightListChecked.length === 0} | |||
| > | |||
| <ChevronLeft /> | |||
| </IconButton> | |||
| </Stack> | |||
| </Stack> | |||
| ); | |||
| }; | |||
| export default TransferList; | |||
| @@ -0,0 +1,15 @@ | |||
| "use client"; | |||
| import React from "react"; | |||
| import TransferList, { TransferListProps } from "./TransferList"; | |||
| import { useMediaQuery, useTheme } from "@mui/material"; | |||
| import MultiSelectList from "./MultiSelectList"; | |||
| const TransferListWrapper: React.FC<TransferListProps> = (props) => { | |||
| const theme = useTheme(); | |||
| const matches = useMediaQuery(theme.breakpoints.up("sm")); | |||
| return matches ? <TransferList {...props} /> : <MultiSelectList {...props} />; | |||
| }; | |||
| export default TransferListWrapper; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./TransferListWrapper"; | |||
| @@ -11,7 +11,7 @@ export const neutral = { | |||
| 900: "#111927", | |||
| }; | |||
| export const indigo = { | |||
| export const primary = { | |||
| lightest: "#F5F7FF", | |||
| light: "#EBEEFE", | |||
| main: "#6366F1", | |||
| @@ -5,6 +5,13 @@ import palette from "./palette"; | |||
| const muiTheme = createTheme(); | |||
| const components: ThemeOptions["components"] = { | |||
| MuiAppBar: { | |||
| styleOverrides: { | |||
| colorDefault: { | |||
| backgroundColor: palette.background.paper, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiAvatar: { | |||
| styleOverrides: { | |||
| root: { | |||
| @@ -40,6 +47,26 @@ const components: ThemeOptions["components"] = { | |||
| }, | |||
| }, | |||
| }, | |||
| MuiPaper: { | |||
| styleOverrides: { | |||
| rounded: { | |||
| borderRadius: 20, | |||
| [`&.MuiPaper-elevation1`]: { | |||
| boxShadow: | |||
| "0px 5px 22px rgba(0, 0, 0, 0.04), 0px 0px 0px 0.5px rgba(0, 0, 0, 0.03)", | |||
| }, | |||
| }, | |||
| outlined: { | |||
| borderStyle: "solid", | |||
| borderWidth: 1, | |||
| overflow: "hidden", | |||
| borderColor: palette.neutral[200], | |||
| "&.MuiPaper-rounded": { | |||
| borderRadius: 8, | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiCard: { | |||
| styleOverrides: { | |||
| root: { | |||
| @@ -208,6 +235,7 @@ const components: ThemeOptions["components"] = { | |||
| notchedOutline: { | |||
| borderColor: palette.neutral[200], | |||
| transition: muiTheme.transitions.create(["border-color", "box-shadow"]), | |||
| legend: { width: 0 }, | |||
| }, | |||
| }, | |||
| }, | |||
| @@ -228,6 +256,8 @@ const components: ThemeOptions["components"] = { | |||
| }, | |||
| [`&.MuiInputLabel-outlined`]: { | |||
| transform: "translate(14px, -9px) scale(0.85)", | |||
| padding: "0 0.25rem", | |||
| background: palette.primary.contrastText, | |||
| }, | |||
| }, | |||
| }, | |||
| @@ -267,7 +297,7 @@ const components: ThemeOptions["components"] = { | |||
| color: palette.neutral[700], | |||
| fontSize: 12, | |||
| fontWeight: 600, | |||
| lineHeight: 1, | |||
| lineHeight: 2, | |||
| letterSpacing: 0.5, | |||
| textTransform: "uppercase", | |||
| }, | |||
| @@ -283,6 +313,13 @@ const components: ThemeOptions["components"] = { | |||
| variant: "filled", | |||
| }, | |||
| }, | |||
| MuiMenu: { | |||
| styleOverrides: { | |||
| list: { | |||
| padding: 0, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiMenuItem: { | |||
| styleOverrides: { | |||
| root: { | |||
| @@ -299,6 +336,15 @@ const components: ThemeOptions["components"] = { | |||
| }, | |||
| }, | |||
| }, | |||
| MuiListItem: { | |||
| styleOverrides: { | |||
| root: { | |||
| ":hover": { | |||
| backgroundColor: palette.neutral[100], | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiListItemButton: { | |||
| styleOverrides: { | |||
| root: { | |||
| @@ -324,6 +370,13 @@ const components: ThemeOptions["components"] = { | |||
| }, | |||
| }, | |||
| }, | |||
| MuiSelect: { | |||
| styleOverrides: { | |||
| select: { | |||
| borderRadius: 8, | |||
| }, | |||
| }, | |||
| }, | |||
| }; | |||
| export default components; | |||
| @@ -1,6 +1,6 @@ | |||
| import { common } from "@mui/material/colors"; | |||
| import { PaletteOptions } from "@mui/material/styles"; | |||
| import { error, indigo, info, neutral, success, warning } from "./colors"; | |||
| import { error, primary, info, neutral, success, warning } from "./colors"; | |||
| const palette = { | |||
| action: { | |||
| @@ -19,7 +19,7 @@ const palette = { | |||
| error, | |||
| info, | |||
| mode: "light", | |||
| primary: indigo, | |||
| primary, | |||
| success, | |||
| text: { | |||
| primary: neutral[900], | |||