Przeglądaj źródła

Merge pull request 'Added basic project and task management page' (#3) from projects into main

Reviewed-on: https://git.2fi-solutions.com/wayne.lee/tsms/pulls/3
tags/Baseline_30082024_FRONTEND_UAT
wayne.lee 1 rok temu
rodzic
commit
1a4c4467e1
38 zmienionych plików z 2620 dodań i 605 usunięć
  1. +4
    -3
      .eslintrc.json
  2. +1132
    -592
      package-lock.json
  3. +4
    -0
      package.json
  4. +7
    -1
      src/app/(main)/layout.tsx
  5. +21
    -0
      src/app/(main)/projects/create/page.tsx
  6. +37
    -1
      src/app/(main)/projects/page.tsx
  7. +21
    -0
      src/app/(main)/tasks/create/page.tsx
  8. +39
    -3
      src/app/(main)/tasks/page.tsx
  9. +46
    -0
      src/app/api/projects/index.ts
  10. +34
    -0
      src/app/api/tasks/index.ts
  11. +1
    -1
      src/components/AppBar/AppBar.tsx
  12. +51
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  13. +1
    -0
      src/components/Breadcrumb/index.ts
  14. +53
    -0
      src/components/CreateProject/CreateProject.tsx
  15. +110
    -0
      src/components/CreateProject/ProjectClientDetails.tsx
  16. +70
    -0
      src/components/CreateProject/TaskSetup.tsx
  17. +1
    -0
      src/components/CreateProject/index.ts
  18. +74
    -0
      src/components/CreateTaskTemplate/CreateTaskTemplate.tsx
  19. +1
    -0
      src/components/CreateTaskTemplate/index.ts
  20. +76
    -0
      src/components/ProjectSearch/ProjectSearch.tsx
  21. +40
    -0
      src/components/ProjectSearch/ProjectSearchLoading.tsx
  22. +18
    -0
      src/components/ProjectSearch/ProjectSearchWrapper.tsx
  23. +1
    -0
      src/components/ProjectSearch/index.ts
  24. +135
    -0
      src/components/SearchBox/SearchBox.tsx
  25. +2
    -0
      src/components/SearchBox/index.ts
  26. +100
    -0
      src/components/SearchResults/SearchResults.tsx
  27. +2
    -0
      src/components/SearchResults/index.ts
  28. +52
    -0
      src/components/TaskTemplateSearch/TaskTemplateSearch.tsx
  29. +38
    -0
      src/components/TaskTemplateSearch/TaskTemplateSearchLoading.tsx
  30. +18
    -0
      src/components/TaskTemplateSearch/TaskTemplateSearchWrapper.tsx
  31. +1
    -0
      src/components/TaskTemplateSearch/index.ts
  32. +143
    -0
      src/components/TransferList/MultiSelectList.tsx
  33. +214
    -0
      src/components/TransferList/TransferList.tsx
  34. +15
    -0
      src/components/TransferList/TransferListWrapper.tsx
  35. +1
    -0
      src/components/TransferList/index.ts
  36. +1
    -1
      src/theme/devias-material-kit/colors.ts
  37. +54
    -1
      src/theme/devias-material-kit/components.ts
  38. +2
    -2
      src/theme/devias-material-kit/palette.ts

+ 4
- 3
.eslintrc.json Wyświetl plik

@@ -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"
}
}

+ 1132
- 592
package-lock.json
Plik diff jest za duży
Wyświetl plik


+ 4
- 0
package.json Wyświetl plik

@@ -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",


+ 7
- 1
src/app/(main)/layout.tsx Wyświetl plik

@@ -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>
</>
);


+ 21
- 0
src/app/(main)/projects/create/page.tsx Wyświetl plik

@@ -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;

+ 37
- 1
src/app/(main)/projects/page.tsx Wyświetl plik

@@ -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;

+ 21
- 0
src/app/(main)/tasks/create/page.tsx Wyświetl plik

@@ -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;

+ 39
- 3
src/app/(main)/tasks/page.tsx Wyświetl plik

@@ -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;

+ 46
- 0
src/app/api/projects/index.ts Wyświetl plik

@@ -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",
},
];

