From 79536849425752e4221a20fc63797467dc67856a Mon Sep 17 00:00:00 2001 From: Wayne Date: Sat, 17 Feb 2024 16:28:39 +0900 Subject: [PATCH] Milestone section --- src/app/api/projects/actions.ts | 9 +- .../CreateProject/MilestoneSection.tsx | 230 ++++++++++++++++-- 2 files changed, 215 insertions(+), 24 deletions(-) diff --git a/src/app/api/projects/actions.ts b/src/app/api/projects/actions.ts index 467d897..edbaa24 100644 --- a/src/app/api/projects/actions.ts +++ b/src/app/api/projects/actions.ts @@ -36,11 +36,18 @@ export interface CreateProjectInputs { [taskGroupId: TaskGroup["id"]]: { startDate: string; endDate: string; - payments: []; + payments: PaymentInputs[]; }; }; } +export interface PaymentInputs { + id: number; + description: string; + date: string; + amount: number; +} + export const saveProject = async (data: CreateProjectInputs) => { return serverFetchJson(`${BASE_API_URL}/projects/new`, { method: "POST", diff --git a/src/components/CreateProject/MilestoneSection.tsx b/src/components/CreateProject/MilestoneSection.tsx index f68309b..104134c 100644 --- a/src/components/CreateProject/MilestoneSection.tsx +++ b/src/components/CreateProject/MilestoneSection.tsx @@ -1,6 +1,6 @@ -import { CreateProjectInputs } from "@/app/api/projects/actions"; +import { CreateProjectInputs, PaymentInputs } from "@/app/api/projects/actions"; import { TaskGroup } from "@/app/api/tasks"; -import { Add, Delete } from "@mui/icons-material"; +import { Add, Check, Close, Delete } from "@mui/icons-material"; import { Stack, Typography, @@ -13,41 +13,159 @@ import { GridColDef, GridActionsCellItem, GridToolbarContainer, + GridRowModesModel, + GridRowModes, + FooterPropsOverrides, + GridRowId, + GridEventListener, + useGridApiRef, + GridRowModel, } from "@mui/x-data-grid"; import { LocalizationProvider, DatePicker } from "@mui/x-date-pickers"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import dayjs from "dayjs"; import "dayjs/locale/zh-hk"; -import React, { useMemo } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; import StyledDataGrid from "../StyledDataGrid"; +import { moneyFormatter } from "@/app/utils/formatUtil"; +import isDate from "lodash/isDate"; interface Props { taskGroupId: TaskGroup["id"]; } -interface FooterToolbarProps { - onAdd: () => void; +declare module "@mui/x-data-grid" { + interface FooterPropsOverrides { + onAdd: () => void; + } } +type PaymentRow = Partial; + const MilestoneSection: React.FC = ({ taskGroupId }) => { const { t } = useTranslation(); - const {} = useFormContext(); + const { getValues, setValue } = useFormContext(); + const [payments, setPayments] = useState( + getValues("milestones")[taskGroupId]?.payments || [], + ); + const [rowModesModel, setRowModesModel] = useState({}); + // Change the payments table depending on the TaskGroupId + useEffect(() => { + setPayments(getValues("milestones")[taskGroupId]?.payments || []); + setRowModesModel({}); + }, [getValues, taskGroupId]); + + const apiRef = useGridApiRef(); + const addRow = useCallback(() => { + const id = Date.now(); + setPayments((p) => [...p, { id, _isNew: true }]); + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.Edit, fieldToFocus: "description" }, + })); + }, []); + + const validateRow = useCallback( + (id: GridRowId) => { + const row = apiRef.current.getRowWithUpdatedValues(id, "") as PaymentRow; + let error: keyof PaymentInputs | "" = ""; + if (!row.description) { + error = "description"; + } else if (!(isDate(row.date) && row.date.getTime())) { + error = "date"; + } else if (!(row.amount && row.amount >= 0)) { + error = "amount"; + } + + apiRef.current.updateRows([{ id, _error: error }]); + return !error; + }, + [apiRef], + ); + + const handleCancel = useCallback( + (id: GridRowId) => () => { + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.View, ignoreModifications: true }, + })); + const editedRow = payments.find((payment) => payment.id === id); + if (editedRow?._isNew) { + setPayments((ps) => ps.filter((p) => p.id !== id)); + } + }, + [payments], + ); + + const handleDelete = useCallback( + (id: GridRowId) => () => { + setPayments((ps) => ps.filter((p) => p.id !== id)); + }, + [], + ); + + const handleSave = useCallback( + (id: GridRowId) => () => { + if (validateRow(id)) { + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.View }, + })); + } + }, + [validateRow], + ); + + const handleEditStop = useCallback>( + (params, event) => { + if (!validateRow(params.id)) { + event.defaultMuiPrevented = true; + } + }, + [validateRow], + ); + + const processRowUpdate = useCallback((newRow: GridRowModel) => { + const updatedRow = { ...newRow, _isNew: false }; + setPayments((ps) => ps.map((p) => (p.id === newRow.id ? updatedRow : p))); + return updatedRow; + }, []); + const columns = useMemo( () => [ { type: "actions", field: "actions", headerName: t("Actions"), - getActions: () => [ - } - label={t("Remove")} - onClick={() => {}} - />, - ], + getActions: ({ id }) => { + if (rowModesModel[id]?.mode === GridRowModes.Edit) { + return [ + } + label={t("Save")} + onClick={handleSave(id)} + />, + } + label={t("Cancel")} + onClick={handleCancel(id)} + />, + ]; + } + + return [ + } + label={t("Remove")} + onClick={handleDelete(id)} + />, + ]; + }, }, { field: "description", @@ -61,6 +179,9 @@ const MilestoneSection: React.FC = ({ taskGroupId }) => { width: 200, type: "date", editable: true, + valueGetter(params) { + return new Date(params.value); + }, }, { field: "amount", @@ -68,11 +189,32 @@ const MilestoneSection: React.FC = ({ taskGroupId }) => { width: 300, editable: true, type: "number", + valueFormatter(params) { + return moneyFormatter.format(params.value); + }, }, ], - [t], + [handleCancel, handleDelete, handleSave, rowModesModel, t], ); + useEffect(() => { + const milestones = getValues("milestones"); + setValue("milestones", { + ...milestones, + [taskGroupId]: { + ...milestones[taskGroupId], + payments: payments + .filter((p) => !p._isNew && !p._error) + .map((p) => ({ + description: p.description!, + id: p.id!, + amount: p.amount!, + date: dayjs(p.date!).toISOString(), + })), + }, + }); + }, [getValues, payments, setValue, taskGroupId]); + return ( @@ -84,13 +226,38 @@ const MilestoneSection: React.FC = ({ taskGroupId }) => { { + if (!date) return; + const milestones = getValues("milestones"); + setValue("milestones", { + ...milestones, + [taskGroupId]: { + ...milestones[taskGroupId], + startDate: date.toISOString(), + }, + }); + }} /> - + { + if (!date) return; + const milestones = getValues("milestones"); + setValue("milestones", { + ...milestones, + [taskGroupId]: { + ...milestones[taskGroupId], + endDate: date.toISOString(), + }, + }); + }} + /> @@ -103,17 +270,32 @@ const MilestoneSection: React.FC = ({ taskGroupId }) => { })} > { + return params.row._error === params.field ? "hasError" : ""; + }} slots={{ footer: FooterToolbar, noRowsOverlay: NoRowsOverlay, }} slotProps={{ - footer: undefined, + footer: { onAdd: addRow }, }} /> @@ -130,12 +312,14 @@ const NoRowsOverlay: React.FC = () => { alignItems="center" height="100%" > - {t("Add some milestones!")} + + {t("Add some payment milestones!")} + ); }; -const FooterToolbar: React.FC = ({ onAdd }) => { +const FooterToolbar: React.FC = ({ onAdd }) => { const { t } = useTranslation(); return ( @@ -145,7 +329,7 @@ const FooterToolbar: React.FC = ({ onAdd }) => { onClick={onAdd} size="small" > - {t("Add Milestone")} + {t("Add Payment Milestone")} );