選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

315 行
9.7 KiB

  1. "use client";
  2. import { useTranslation } from "react-i18next";
  3. import React, { useEffect, useMemo } from "react";
  4. import RestartAlt from "@mui/icons-material/RestartAlt";
  5. import SearchResults, { Column } from "../SearchResults";
  6. import { Search, Clear, PersonAdd, PersonRemove } from "@mui/icons-material";
  7. import {
  8. Stack,
  9. Typography,
  10. Grid,
  11. TextField,
  12. InputAdornment,
  13. IconButton,
  14. FormControl,
  15. InputLabel,
  16. Select,
  17. MenuItem,
  18. Box,
  19. Button,
  20. Card,
  21. CardActions,
  22. CardContent,
  23. TabsProps,
  24. Tab,
  25. Tabs,
  26. SelectChangeEvent,
  27. } from "@mui/material";
  28. import differenceWith from "lodash/differenceWith";
  29. import intersectionWith from "lodash/intersectionWith";
  30. import uniq from "lodash/uniq";
  31. import ResourceCapacity from "./ResourceCapacity";
  32. import { useFormContext } from "react-hook-form";
  33. import { CreateProjectInputs } from "@/app/api/projects/actions";
  34. import ResourceAllocation from "./ResourceAllocation";
  35. import { Task } from "@/app/api/tasks";
  36. import { Grade } from "@/app/api/grades";
  37. import { StaffResult } from "@/app/api/staff";
  38. const staffComparator = (a: StaffResult, b: StaffResult) => {
  39. return (
  40. a.team?.localeCompare(b.team) ||
  41. a.grade?.localeCompare(b.grade) ||
  42. a.id - b.id
  43. );
  44. };
  45. export interface Props {
  46. allStaffs: StaffResult[];
  47. isActive: boolean;
  48. defaultManhourBreakdownByGrade?: { [gradeId: number]: number };
  49. allTasks: Task[];
  50. grades: Grade[];
  51. }
  52. const StaffAllocation: React.FC<Props> = ({
  53. allStaffs,
  54. allTasks,
  55. isActive,
  56. defaultManhourBreakdownByGrade,
  57. grades,
  58. }) => {
  59. const { t } = useTranslation();
  60. const { setValue, getValues, watch } = useFormContext<CreateProjectInputs>();
  61. const [filteredStaff, setFilteredStaff] = React.useState(
  62. allStaffs.sort(staffComparator),
  63. );
  64. const selectedStaffIds = watch("allocatedStaffIds");
  65. // Adding / Removing staff
  66. const addStaff = React.useCallback(
  67. (staff: StaffResult) => {
  68. const currentStaffIds = getValues("allocatedStaffIds");
  69. setValue("allocatedStaffIds", [...currentStaffIds, staff.id]);
  70. },
  71. [getValues, setValue],
  72. );
  73. const removeStaff = React.useCallback(
  74. (staff: StaffResult) => {
  75. const currentStaffIds = getValues("allocatedStaffIds");
  76. setValue(
  77. "allocatedStaffIds",
  78. currentStaffIds.filter((id) => id !== staff.id),
  79. );
  80. },
  81. [getValues, setValue],
  82. );
  83. const clearStaff = React.useCallback(() => {
  84. setValue("allocatedStaffIds", []);
  85. }, [setValue]);
  86. const staffPoolColumns = React.useMemo<Column<StaffResult>[]>(
  87. () => [
  88. {
  89. label: t("Add"),
  90. name: "id",
  91. onClick: addStaff,
  92. buttonIcon: <PersonAdd />,
  93. },
  94. { label: t("Team"), name: "team" },
  95. { label: t("Grade"), name: "grade" },
  96. { label: t("Staff ID"), name: "staffId" },
  97. { label: t("Staff Name"), name: "name" },
  98. { label: t("Title"), name: "currentPosition" },
  99. ],
  100. [addStaff, t],
  101. );
  102. const allocatedStaffColumns = React.useMemo<Column<StaffResult>[]>(
  103. () => [
  104. {
  105. label: t("Remove"),
  106. name: "id",
  107. onClick: removeStaff,
  108. buttonIcon: <PersonRemove />,
  109. },
  110. { label: t("Team"), name: "team" },
  111. { label: t("Grade"), name: "grade" },
  112. { label: t("Staff ID"), name: "id" },
  113. { label: t("Staff Name"), name: "name" },
  114. { label: t("Title"), name: "currentPosition" },
  115. ],
  116. [removeStaff, t],
  117. );
  118. // Query related
  119. const [query, setQuery] = React.useState("");
  120. const onQueryInputChange = React.useCallback<
  121. React.ChangeEventHandler<HTMLInputElement>
  122. >((e) => {
  123. setQuery(e.target.value);
  124. }, []);
  125. const clearQueryInput = React.useCallback(() => {
  126. setQuery("");
  127. }, []);
  128. const columnFilters = React.useMemo<(keyof StaffResult)[]>(
  129. () => ["team", "grade"],
  130. [],
  131. );
  132. const filterValues = React.useMemo(() => {
  133. return columnFilters.reduce<{ [filter in keyof StaffResult]?: string[] }>(
  134. (acc, filter) => {
  135. return {
  136. ...acc,
  137. [filter]: uniq(allStaffs.map((staff) => staff[filter])),
  138. };
  139. },
  140. {},
  141. );
  142. }, [columnFilters, allStaffs]);
  143. const defaultFilterValues = React.useMemo(() => {
  144. return columnFilters.reduce<{ [filter in keyof StaffResult]?: string }>(
  145. (acc, filter) => {
  146. return { ...acc, [filter]: "All" };
  147. },
  148. {},
  149. );
  150. }, [columnFilters]);
  151. const [filters, setFilters] = React.useState(defaultFilterValues);
  152. const makeFilterSelect = React.useCallback(
  153. (filter: keyof StaffResult) => (event: SelectChangeEvent<string>) => {
  154. setFilters((f) => ({ ...f, [filter]: event.target.value }));
  155. },
  156. [],
  157. );
  158. useEffect(() => {
  159. setFilteredStaff(
  160. allStaffs.filter((staff) => {
  161. const q = query.toLowerCase();
  162. return (
  163. (staff.name.toLowerCase().includes(q) ||
  164. staff.id.toString().includes(q) ||
  165. staff.currentPosition.toLowerCase().includes(q)) &&
  166. Object.entries(filters).every(([filterKey, filterValue]) => {
  167. const staffColumnValue = staff[filterKey as keyof StaffResult];
  168. return staffColumnValue === filterValue || filterValue === "All";
  169. })
  170. );
  171. }),
  172. );
  173. }, [allStaffs, filters, query]);
  174. // Tab related
  175. const [tabIndex, setTabIndex] = React.useState(0);
  176. const handleTabChange = React.useCallback<NonNullable<TabsProps["onChange"]>>(
  177. (_e, newValue) => {
  178. setTabIndex(newValue);
  179. },
  180. [],
  181. );
  182. const reset = React.useCallback(() => {
  183. clearQueryInput();
  184. clearStaff();
  185. setFilters(defaultFilterValues);
  186. }, [clearQueryInput, clearStaff, defaultFilterValues]);
  187. return (
  188. <>
  189. <Card sx={{ display: isActive ? "block" : "none" }}>
  190. <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
  191. <ResourceCapacity />
  192. </CardContent>
  193. </Card>
  194. <Card sx={{ display: isActive ? "block" : "none" }}>
  195. <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
  196. <Stack gap={2}>
  197. <Typography variant="overline" display="block">
  198. {t("Staff Allocation")}
  199. </Typography>
  200. <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
  201. <Grid item xs={6} display="flex" alignItems="center">
  202. <Search sx={{ marginInlineEnd: 1 }} />
  203. <TextField
  204. variant="standard"
  205. fullWidth
  206. onChange={onQueryInputChange}
  207. value={query}
  208. placeholder={t("Search by staff ID, name or title")}
  209. InputProps={{
  210. endAdornment: query && (
  211. <InputAdornment position="end">
  212. <IconButton onClick={clearQueryInput}>
  213. <Clear />
  214. </IconButton>
  215. </InputAdornment>
  216. ),
  217. }}
  218. />
  219. </Grid>
  220. {columnFilters.map((filter, idx) => {
  221. const label = staffPoolColumns.find(
  222. (c) => c.name === filter,
  223. )!.label;
  224. return (
  225. <Grid key={`${filter.toString()}-${idx}`} item xs={3}>
  226. <FormControl fullWidth>
  227. <InputLabel size="small">{label}</InputLabel>
  228. <Select
  229. label={label}
  230. size="small"
  231. value={filters[filter]}
  232. onChange={makeFilterSelect(filter)}
  233. >
  234. <MenuItem value={"All"}>{t("All")}</MenuItem>
  235. {filterValues[filter]?.map((option, index) => (
  236. <MenuItem key={`${option}-${index}`} value={option}>
  237. {option}
  238. </MenuItem>
  239. ))}
  240. </Select>
  241. </FormControl>
  242. </Grid>
  243. );
  244. })}
  245. </Grid>
  246. <Tabs value={tabIndex} onChange={handleTabChange}>
  247. <Tab label={t("Staff Pool")} />
  248. <Tab
  249. label={`${t("Allocated Staff")} (${selectedStaffIds.length})`}
  250. />
  251. </Tabs>
  252. <Box sx={{ marginInline: -3 }}>
  253. {tabIndex === 0 && (
  254. <SearchResults
  255. noWrapper
  256. items={differenceWith(
  257. filteredStaff,
  258. selectedStaffIds,
  259. (staff, staffId) => staff.id === staffId,
  260. )}
  261. columns={staffPoolColumns}
  262. />
  263. )}
  264. {tabIndex === 1 && (
  265. <SearchResults
  266. noWrapper
  267. items={intersectionWith(
  268. allStaffs,
  269. selectedStaffIds,
  270. (staff, staffId) => staff.id === staffId,
  271. )}
  272. columns={allocatedStaffColumns}
  273. />
  274. )}
  275. </Box>
  276. </Stack>
  277. <CardActions sx={{ justifyContent: "flex-end" }}>
  278. <Button variant="text" startIcon={<RestartAlt />} onClick={reset}>
  279. {t("Reset")}
  280. </Button>
  281. </CardActions>
  282. </CardContent>
  283. </Card>
  284. {/* MUI X-Grid will throw an error if it is rendered without any dimensions; so not rendering when not active */}
  285. {isActive && (
  286. <Card>
  287. <CardContent>
  288. <ResourceAllocation
  289. grades={grades}
  290. allTasks={allTasks}
  291. manhourBreakdownByGrade={defaultManhourBreakdownByGrade}
  292. />
  293. </CardContent>
  294. </Card>
  295. )}
  296. </>
  297. );
  298. };
  299. export default StaffAllocation;