#4 Add staff allocation and api calls

已合并
wayne.lee 1年前 将 2 次代码提交从 projects合并至 main
  1. +1
    -1
      src/app/(main)/layout.tsx
  2. +0
    -1
      src/app/(main)/projects/page.tsx
  3. +2
    -0
      src/app/(main)/tasks/create/page.tsx
  4. +19
    -0
      src/app/api/tasks/actions.ts
  5. +23
    -19
      src/app/api/tasks/index.ts
  6. +7
    -0
      src/app/logout/page.tsx
  7. +46
    -0
      src/app/utils/fetchUtil.ts
  8. +2
    -2
      src/components/AppBar/NavigationToggle.tsx
  9. +4
    -0
      src/components/CreateProject/CreateProject.tsx
  10. +31
    -0
      src/components/CreateProject/ResourceMilestone.tsx
  11. +252
    -0
      src/components/CreateProject/StaffAllocation.tsx
  12. +81
    -18
      src/components/CreateTaskTemplate/CreateTaskTemplate.tsx
  13. +11
    -0
      src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx
  14. +1
    -1
      src/components/CreateTaskTemplate/index.ts
  15. +17
    -0
      src/components/LogoutPage/LogoutPage.tsx
  16. +1
    -0
      src/components/LogoutPage/index.ts
  17. +13
    -3
      src/components/ProjectSearch/ProjectSearch.tsx
  18. +8
    -2
      src/components/SearchBox/SearchBox.tsx
  19. +40
    -14
      src/components/SearchResults/SearchResults.tsx
  20. +28
    -10
      src/components/TaskTemplateSearch/TaskTemplateSearch.tsx
  21. +46
    -16
      src/components/TransferList/MultiSelectList.tsx
  22. +53
    -9
      src/components/TransferList/TransferList.tsx
  23. +2
    -2
      src/config/api.ts
  24. +1
    -1
      src/config/authConfig.ts
  25. +1
    -1
      src/middleware.ts
  26. +2
    -2
      src/theme/devias-material-kit/components.ts

+ 1
- 1
src/app/(main)/layout.tsx 查看文件

