Reviewed-on: https://git.2fi-solutions.com/wayne.lee/tsms/pulls/3tags/Baseline_30082024_FRONTEND_UAT
@@ -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": { | "rules": { | ||||
"prettier/prettier": "warn", | "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", | "dayjs": "^1.11.10", | ||||
"i18next": "^23.7.11", | "i18next": "^23.7.11", | ||||
"i18next-resources-to-backend": "^1.2.0", | "i18next-resources-to-backend": "^1.2.0", | ||||
"lodash": "^4.17.21", | |||||
"next": "14.0.4", | "next": "14.0.4", | ||||
"next-auth": "^4.24.5", | "next-auth": "^4.24.5", | ||||
"react": "^18", | "react": "^18", | ||||
@@ -32,9 +33,12 @@ | |||||
"react-intl": "^6.5.5" | "react-intl": "^6.5.5" | ||||
}, | }, | ||||
"devDependencies": { | "devDependencies": { | ||||
"@types/lodash": "^4.14.202", | |||||
"@types/node": "^20", | "@types/node": "^20", | ||||
"@types/react": "^18", | "@types/react": "^18", | ||||
"@types/react-dom": "^18", | "@types/react-dom": "^18", | ||||
"@typescript-eslint/eslint-plugin": "^6.18.1", | |||||
"@typescript-eslint/parser": "^6.18.1", | |||||
"autoprefixer": "^10.4.16", | "autoprefixer": "^10.4.16", | ||||
"eslint": "^8", | "eslint": "^8", | ||||
"eslint-config-next": "14.0.4", | "eslint-config-next": "14.0.4", | ||||
@@ -4,6 +4,8 @@ import { authOptions } from "@/config/authConfig"; | |||||
import { redirect } from "next/navigation"; | import { redirect } from "next/navigation"; | ||||
import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | ||||
import Stack from "@mui/material/Stack"; | |||||
import Breadcrumb from "@/components/Breadcrumb"; | |||||
export default async function MainLayout({ | export default async function MainLayout({ | ||||
children, | children, | ||||
@@ -26,9 +28,13 @@ export default async function MainLayout({ | |||||
component="main" | component="main" | ||||
sx={{ | sx={{ | ||||
marginInlineStart: { xs: 0, lg: NAVIGATION_CONTENT_WIDTH }, | marginInlineStart: { xs: 0, lg: NAVIGATION_CONTENT_WIDTH }, | ||||
padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" }, | |||||
}} | }} | ||||
> | > | ||||
{children} | |||||
<Stack spacing={2}> | |||||
<Breadcrumb /> | |||||
{children} | |||||
</Stack> | |||||
</Box> | </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 { Metadata } from "next"; | ||||
import Link from "next/link"; | |||||
import { Suspense } from "react"; | |||||
export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
title: "Projects", | title: "Projects", | ||||
}; | }; | ||||
const Projects: React.FC = async () => { | 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; | 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 { Metadata } from "next"; | ||||
import Link from "next/link"; | |||||
import { Suspense } from "react"; | |||||
export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
title: "Tasks", | 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 }) => { | const AppBar: React.FC<AppBarProps> = ({ avatarImageSrc, profileName }) => { | ||||
return ( | return ( | ||||
<I18nProvider namespaces={["common"]}> | <I18nProvider namespaces={["common"]}> | ||||
<MUIAppBar position="sticky"> | |||||
<MUIAppBar position="sticky" color="default" elevation={4}> | |||||
<Toolbar> | <Toolbar> | ||||
<NavigationToggle /> | <NavigationToggle /> | ||||
<Box | <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", | 900: "#111927", | ||||
}; | }; | ||||
export const indigo = { | |||||
export const primary = { | |||||
lightest: "#F5F7FF", | lightest: "#F5F7FF", | ||||
light: "#EBEEFE", | light: "#EBEEFE", | ||||
main: "#6366F1", | main: "#6366F1", | ||||
@@ -5,6 +5,13 @@ import palette from "./palette"; | |||||
const muiTheme = createTheme(); | const muiTheme = createTheme(); | ||||
const components: ThemeOptions["components"] = { | const components: ThemeOptions["components"] = { | ||||
MuiAppBar: { | |||||
styleOverrides: { | |||||
colorDefault: { | |||||
backgroundColor: palette.background.paper, | |||||
}, | |||||
}, | |||||
}, | |||||
MuiAvatar: { | MuiAvatar: { | ||||
styleOverrides: { | styleOverrides: { | ||||
root: { | 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: { | MuiCard: { | ||||
styleOverrides: { | styleOverrides: { | ||||
root: { | root: { | ||||
@@ -208,6 +235,7 @@ const components: ThemeOptions["components"] = { | |||||
notchedOutline: { | notchedOutline: { | ||||
borderColor: palette.neutral[200], | borderColor: palette.neutral[200], | ||||
transition: muiTheme.transitions.create(["border-color", "box-shadow"]), | transition: muiTheme.transitions.create(["border-color", "box-shadow"]), | ||||
legend: { width: 0 }, | |||||
}, | }, | ||||
}, | }, | ||||
}, | }, | ||||
@@ -228,6 +256,8 @@ const components: ThemeOptions["components"] = { | |||||
}, | }, | ||||
[`&.MuiInputLabel-outlined`]: { | [`&.MuiInputLabel-outlined`]: { | ||||
transform: "translate(14px, -9px) scale(0.85)", | 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], | color: palette.neutral[700], | ||||
fontSize: 12, | fontSize: 12, | ||||
fontWeight: 600, | fontWeight: 600, | ||||
lineHeight: 1, | |||||
lineHeight: 2, | |||||
letterSpacing: 0.5, | letterSpacing: 0.5, | ||||
textTransform: "uppercase", | textTransform: "uppercase", | ||||
}, | }, | ||||
@@ -283,6 +313,13 @@ const components: ThemeOptions["components"] = { | |||||
variant: "filled", | variant: "filled", | ||||
}, | }, | ||||
}, | }, | ||||
MuiMenu: { | |||||
styleOverrides: { | |||||
list: { | |||||
padding: 0, | |||||
}, | |||||
}, | |||||
}, | |||||
MuiMenuItem: { | MuiMenuItem: { | ||||
styleOverrides: { | styleOverrides: { | ||||
root: { | root: { | ||||
@@ -299,6 +336,15 @@ const components: ThemeOptions["components"] = { | |||||
}, | }, | ||||
}, | }, | ||||
}, | }, | ||||
MuiListItem: { | |||||
styleOverrides: { | |||||
root: { | |||||
":hover": { | |||||
backgroundColor: palette.neutral[100], | |||||
}, | |||||
}, | |||||
}, | |||||
}, | |||||
MuiListItemButton: { | MuiListItemButton: { | ||||
styleOverrides: { | styleOverrides: { | ||||
root: { | root: { | ||||
@@ -324,6 +370,13 @@ const components: ThemeOptions["components"] = { | |||||
}, | }, | ||||
}, | }, | ||||
}, | }, | ||||
MuiSelect: { | |||||
styleOverrides: { | |||||
select: { | |||||
borderRadius: 8, | |||||
}, | |||||
}, | |||||
}, | |||||
}; | }; | ||||
export default components; | export default components; |
@@ -1,6 +1,6 @@ | |||||
import { common } from "@mui/material/colors"; | import { common } from "@mui/material/colors"; | ||||
import { PaletteOptions } from "@mui/material/styles"; | 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 = { | const palette = { | ||||
action: { | action: { | ||||
@@ -19,7 +19,7 @@ const palette = { | |||||
error, | error, | ||||
info, | info, | ||||
mode: "light", | mode: "light", | ||||
primary: indigo, | |||||
primary, | |||||
success, | success, | ||||
text: { | text: { | ||||
primary: neutral[900], | primary: neutral[900], | ||||