FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

335 lines
9.6 KiB

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