@@ -27,7 +27,7 @@ export default async function MainLayout({
<Box
component="main"
sx={{
marginInlineStart: { xs: 0, lg: NAVIGATION_CONTENT_WIDTH },
marginInlineStart: { xs: 0, xl: NAVIGATION_CONTENT_WIDTH },
padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" },
}}
>


+ 0
- 1
src/app/(main)/projects/page.tsx 查看文件

@@ -1,6 +1,5 @@
import { preloadProjects } from "@/app/api/projects";
import ProjectSearch from "@/components/ProjectSearch";
import ProgressByClientSearch from "@/components/ProgressByClientSearch";
import { getServerI18n } from "@/i18n";
import Add from "@mui/icons-material/Add";
import Button from "@mui/material/Button";


+ 2
- 0
src/app/(main)/tasks/create/page.tsx 查看文件

@@ -1,3 +1,4 @@
import { preloadAllTasks } from "@/app/api/tasks";
import CreateTaskTemplate from "@/components/CreateTaskTemplate";
import { getServerI18n } from "@/i18n";
import Typography from "@mui/material/Typography";
@@ -9,6 +10,7 @@ export const metadata: Metadata = {

const Projects: React.FC = async () => {
const { t } = await getServerI18n("tasks");
preloadAllTasks();

return (
<>


+ 19
- 0
src/app/api/tasks/actions.ts 查看文件

@@ -0,0 +1,19 @@
"use server";

import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { TaskTemplate } from ".";

export interface NewTaskTemplateFormInputs {
code: string;
name: string;
taskIds: number[];
}

export const saveTaskTemplate = async (data: NewTaskTemplateFormInputs) => {
return serverFetchJson<TaskTemplate>(`${BASE_API_URL}/tasks/templates/new`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};

+ 23
- 19
src/app/api/tasks/index.ts 查看文件

@@ -1,7 +1,21 @@
import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { cache } from "react";
import "server-only";

export interface TaskTemplateResult {
export interface TaskGroup {
id: number;
name: string;
}

export interface Task {
id: number;
name: string;
description: string | null;
taskGroup: TaskGroup | null;
}

export interface TaskTemplate {
id: number;
code: string;
name: string;
@@ -12,23 +26,13 @@ export const preloadTaskTemplates = () => {
};

export const fetchTaskTemplates = cache(async () => {
return mockProjects;
return serverFetchJson<TaskTemplate[]>(`${BASE_API_URL}/tasks/templates`);
});

const mockProjects: TaskTemplateResult[] = [
{
id: 1,
code: "Pre-001",
name: "Pre-contract Template",
},
{
id: 2,
code: "Post-001",
name: "Post-contract Template",
},
{
id: 3,
code: "Full-001",
name: "Full Project Template",
},
];
export const preloadAllTasks = () => {
fetchAllTasks();
};

export const fetchAllTasks = cache(async () => {
return serverFetchJson<Task[]>(`${BASE_API_URL}/tasks`);
});

+ 7
- 0
src/app/logout/page.tsx 查看文件

@@ -0,0 +1,7 @@
import LogoutPage from "@/components/LogoutPage";

const Logout: React.FC = async () => {
return <LogoutPage />;
};

export default Logout;

+ 46
- 0
src/app/utils/fetchUtil.ts 查看文件

@@ -0,0 +1,46 @@
import { SessionWithTokens, authOptions } from "@/config/authConfig";
import { getServerSession } from "next-auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

export const serverFetch: typeof fetch = async (input, init) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = await getServerSession<any, SessionWithTokens>(authOptions);
const accessToken = session?.accessToken;

return fetch(input, {
...init,
headers: {
...init?.headers,
...(accessToken
? {
Authorization: `Bearer ${accessToken}`,
}
: {}),
},
});
};

type FetchParams = Parameters<typeof fetch>;

export async function serverFetchJson<T>(...args: FetchParams) {
const response = await serverFetch(...args);
if (response.ok) {
return response.json() as T;
} else {
switch (response.status) {
case 401:
signOutUser();
default:
throw Error("Something went wrong fetching data in server.");
}
}
}

export const signOutUser = () => {
const headersList = headers();
const referer = headersList.get("referer");
redirect(
`/logout${referer ? `?callbackUrl=${encodeURIComponent(referer)}` : ""}`,
);
};

+ 2
- 2
src/components/AppBar/NavigationToggle.tsx 查看文件

@@ -18,7 +18,7 @@ const NavigationToggle: React.FC = () => {
return (
<>
<Drawer variant="permanent" sx={{ display: { xs: "none", xl: "block" } }}>
<NavigationContent/>
<NavigationContent />
</Drawer>
<Drawer
sx={{ display: { xl: "none" } }}
@@ -28,7 +28,7 @@ const NavigationToggle: React.FC = () => {
keepMounted: true,
}}
>
<NavigationContent/>
<NavigationContent />
</Drawer>
<IconButton
sx={{ display: { xl: "none" } }}


+ 4
- 0
src/components/CreateProject/CreateProject.tsx 查看文件

@@ -11,6 +11,8 @@ import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import ProjectClientDetails from "./ProjectClientDetails";
import TaskSetup from "./TaskSetup";
import StaffAllocation from "./StaffAllocation";
import ResourceMilestone from "./ResourceMilestone";

const CreateProject: React.FC = () => {
const [tabIndex, setTabIndex] = useState(0);
@@ -38,6 +40,8 @@ const CreateProject: React.FC = () => {
</Tabs>
{tabIndex === 0 && <ProjectClientDetails />}
{tabIndex === 1 && <TaskSetup />}
{tabIndex === 2 && <StaffAllocation initiallySelectedStaff={[]} />}
{tabIndex === 3 && <ResourceMilestone />}
<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button variant="outlined" startIcon={<Close />} onClick={handleCancel}>
{t("Cancel")}


+ 31
- 0
src/components/CreateProject/ResourceMilestone.tsx 查看文件

@@ -0,0 +1,31 @@
"use client";

import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next";
import Button from "@mui/material/Button";
import React from "react";
import CardActions from "@mui/material/CardActions";
import RestartAlt from "@mui/icons-material/RestartAlt";

const ResourceMilestone = () => {
const { t } = useTranslation();

return (
<Card>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Resource and Milestone")}
</Typography>
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button variant="text" startIcon={<RestartAlt />}>
{t("Reset")}
</Button>
</CardActions>
</CardContent>
</Card>
);
};

export default ResourceMilestone;

+ 252
- 0
src/components/CreateProject/StaffAllocation.tsx 查看文件

@@ -0,0 +1,252 @@
"use client";

import { useTranslation } from "react-i18next";
import React from "react";
import RestartAlt from "@mui/icons-material/RestartAlt";
import SearchResults, { Column } from "../SearchResults";
import { Search, Clear, PersonAdd, PersonRemove } from "@mui/icons-material";
import {
Stack,
Typography,
Grid,
TextField,
InputAdornment,
IconButton,
FormControl,
InputLabel,
Select,
MenuItem,
Box,
Button,
Card,
CardActions,
CardContent,
TabsProps,
Tab,
Tabs,
} from "@mui/material";
import differenceBy from "lodash/differenceBy";

interface StaffResult {
id: string;
name: string;
team: string;
grade: string;
title: string;
}

const mockStaffs: StaffResult[] = [
{
name: "Albert",
grade: "1",
id: "1",
team: "ABC",
title: "Associate Quantity Surveyor",
},
{
name: "Bernard",
grade: "2",
id: "2",
team: "ABC",
title: "Quantity Surveyor",
},
{
name: "Carl",
grade: "3",
id: "3",
team: "XYZ",
title: "Senior Quantity Surveyor",
},
{ name: "Denis", grade: "4", id: "4", team: "ABC", title: "Manager" },
{ name: "Edward", grade: "5", id: "5", team: "ABC", title: "Director" },
{ name: "Fred", grade: "1", id: "6", team: "XYZ", title: "General Laborer" },
{ name: "Gordon", grade: "2", id: "7", team: "ABC", title: "Inspector" },
{
name: "Heather",
grade: "3",
id: "8",
team: "XYZ",
title: "Field Engineer",
},
{ name: "Ivan", grade: "4", id: "9", team: "ABC", title: "Senior Manager" },
{
name: "Jackson",
grade: "5",
id: "10",
team: "XYZ",
title: "Senior Director",
},
{
name: "Kurt",
grade: "1",
id: "11",
team: "ABC",
title: "Construction Assistant",
},
{ name: "Lawrence", grade: "2", id: "12", team: "ABC", title: "Operator" },
];

interface Props {
allStaff?: StaffResult[];
initiallySelectedStaff: StaffResult[];
}

const StaffAllocation: React.FC<Props> = ({
allStaff = mockStaffs,
initiallySelectedStaff,
}) => {
const { t } = useTranslation();
const [filteredStaff, setFilteredStaff] = React.useState(allStaff);
const [selectedStaff, setSelectedStaff] = React.useState<
typeof filteredStaff
>(initiallySelectedStaff);
const filters = React.useMemo<(keyof StaffResult)[]>(
() => ["team", "grade"],
[],
);
const addStaff = React.useCallback((staff: StaffResult) => {
setSelectedStaff((staffs) => [...staffs, staff]);
}, []);
const removeStaff = React.useCallback((staff: StaffResult) => {
setSelectedStaff((staffs) => staffs.filter((s) => s.id !== staff.id));
}, []);

const staffPoolColumns = React.useMemo<Column<StaffResult>[]>(
() => [
{
label: t("Add"),
name: "id",
onClick: addStaff,
buttonIcon: <PersonAdd />,
},
{ label: t("Staff ID"), name: "id" },
{ label: t("Staff Name"), name: "name" },
{ label: t("Team"), name: "team" },
{ label: t("Grade"), name: "grade" },
{ label: t("Title"), name: "title" },
],
[addStaff, t],
);

const allocatedStaffColumns = React.useMemo<Column<StaffResult>[]>(
() => [
{
label: t("Remove"),
name: "id",
onClick: removeStaff,
buttonIcon: <PersonRemove />,
},
{ label: t("Staff ID"), name: "id" },
{ label: t("Staff Name"), name: "name" },
{ label: t("Team"), name: "team" },
{ label: t("Grade"), name: "grade" },
{ label: t("Title"), name: "title" },
],
[removeStaff, t],
);

const [query, setQuery] = React.useState("");
const onQueryInputChange = React.useCallback<
React.ChangeEventHandler<HTMLInputElement>
>((e) => {
setQuery(e.target.value);
}, []);
const clearQueryInput = React.useCallback(() => {
setQuery("");
}, []);

React.useEffect(() => {
setFilteredStaff(
allStaff.filter(
(staff) =>
staff.name.toLowerCase().includes(query) ||
staff.id.toLowerCase().includes(query) ||
staff.title.toLowerCase().includes(query),
),
);
}, [allStaff, query]);

const [tabIndex, setTabIndex] = React.useState(0);
const handleTabChange = React.useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
},
[],
);

return (
<Card>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Stack gap={2}>
<Typography variant="overline" display="block">
{t("Staff Allocation")}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={6} display="flex" alignItems="center">
<Search sx={{ marginInlineEnd: 1 }} />
<TextField
variant="standard"
fullWidth
onChange={onQueryInputChange}
value={query}
placeholder={t("Search by staff ID, name or title")}
InputProps={{
endAdornment: query && (
<InputAdornment position="end">
<IconButton onClick={clearQueryInput}>
<Clear />
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
{filters.map((filter, idx) => {
const label = staffPoolColumns.find(
(c) => c.name === filter,
)!.label;

return (
<Grid key={`${filter.toString()}-${idx}`} item xs={3}>
<FormControl fullWidth>
<InputLabel size="small">{label}</InputLabel>
<Select label={label} size="small">
<MenuItem value={"All"}>{t("All")}</MenuItem>
</Select>
</FormControl>
</Grid>
);
})}
</Grid>
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={t("Staff Pool")} />
<Tab label={t("Allocated Staff")} />
</Tabs>
<Box sx={{ marginInline: -3 }}>
{tabIndex === 0 && (
<SearchResults
noWrapper
items={differenceBy(filteredStaff, selectedStaff, "id")}
columns={staffPoolColumns}
/>
)}
{tabIndex === 1 && (
<SearchResults
noWrapper
items={selectedStaff}
columns={allocatedStaffColumns}
/>
)}
</Box>
</Stack>
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button variant="text" startIcon={<RestartAlt />}>
{t("Reset")}
</Button>
</CardActions>
</CardContent>
</Card>
);
};

export default StaffAllocation;

+ 81
- 18
src/components/CreateTaskTemplate/CreateTaskTemplate.tsx 查看文件

@@ -13,8 +13,18 @@ import Close from "@mui/icons-material/Close";
import { useRouter } from "next/navigation";
import React from "react";
import Stack from "@mui/material/Stack";
import { Task } from "@/app/api/tasks";
import {
NewTaskTemplateFormInputs,
saveTaskTemplate,
} from "@/app/api/tasks/actions";
import { SubmitHandler, useForm } from "react-hook-form";

const CreateTaskTemplate = () => {
interface Props {
tasks: Task[];
}

const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => {
const { t } = useTranslation();

const router = useRouter();
@@ -22,8 +32,40 @@ const CreateTaskTemplate = () => {
router.back();
};

const items = React.useMemo(
() =>
tasks.map((task) => ({
id: task.id,
label: task.name,
group: task.taskGroup || undefined,
})),
[tasks],
);

const [serverError, setServerError] = React.useState("");

const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
} = useForm<NewTaskTemplateFormInputs>();

const onSubmit: SubmitHandler<NewTaskTemplateFormInputs> = React.useCallback(
async (data) => {
try {
setServerError("");
await saveTaskTemplate(data);
router.replace("/tasks");
} catch (e) {
setServerError(t("An error has occurred. Please try again later."));
}
},
[router, t],
);

return (
<>
<Stack component="form" onSubmit={handleSubmit(onSubmit)} gap={2}>
<Card>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="overline">{t("Task List Setup")}</Typography>
@@ -34,40 +76,61 @@ const CreateTaskTemplate = () => {
marginBlockEnd={1}
>
<Grid item xs={6}>
<TextField label={t("Task Template Code")} fullWidth />
<TextField
label={t("Task Template Code")}
fullWidth
{...register("code", {
required: t("Task template code is required"),
})}
error={Boolean(errors.code?.message)}
helperText={errors.code?.message}
/>
</Grid>
<Grid item xs={6}>
<TextField label={t("Task Template Name")} fullWidth />
<TextField
label={t("Task Template Name")}
fullWidth
{...register("name", {
required: t("Task template name is required"),
})}
error={Boolean(errors.name?.message)}
helperText={errors.name?.message}
/>
</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" },
]}
allItems={items}
initiallySelectedItems={[]}
onChange={() => {}}
onChange={(selectedItems) => {
setValue(
"taskIds",
selectedItems.map((item) => item.id),
);
}}
allItemsLabel={t("Task Pool")}
selectedItemsLabel={t("Task List Template")}
/>
</CardContent>
</Card>
{serverError && (
<Typography variant="body2" color="error" alignSelf="flex-end">
{serverError}
</Typography>
)}
<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button variant="outlined" startIcon={<Close />} onClick={handleCancel}>
{t("Cancel")}
</Button>
<Button variant="contained" startIcon={<Check />}>
<Button
variant="contained"
startIcon={<Check />}
type="submit"
disabled={isSubmitting}
>
{t("Confirm")}
</Button>
</Stack>
</>
</Stack>
);
};



+ 11
- 0
src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx 查看文件

@@ -0,0 +1,11 @@
import React from "react";
import CreateTaskTemplate from "./CreateTaskTemplate";
import { fetchAllTasks } from "@/app/api/tasks";

const CreateTaskTemplateWrapper: React.FC = async () => {
const tasks = await fetchAllTasks();

return <CreateTaskTemplate tasks={tasks} />;
};

export default CreateTaskTemplateWrapper;

+ 1
- 1
src/components/CreateTaskTemplate/index.ts 查看文件

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

+ 17
- 0
src/components/LogoutPage/LogoutPage.tsx 查看文件

@@ -0,0 +1,17 @@
"use client";

import { signOut } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";

const LogoutPage = () => {
const params = useSearchParams();
const callbackUrl = params.get("callbackUrl");
useEffect(() => {
signOut({ redirect: true, callbackUrl: callbackUrl || "/" });
});

return null;
};

export default LogoutPage;

+ 1
- 0
src/components/LogoutPage/index.ts 查看文件

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

+ 13
- 3
src/components/ProjectSearch/ProjectSearch.tsx 查看文件

@@ -1,10 +1,11 @@
"use client";

import { ProjectResult } from "@/app/api/projects";
import React, { useMemo, useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { useTranslation } from "react-i18next";
import SearchResults, { Column } from "../SearchResults";
import EditNote from "@mui/icons-material/EditNote";

interface Props {
projects: ProjectResult[];
@@ -45,16 +46,25 @@ const ProjectSearch: React.FC<Props> = ({ projects }) => {
[t],
);

const onProjectClick = useCallback((project: ProjectResult) => {
console.log(project);
}, []);

const columns = useMemo<Column<ProjectResult>[]>(
() => [
{ name: "id", label: t("Details") },
{
name: "id",
label: t("Details"),
onClick: onProjectClick,
buttonIcon: <EditNote />,
},
{ name: "code", label: t("Project Code") },
{ name: "name", label: t("Project Name") },
{ name: "category", label: t("Project Category") },
{ name: "team", label: t("Team") },
{ name: "client", label: t("Client") },
],
[t],
[t, onProjectClick],
);

return (


+ 8
- 2
src/components/SearchBox/SearchBox.tsx 查看文件

@@ -35,9 +35,14 @@ export type Criterion<T extends string> = TextCriterion<T> | SelectCriterion<T>;
interface Props<T extends string> {
criteria: Criterion<T>[];
onSearch: (inputs: Record<T, string>) => void;
onReset?: () => void;
}

function SearchBox<T extends string>({ criteria, onSearch }: Props<T>) {
function SearchBox<T extends string>({
criteria,
onSearch,
onReset,
}: Props<T>) {
const { t } = useTranslation("common");
const defaultInputs = useMemo(
() =>
@@ -68,6 +73,7 @@ function SearchBox<T extends string>({ criteria, onSearch }: Props<T>) {

const handleReset = () => {
setInputs(defaultInputs);
onReset?.();
};

const handleSearch = () => {
@@ -77,7 +83,7 @@ function SearchBox<T extends string>({ criteria, onSearch }: Props<T>) {
return (
<Card>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="overline" style={{fontWeight:"600"}}>{t("Search Criteria")}</Typography>
<Typography variant="overline">{t("Search Criteria")}</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
{criteria.map((c) => {
return (


+ 40
- 14
src/components/SearchResults/SearchResults.tsx 查看文件

@@ -12,23 +12,42 @@ import TablePagination, {
} from "@mui/material/TablePagination";
import TableRow from "@mui/material/TableRow";
import IconButton from "@mui/material/IconButton";
import EditNote from "@mui/icons-material/EditNote";

interface ResultWithId {
export interface ResultWithId {
id: string | number;
}

export interface Column<T extends ResultWithId> {
interface BaseColumn<T extends ResultWithId> {
name: keyof T;
label: string;
}

interface ColumnWithAction<T extends ResultWithId> extends BaseColumn<T> {
onClick: (item: T) => void;
buttonIcon: React.ReactNode;
}

export type Column<T extends ResultWithId> =
| BaseColumn<T>
| ColumnWithAction<T>;

interface Props<T extends ResultWithId> {
items: T[];
columns: Column<T>[];
noWrapper?: boolean;
}

function SearchResults<T extends ResultWithId>({ items, columns }: Props<T>) {
function isActionColumn<T extends ResultWithId>(
column: Column<T>,
): column is ColumnWithAction<T> {
return Boolean((column as ColumnWithAction<T>).onClick);
}

function SearchResults<T extends ResultWithId>({
items,
columns,
noWrapper,
}: Props<T>) {
const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(10);

@@ -46,14 +65,14 @@ function SearchResults<T extends ResultWithId>({ items, columns }: Props<T>) {
setPage(0);
};

return (
<Paper sx={{ overflow: "hidden" }}>
const table = (
<>
<TableContainer sx={{ maxHeight: 440 }}>
<Table stickyHeader>
<TableHead>
<TableRow>
{columns.map((column) => (
<TableCell key={column.name.toString()}>
{columns.map((column, idx) => (
<TableCell key={`${column.name.toString()}${idx}`}>
{column.label}
</TableCell>
))}
@@ -65,12 +84,17 @@ function SearchResults<T extends ResultWithId>({ items, columns }: Props<T>) {
.map((item) => {
return (
<TableRow hover tabIndex={-1} key={item.id}>
{columns.map(({ name: columnName }) => {
{columns.map((column, idx) => {
const columnName = column.name;

return (
<TableCell key={columnName.toString()}>
{columnName === "id" ? (
<IconButton color="primary">
<EditNote />
<TableCell key={`${columnName.toString()}-${idx}`}>
{isActionColumn(column) ? (
<IconButton
color="primary"
onClick={() => column.onClick(item)}
>
{column.buttonIcon}
</IconButton>
) : (
<>{item[columnName]}</>
@@ -93,8 +117,10 @@ function SearchResults<T extends ResultWithId>({ items, columns }: Props<T>) {
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</Paper>
</>
);

return noWrapper ? table : <Paper sx={{ overflow: "hidden" }}>{table}</Paper>;
}

export default SearchResults;

+ 28
- 10
src/components/TaskTemplateSearch/TaskTemplateSearch.tsx 查看文件

@@ -1,24 +1,23 @@
"use client";

import { TaskTemplateResult } from "@/app/api/tasks";
import React, { useMemo, useState } from "react";
import { TaskTemplate } from "@/app/api/tasks";
import React, { useCallback, useMemo, useState } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { useTranslation } from "react-i18next";
import SearchResults, { Column } from "../SearchResults";
import EditNote from "@mui/icons-material/EditNote";

interface Props {
taskTemplates: TaskTemplateResult[];
taskTemplates: TaskTemplate[];
}

type SearchQuery = Partial<Omit<TaskTemplateResult, "id">>;
type SearchQuery = Partial<Omit<TaskTemplate, "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" },
@@ -26,14 +25,26 @@ const TaskTemplateSearch: React.FC<Props> = ({ taskTemplates }) => {
],
[t],
);
const onReset = useCallback(() => {
setFilteredTemplates(taskTemplates);
}, [taskTemplates]);

const onTaskClick = useCallback((taskTemplate: TaskTemplate) => {
console.log(taskTemplate);
}, []);

const columns = useMemo<Column<TaskTemplateResult>[]>(
const columns = useMemo<Column<TaskTemplate>[]>(
() => [
{ name: "id", label: t("Details") },
{
name: "id",
label: t("Details"),
onClick: onTaskClick,
buttonIcon: <EditNote />,
},
{ name: "code", label: t("Task Template Code") },
{ name: "name", label: t("Task Template Name") },
],
[t],
[onTaskClick, t],
);

return (
@@ -41,8 +52,15 @@ const TaskTemplateSearch: React.FC<Props> = ({ taskTemplates }) => {
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
console.log(query);
setFilteredTemplates(
taskTemplates.filter(
(task) =>
task.code.toLowerCase().includes(query.code) &&
task.name.toLowerCase().includes(query.name),
),
);
}}
onReset={onReset}
/>
<SearchResults items={filteredTemplates} columns={columns} />
</>


+ 46
- 16
src/components/TransferList/MultiSelectList.tsx 查看文件

@@ -15,8 +15,11 @@ import {
Stack,
Typography,
} from "@mui/material";
import React, { useCallback, useState } from "react";
import { LabelWithId, TransferListProps } from "./TransferList";
import React, { useCallback, useEffect, useState } from "react";
import { LabelGroup, LabelWithId, TransferListProps } from "./TransferList";
import { useTranslation } from "react-i18next";
import uniqBy from "lodash/uniqBy";
import groupBy from "lodash/groupBy";

export const MultiSelectList: React.FC<TransferListProps> = ({
allItems,
@@ -33,7 +36,7 @@ export const MultiSelectList: React.FC<TransferListProps> = ({
);
}, [allItems]);
const compareFn = React.useCallback(
(a: string, b: string) => sortMap[a].index - sortMap[b].index,
(a: number, b: number) => sortMap[a].index - sortMap[b].index,
[sortMap],
);
const [selectedItems, setSelectedItems] = useState(
@@ -45,7 +48,7 @@ export const MultiSelectList: React.FC<TransferListProps> = ({
const {
target: { value },
} = event;
setSelectedItems(typeof value === "string" ? [value] : value);
setSelectedItems(typeof value === "string" ? [Number(value)] : value);
}, []);

const handleToggleAll = useCallback(
@@ -59,6 +62,23 @@ export const MultiSelectList: React.FC<TransferListProps> = ({
[allItems, selectedItems.length],
);

useEffect(() => {
onChange(selectedItems.map((item) => sortMap[item]));
}, [onChange, selectedItems, sortMap]);

const { t } = useTranslation();
const groups: LabelGroup[] = uniqBy(
[
...allItems.reduce<LabelGroup[]>((acc, item) => {
return item.group ? [...acc, item.group] : acc;
}, []),
// Items with no group
{ id: 0, name: t("Ungrouped") },
],
"id",
);
const groupedItems = groupBy(allItems, (item) => item.group?.id ?? 0);

return (
<Box>
<FormControl fullWidth>
@@ -121,18 +141,28 @@ export const MultiSelectList: React.FC<TransferListProps> = ({
</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>
);
{groups.flatMap((group) => {
const groupItems = groupedItems[group.id];
if (!groupItems) return null;

return [
<ListSubheader disableSticky key={`${group.id}-${group.name}`}>
{group.name}
</ListSubheader>,
...groupItems.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>


+ 53
- 9
src/components/TransferList/TransferList.tsx 查看文件

@@ -16,16 +16,25 @@ import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import ListSubheader from "@mui/material/ListSubheader";
import groupBy from "lodash/groupBy";
import uniqBy from "lodash/uniqBy";
import { useTranslation } from "react-i18next";

export interface LabelGroup {
id: number;
name: string;
}

export interface LabelWithId {
id: string;
id: number;
label: string;
group?: LabelGroup;
}

export interface TransferListProps {
allItems: LabelWithId[];
initiallySelectedItems: LabelWithId[];
onChange: () => void;
onChange: (selectedItems: LabelWithId[]) => void;
allItemsLabel: string;
selectedItemsLabel: string;
}
@@ -48,6 +57,19 @@ const ItemList: React.FC<ItemListProps> = ({
handleToggle,
handleToggleAll,
}) => {
const { t } = useTranslation();
const groups: LabelGroup[] = uniqBy(
[
...items.reduce<LabelGroup[]>((acc, item) => {
return item.group ? [...acc, item.group] : acc;
}, []),
// Items with no group
{ id: 0, name: t("Ungrouped") },
],
"id",
);
const groupedItems = groupBy(items, (item) => item.group?.id ?? 0);

return (
<Paper sx={{ width: "100%" }} variant="outlined">
<List
@@ -87,14 +109,32 @@ const ItemList: React.FC<ItemListProps> = ({
</ListSubheader>
}
>
{items.map((item) => {
{groups.map((group) => {
const groupItems = groupedItems[group.id];
if (!groupItems) return null;

return (
<ListItem key={item.id} onClick={handleToggle(item)}>
<ListItemIcon>
<Checkbox checked={checkedItems.includes(item)} tabIndex={-1} />
</ListItemIcon>
<ListItemText primary={item.label} />
</ListItem>
<React.Fragment key={group.id}>
<ListSubheader
disableSticky
sx={{ paddingBlock: 2, lineHeight: 1.8 }}
>
{group.name}
</ListSubheader>
{groupItems.map((item) => {
return (
<ListItem key={item.id} onClick={handleToggle(item)}>
<ListItemIcon>
<Checkbox
checked={checkedItems.includes(item)}
tabIndex={-1}
/>
</ListItemIcon>
<ListItemText primary={item.label} />
</ListItem>
);
})}
</React.Fragment>
);
})}
</List>
@@ -167,6 +207,10 @@ const TransferList: React.FC<TransferListProps> = ({
setCheckedList(difference(checkedList, rightListChecked));
};

React.useEffect(() => {
onChange(rightList);
}, [onChange, rightList]);

return (
<Stack spacing={2} direction="row" alignItems="center" position="relative">
<ItemList


+ 2
- 2
src/config/api.ts 查看文件

@@ -1,2 +1,2 @@
export const BASE_API_URL = `${process.env.API_PROTOCOL}://${process.env.API_HOST}:${process.env.API_PORT}`;
export const LOGIN_API_PATH = `${BASE_API_URL}/api/login`;
export const BASE_API_URL = `${process.env.API_PROTOCOL}://${process.env.API_HOST}:${process.env.API_PORT}/api`;
export const LOGIN_API_PATH = `${BASE_API_URL}/login`;

+ 1
- 1
src/config/authConfig.ts 查看文件

@@ -2,7 +2,7 @@ import { AuthOptions, Session } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { LOGIN_API_PATH } from "./api";

interface SessionWithTokens extends Session {
export interface SessionWithTokens extends Session {
accessToken?: string;
refreshToken?: string;
}


+ 1
- 1
src/middleware.ts 查看文件

@@ -2,7 +2,7 @@ import { NextRequestWithAuth, withAuth } from "next-auth/middleware";
import { authOptions } from "@/config/authConfig";
import { NextFetchEvent, NextResponse } from "next/server";

const PUBLIC_ROUTES = ["/login"];
const PUBLIC_ROUTES = ["/login", "/logout"];
const LANG_QUERY_PARAM = "lang";

const authMiddleware = withAuth({


+ 2
- 2
src/theme/devias-material-kit/components.ts 查看文件

@@ -189,7 +189,7 @@ const components: ThemeOptions["components"] = {
},
[`&.Mui-focused`]: {
backgroundColor: "transparent",
borderColor: "palette.primary.main",
borderColor: palette.primary.main,
boxShadow: `${palette.primary.main} 0 0 0 2px`,
},
[`&.Mui-error`]: {
@@ -216,7 +216,7 @@ const components: ThemeOptions["components"] = {
[`&.Mui-focused`]: {
backgroundColor: "transparent",
[`& .MuiOutlinedInput-notchedOutline`]: {
borderColor: "palette.primary.main",
borderColor: palette.primary.main,
boxShadow: `${palette.primary.main} 0 0 0 2px`,
},
},


正在加载...
取消
保存