+ 34
- 0
src/app/api/tasks/index.ts Wyświetl plik

@@ -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",
},
];

+ 1
- 1
src/components/AppBar/AppBar.tsx Wyświetl plik

@@ -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


+ 51
- 0
src/components/Breadcrumb/Breadcrumb.tsx Wyświetl plik

@@ -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;

+ 1
- 0
src/components/Breadcrumb/index.ts Wyświetl plik

@@ -0,0 +1 @@
export { default } from "./Breadcrumb";

+ 53
- 0
src/components/CreateProject/CreateProject.tsx Wyświetl plik

@@ -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;

+ 110
- 0
src/components/CreateProject/ProjectClientDetails.tsx Wyświetl plik

@@ -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;

+ 70
- 0
src/components/CreateProject/TaskSetup.tsx Wyświetl plik

@@ -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;

+ 1
- 0
src/components/CreateProject/index.ts Wyświetl plik

@@ -0,0 +1 @@
export { default } from "./CreateProject";

+ 74
- 0
src/components/CreateTaskTemplate/CreateTaskTemplate.tsx Wyświetl plik

@@ -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;

+ 1
- 0
src/components/CreateTaskTemplate/index.ts Wyświetl plik

@@ -0,0 +1 @@
export { default } from "./CreateTaskTemplate";

+ 76
- 0
src/components/ProjectSearch/ProjectSearch.tsx Wyświetl plik

@@ -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;

+ 40
- 0
src/components/ProjectSearch/ProjectSearchLoading.tsx Wyświetl plik

@@ -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;

+ 18
- 0
src/components/ProjectSearch/ProjectSearchWrapper.tsx Wyświetl plik

@@ -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;

+ 1
- 0
src/components/ProjectSearch/index.ts Wyświetl plik

@@ -0,0 +1 @@
export { default } from "./ProjectSearchWrapper";

+ 135
- 0
src/components/SearchBox/SearchBox.tsx Wyświetl plik

@@ -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;

+ 2
- 0
src/components/SearchBox/index.ts Wyświetl plik

@@ -0,0 +1,2 @@
export { default } from "./SearchBox";
export type { Criterion } from "./SearchBox";

+ 100
- 0
src/components/SearchResults/SearchResults.tsx Wyświetl plik

@@ -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;

+ 2
- 0
src/components/SearchResults/index.ts Wyświetl plik

@@ -0,0 +1,2 @@
export { default } from "./SearchResults";
export type { Column } from "./SearchResults";

+ 52
- 0
src/components/TaskTemplateSearch/TaskTemplateSearch.tsx Wyświetl plik

@@ -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;

+ 38
- 0
src/components/TaskTemplateSearch/TaskTemplateSearchLoading.tsx Wyświetl plik

@@ -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;

+ 18
- 0
src/components/TaskTemplateSearch/TaskTemplateSearchWrapper.tsx Wyświetl plik

@@ -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;

+ 1
- 0
src/components/TaskTemplateSearch/index.ts Wyświetl plik

@@ -0,0 +1 @@
export { default } from "./TaskTemplateSearchWrapper";

+ 143
- 0
src/components/TransferList/MultiSelectList.tsx Wyświetl plik

@@ -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;

+ 214
- 0
src/components/TransferList/TransferList.tsx Wyświetl plik

@@ -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;

+ 15
- 0
src/components/TransferList/TransferListWrapper.tsx Wyświetl plik

@@ -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;

+ 1
- 0
src/components/TransferList/index.ts Wyświetl plik

@@ -0,0 +1 @@
export { default } from "./TransferListWrapper";

+ 1
- 1
src/theme/devias-material-kit/colors.ts Wyświetl plik

@@ -11,7 +11,7 @@ export const neutral = {
900: "#111927",
};

export const indigo = {
export const primary = {
lightest: "#F5F7FF",
light: "#EBEEFE",
main: "#6366F1",


+ 54
- 1
src/theme/devias-material-kit/components.ts Wyświetl plik

@@ -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;

+ 2
- 2
src/theme/devias-material-kit/palette.ts Wyświetl plik

@@ -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],


Ładowanie…
Anuluj
Zapisz