Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 

339 rader
9.4 KiB

  1. import { CreateProjectInputs, PaymentInputs } from "@/app/api/projects/actions";
  2. import { TaskGroup } from "@/app/api/tasks";
  3. import { Add, Check, Close, Delete } from "@mui/icons-material";
  4. import {
  5. Stack,
  6. Typography,
  7. Grid,
  8. FormControl,
  9. Box,
  10. Button,
  11. } from "@mui/material";
  12. import {
  13. GridColDef,
  14. GridActionsCellItem,
  15. GridToolbarContainer,
  16. GridRowModesModel,
  17. GridRowModes,
  18. FooterPropsOverrides,
  19. GridRowId,
  20. GridEventListener,
  21. useGridApiRef,
  22. GridRowModel,
  23. } from "@mui/x-data-grid";
  24. import { LocalizationProvider, DatePicker } from "@mui/x-date-pickers";
  25. import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
  26. import dayjs from "dayjs";
  27. import "dayjs/locale/zh-hk";
  28. import React, { useCallback, useEffect, useMemo, useState } from "react";
  29. import { useFormContext } from "react-hook-form";
  30. import { useTranslation } from "react-i18next";
  31. import StyledDataGrid from "../StyledDataGrid";
  32. import { moneyFormatter } from "@/app/utils/formatUtil";
  33. import isDate from "lodash/isDate";
  34. interface Props {
  35. taskGroupId: TaskGroup["id"];
  36. }
  37. declare module "@mui/x-data-grid" {
  38. interface FooterPropsOverrides {
  39. onAdd: () => void;
  40. }
  41. }
  42. type PaymentRow = Partial<PaymentInputs & { _isNew: boolean; _error: string }>;
  43. const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => {
  44. const { t } = useTranslation();
  45. const { getValues, setValue } = useFormContext<CreateProjectInputs>();
  46. const [payments, setPayments] = useState<PaymentRow[]>(
  47. getValues("milestones")[taskGroupId]?.payments || [],
  48. );
  49. const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
  50. // Change the payments table depending on the TaskGroupId
  51. useEffect(() => {
  52. setPayments(getValues("milestones")[taskGroupId]?.payments || []);
  53. setRowModesModel({});
  54. }, [getValues, taskGroupId]);
  55. const apiRef = useGridApiRef();
  56. const addRow = useCallback(() => {
  57. const id = Date.now();
  58. setPayments((p) => [...p, { id, _isNew: true }]);
  59. setRowModesModel((model) => ({
  60. ...model,
  61. [id]: { mode: GridRowModes.Edit, fieldToFocus: "description" },
  62. }));
  63. }, []);
  64. const validateRow = useCallback(
  65. (id: GridRowId) => {
  66. const row = apiRef.current.getRowWithUpdatedValues(id, "") as PaymentRow;
  67. let error: keyof PaymentInputs | "" = "";
  68. if (!row.description) {
  69. error = "description";
  70. } else if (!(isDate(row.date) && row.date.getTime())) {
  71. error = "date";
  72. } else if (!(row.amount && row.amount >= 0)) {
  73. error = "amount";
  74. }
  75. apiRef.current.updateRows([{ id, _error: error }]);
  76. return !error;
  77. },
  78. [apiRef],
  79. );
  80. const handleCancel = useCallback(
  81. (id: GridRowId) => () => {
  82. setRowModesModel((model) => ({
  83. ...model,
  84. [id]: { mode: GridRowModes.View, ignoreModifications: true },
  85. }));
  86. const editedRow = payments.find((payment) => payment.id === id);
  87. if (editedRow?._isNew) {
  88. setPayments((ps) => ps.filter((p) => p.id !== id));
  89. }
  90. },
  91. [payments],
  92. );
  93. const handleDelete = useCallback(
  94. (id: GridRowId) => () => {
  95. setPayments((ps) => ps.filter((p) => p.id !== id));
  96. },
  97. [],
  98. );
  99. const handleSave = useCallback(
  100. (id: GridRowId) => () => {
  101. if (validateRow(id)) {
  102. setRowModesModel((model) => ({
  103. ...model,
  104. [id]: { mode: GridRowModes.View },
  105. }));
  106. }
  107. },
  108. [validateRow],
  109. );
  110. const handleEditStop = useCallback<GridEventListener<"rowEditStop">>(
  111. (params, event) => {
  112. if (!validateRow(params.id)) {
  113. event.defaultMuiPrevented = true;
  114. }
  115. },
  116. [validateRow],
  117. );
  118. const processRowUpdate = useCallback((newRow: GridRowModel) => {
  119. const updatedRow = { ...newRow, _isNew: false };
  120. setPayments((ps) => ps.map((p) => (p.id === newRow.id ? updatedRow : p)));
  121. return updatedRow;
  122. }, []);
  123. const columns = useMemo<GridColDef[]>(
  124. () => [
  125. {
  126. type: "actions",
  127. field: "actions",
  128. headerName: t("Actions"),
  129. getActions: ({ id }) => {
  130. if (rowModesModel[id]?.mode === GridRowModes.Edit) {
  131. return [
  132. <GridActionsCellItem
  133. key="accpet-action"
  134. icon={<Check />}
  135. label={t("Save")}
  136. onClick={handleSave(id)}
  137. />,
  138. <GridActionsCellItem
  139. key="cancel-action"
  140. icon={<Close />}
  141. label={t("Cancel")}
  142. onClick={handleCancel(id)}
  143. />,
  144. ];
  145. }
  146. return [
  147. <GridActionsCellItem
  148. key="delete-action"
  149. icon={<Delete />}
  150. label={t("Remove")}
  151. onClick={handleDelete(id)}
  152. />,
  153. ];
  154. },
  155. },
  156. {
  157. field: "description",
  158. headerName: t("Payment Milestone Description"),
  159. width: 300,
  160. editable: true,
  161. },
  162. {
  163. field: "date",
  164. headerName: t("Payment Milestone Date"),
  165. width: 200,
  166. type: "date",
  167. editable: true,
  168. valueGetter(params) {
  169. return new Date(params.value);
  170. },
  171. },
  172. {
  173. field: "amount",
  174. headerName: t("Payment Milestone Amount"),
  175. width: 300,
  176. editable: true,
  177. type: "number",
  178. valueFormatter(params) {
  179. return moneyFormatter.format(params.value);
  180. },
  181. },
  182. ],
  183. [handleCancel, handleDelete, handleSave, rowModesModel, t],
  184. );
  185. useEffect(() => {
  186. const milestones = getValues("milestones");
  187. setValue("milestones", {
  188. ...milestones,
  189. [taskGroupId]: {
  190. ...milestones[taskGroupId],
  191. payments: payments
  192. .filter((p) => !p._isNew && !p._error)
  193. .map((p) => ({
  194. description: p.description!,
  195. id: p.id!,
  196. amount: p.amount!,
  197. date: dayjs(p.date!).toISOString(),
  198. })),
  199. },
  200. });
  201. }, [getValues, payments, setValue, taskGroupId]);
  202. return (
  203. <Stack gap={1}>
  204. <Typography variant="overline" display="block" marginBlockEnd={1}>
  205. {t("Task Stage Milestones")}
  206. </Typography>
  207. <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
  208. <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
  209. <Grid item xs>
  210. <FormControl fullWidth>
  211. <DatePicker
  212. label={t("Stage Start Date")}
  213. value={dayjs(getValues("milestones")[taskGroupId]?.startDate)}
  214. onChange={(date) => {
  215. if (!date) return;
  216. const milestones = getValues("milestones");
  217. setValue("milestones", {
  218. ...milestones,
  219. [taskGroupId]: {
  220. ...milestones[taskGroupId],
  221. startDate: date.toISOString(),
  222. },
  223. });
  224. }}
  225. />
  226. </FormControl>
  227. </Grid>
  228. <Grid item xs>
  229. <FormControl fullWidth>
  230. <DatePicker
  231. label={t("Stage End Date")}
  232. value={dayjs(getValues("milestones")[taskGroupId]?.endDate)}
  233. onChange={(date) => {
  234. if (!date) return;
  235. const milestones = getValues("milestones");
  236. setValue("milestones", {
  237. ...milestones,
  238. [taskGroupId]: {
  239. ...milestones[taskGroupId],
  240. endDate: date.toISOString(),
  241. },
  242. });
  243. }}
  244. />
  245. </FormControl>
  246. </Grid>
  247. </Grid>
  248. </LocalizationProvider>
  249. <Box
  250. sx={(theme) => ({
  251. marginBlockStart: 1,
  252. marginInline: -3,
  253. borderBottom: `1px solid ${theme.palette.divider}`,
  254. })}
  255. >
  256. <StyledDataGrid
  257. apiRef={apiRef}
  258. autoHeight
  259. sx={{
  260. "--DataGrid-overlayHeight": "100px",
  261. ".MuiDataGrid-row .MuiDataGrid-cell.hasError": {
  262. border: "1px solid",
  263. borderColor: "error.main",
  264. },
  265. }}
  266. disableColumnMenu
  267. editMode="row"
  268. rows={payments}
  269. rowModesModel={rowModesModel}
  270. onRowModesModelChange={setRowModesModel}
  271. onRowEditStop={handleEditStop}
  272. processRowUpdate={processRowUpdate}
  273. columns={columns}
  274. getCellClassName={(params) => {
  275. return params.row._error === params.field ? "hasError" : "";
  276. }}
  277. slots={{
  278. footer: FooterToolbar,
  279. noRowsOverlay: NoRowsOverlay,
  280. }}
  281. slotProps={{
  282. footer: { onAdd: addRow },
  283. }}
  284. />
  285. </Box>
  286. </Stack>
  287. );
  288. };
  289. const NoRowsOverlay: React.FC = () => {
  290. const { t } = useTranslation();
  291. return (
  292. <Box
  293. display="flex"
  294. justifyContent="center"
  295. alignItems="center"
  296. height="100%"
  297. >
  298. <Typography variant="caption">
  299. {t("Add some payment milestones!")}
  300. </Typography>
  301. </Box>
  302. );
  303. };
  304. const FooterToolbar: React.FC<FooterPropsOverrides> = ({ onAdd }) => {
  305. const { t } = useTranslation();
  306. return (
  307. <GridToolbarContainer sx={{ p: 2 }}>
  308. <Button
  309. variant="outlined"
  310. startIcon={<Add />}
  311. onClick={onAdd}
  312. size="small"
  313. >
  314. {t("Add Payment Milestone")}
  315. </Button>
  316. </GridToolbarContainer>
  317. );
  318. };
  319. export default MilestoneSection;