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

321 行
10 KiB

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