| @@ -73,6 +73,7 @@ const BulkAddPaymentModal: React.FC<Props> = ({ | |||||
| const { register, reset, trigger, formState, watch, control } = | const { register, reset, trigger, formState, watch, control } = | ||||
| useForm<BulkAddPaymentForm>({ | useForm<BulkAddPaymentForm>({ | ||||
| mode: "onTouched", | |||||
| defaultValues: { dateType: "monthly", dateReference: dayjs() }, | defaultValues: { dateType: "monthly", dateReference: dayjs() }, | ||||
| }); | }); | ||||
| @@ -87,7 +88,12 @@ const BulkAddPaymentModal: React.FC<Props> = ({ | |||||
| description, | description, | ||||
| } = formValues; | } = formValues; | ||||
| if (numberOfEntries > 0 && amountToDivide && description) { | |||||
| if ( | |||||
| Number.isInteger(numberOfEntries) && | |||||
| numberOfEntries > 0 && | |||||
| amountToDivide && | |||||
| description | |||||
| ) { | |||||
| const dividedAmount = amountToDivide / numberOfEntries; | const dividedAmount = amountToDivide / numberOfEntries; | ||||
| return Array(numberOfEntries) | return Array(numberOfEntries) | ||||
| .fill(undefined) | .fill(undefined) | ||||
| @@ -157,6 +163,17 @@ const BulkAddPaymentModal: React.FC<Props> = ({ | |||||
| {...register("numberOfEntries", { | {...register("numberOfEntries", { | ||||
| valueAsNumber: true, | valueAsNumber: true, | ||||
| required: t("Required"), | required: t("Required"), | ||||
| validate: (value) => { | |||||
| if (!value) { | |||||
| return t("Required"); | |||||
| } else if (value < 0) { | |||||
| return t("Number must be positive"); | |||||
| } else if (!Number.isInteger(value)) { | |||||
| return t("Number must be an integer"); | |||||
| } else { | |||||
| return true; | |||||
| } | |||||
| }, | |||||
| })} | })} | ||||
| error={Boolean(formState.errors.numberOfEntries)} | error={Boolean(formState.errors.numberOfEntries)} | ||||
| helperText={formState.errors.numberOfEntries?.message} | helperText={formState.errors.numberOfEntries?.message} | ||||
| @@ -0,0 +1,73 @@ | |||||
| import { useMemo } from "react"; | |||||
| import StyledDataGrid from "../StyledDataGrid"; | |||||
| import { GridColDef, GridValidRowModel } from "@mui/x-data-grid"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import dayjs from "dayjs"; | |||||
| import { moneyFormatter, OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||||
| import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||||
| import { useFormContext } from "react-hook-form"; | |||||
| import { TaskGroup } from "@/app/api/tasks"; | |||||
| interface Props { | |||||
| taskGroups: TaskGroup[]; | |||||
| } | |||||
| interface Row extends GridValidRowModel { | |||||
| taskStage: string; | |||||
| description: string; | |||||
| date: string; | |||||
| amount: number; | |||||
| } | |||||
| const PaymentSummary: React.FC<Props> = ({ taskGroups }) => { | |||||
| const { t } = useTranslation(); | |||||
| const { watch } = useFormContext<CreateProjectInputs>(); | |||||
| const milestones = watch("milestones"); | |||||
| const rows = useMemo<Row[]>(() => { | |||||
| return taskGroups.flatMap((group) => { | |||||
| const payments = milestones[group.id]?.payments || []; | |||||
| return payments.map(({ description, date, amount, id }) => ({ | |||||
| id, | |||||
| taskStage: group.name, | |||||
| description, | |||||
| date, | |||||
| amount, | |||||
| })); | |||||
| }); | |||||
| }, [milestones, taskGroups]); | |||||
| const columns = useMemo<GridColDef<GridValidRowModel>[]>( | |||||
| () => [ | |||||
| { field: "taskStage", headerName: t("Task Stage"), width: 300 }, | |||||
| { | |||||
| field: "description", | |||||
| headerName: t("Payment Milestone Description"), | |||||
| width: 300, | |||||
| }, | |||||
| { | |||||
| field: "date", | |||||
| headerName: t("Payment Milestone Date"), | |||||
| width: 200, | |||||
| type: "date", | |||||
| valueFormatter(params) { | |||||
| return dayjs(params.value).format(OUTPUT_DATE_FORMAT); | |||||
| }, | |||||
| }, | |||||
| { | |||||
| field: "amount", | |||||
| headerName: t("Payment Milestone Amount"), | |||||
| width: 300, | |||||
| type: "number", | |||||
| valueFormatter(params) { | |||||
| return moneyFormatter.format(params.value); | |||||
| }, | |||||
| }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| return <StyledDataGrid columns={columns} rows={rows} />; | |||||
| }; | |||||
| export default PaymentSummary; | |||||
| @@ -1,21 +1,51 @@ | |||||
| import { CreateProjectInputs } from "@/app/api/projects/actions"; | import { CreateProjectInputs } from "@/app/api/projects/actions"; | ||||
| import { TaskGroup } from "@/app/api/tasks"; | import { TaskGroup } from "@/app/api/tasks"; | ||||
| import { moneyFormatter } from "@/app/utils/formatUtil"; | import { moneyFormatter } from "@/app/utils/formatUtil"; | ||||
| import { Divider, Stack, Typography } from "@mui/material"; | |||||
| import React from "react"; | |||||
| import { | |||||
| Button, | |||||
| Divider, | |||||
| Modal, | |||||
| Paper, | |||||
| Stack, | |||||
| SxProps, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| import React, { useCallback, useState } from "react"; | |||||
| import { useFormContext } from "react-hook-form"; | import { useFormContext } from "react-hook-form"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import PaymentSummary from "./PaymentSummary"; | |||||
| interface Props { | interface Props { | ||||
| taskGroups: TaskGroup[]; | taskGroups: TaskGroup[]; | ||||
| } | } | ||||
| const modalSx: SxProps = { | |||||
| position: "absolute", | |||||
| top: "50%", | |||||
| left: "50%", | |||||
| transform: "translate(-50%, -50%)", | |||||
| width: "90%", | |||||
| maxHeight: "90%", | |||||
| padding: 3, | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| gap: 2, | |||||
| }; | |||||
| const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const { watch } = useFormContext<CreateProjectInputs>(); | const { watch } = useFormContext<CreateProjectInputs>(); | ||||
| const milestones = watch("milestones"); | const milestones = watch("milestones"); | ||||
| const expectedTotalFee = watch("expectedProjectFee"); | const expectedTotalFee = watch("expectedProjectFee"); | ||||
| const [paymentSummaryOpen, setPaymentSummaryOpen] = useState(false); | |||||
| const closePaymentSummary = useCallback(() => { | |||||
| setPaymentSummaryOpen(false); | |||||
| }, []); | |||||
| const openPaymentSummary = useCallback(() => { | |||||
| setPaymentSummaryOpen(true); | |||||
| }, []); | |||||
| let projectTotal = 0; | let projectTotal = 0; | ||||
| return ( | return ( | ||||
| @@ -37,9 +67,24 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||||
| </Stack> | </Stack> | ||||
| ); | ); | ||||
| })} | })} | ||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| sx={{ alignSelf: "flex-start" }} | |||||
| onClick={openPaymentSummary} | |||||
| > | |||||
| {t("View payment breakdown")} | |||||
| </Button> | |||||
| <Modal open={paymentSummaryOpen} onClose={closePaymentSummary}> | |||||
| <Paper sx={modalSx}> | |||||
| <PaymentSummary taskGroups={taskGroups} /> | |||||
| </Paper> | |||||
| </Modal> | |||||
| <Divider sx={{ paddingBlockStart: 2 }} /> | <Divider sx={{ paddingBlockStart: 2 }} /> | ||||
| <Stack direction="row" justifyContent="space-between"> | <Stack direction="row" justifyContent="space-between"> | ||||
| <Typography variant="h6">{t("Sum of Payment Milestone Fee")}</Typography> | |||||
| <Typography variant="h6"> | |||||
| {t("Sum of Payment Milestone Fee")} | |||||
| </Typography> | |||||
| <Typography>{moneyFormatter.format(projectTotal)}</Typography> | <Typography>{moneyFormatter.format(projectTotal)}</Typography> | ||||
| </Stack> | </Stack> | ||||
| <Divider sx={{ paddingBlockStart: 2 }} /> | <Divider sx={{ paddingBlockStart: 2 }} /> | ||||
| @@ -49,7 +94,9 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||||
| </Stack> | </Stack> | ||||
| {projectTotal !== expectedTotalFee && ( | {projectTotal !== expectedTotalFee && ( | ||||
| <Typography variant="caption" color="warning.main" alignSelf="flex-end"> | <Typography variant="caption" color="warning.main" alignSelf="flex-end"> | ||||
| {t("Sum of Payment Milestone Fee should be same as the expected total fee!")} | |||||
| {t( | |||||
| "Sum of Payment Milestone Fee should be same as the expected total fee!", | |||||
| )} | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| @@ -1,6 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { FormHelperText } from "@mui/material"; | |||||
| import { FormHelperText, SxProps } from "@mui/material"; | |||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import TextField from "@mui/material/TextField"; | import TextField from "@mui/material/TextField"; | ||||
| @@ -31,7 +31,7 @@ const getHumanFriendlyErrorMessage = ( | |||||
| } | } | ||||
| }; | }; | ||||
| const LoginForm: React.FC = () => { | |||||
| const LoginForm: React.FC<{ sx?: SxProps }> = ({ sx }) => { | |||||
| const { t } = useTranslation("login"); | const { t } = useTranslation("login"); | ||||
| const { | const { | ||||
| register, | register, | ||||
| @@ -65,6 +65,7 @@ const LoginForm: React.FC = () => { | |||||
| margin={5} | margin={5} | ||||
| component="form" | component="form" | ||||
| onSubmit={handleSubmit(onSubmit)} | onSubmit={handleSubmit(onSubmit)} | ||||
| sx={sx} | |||||
| > | > | ||||
| <Typography variant="h1">{t("Sign In")}</Typography> | <Typography variant="h1">{t("Sign In")}</Typography> | ||||
| <TextField | <TextField | ||||
| @@ -7,14 +7,44 @@ import { Box } from "@mui/material"; | |||||
| const LoginPage = () => { | const LoginPage = () => { | ||||
| return ( | return ( | ||||
| <Grid container height="100vh"> | <Grid container height="100vh"> | ||||
| <Grid item sm sx={{ backgroundColor: 'neutral.000', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> | |||||
| <Box sx={{ width: '100%', padding: 5, paddingBlockStart: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', svg: { maxHeight: 120 } }}> | |||||
| <Grid | |||||
| item | |||||
| xs={12} | |||||
| md={6} | |||||
| sx={{ | |||||
| backgroundColor: "neutral.000", | |||||
| display: "flex", | |||||
| alignItems: "center", | |||||
| justifyContent: "center", | |||||
| flexGrow: 1, | |||||
| }} | |||||
| > | |||||
| <Box | |||||
| sx={{ | |||||
| width: "100%", | |||||
| padding: 5, | |||||
| paddingBlockStart: 10, | |||||
| display: "flex", | |||||
| alignItems: "center", | |||||
| justifyContent: "center", | |||||
| svg: { maxHeight: 120 }, | |||||
| }} | |||||
| > | |||||
| <Logo /> | <Logo /> | ||||
| </Box> | </Box> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={12} sm={8} lg={5} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}> | |||||
| <Paper square sx={{ width: '100%', padding: 5 }}> | |||||
| <LoginForm /> | |||||
| <Grid item xs={12} md={6} sx={{ height: { md: "100%" }, flexGrow: 1 }}> | |||||
| <Paper | |||||
| square | |||||
| sx={{ | |||||
| height: "100%", | |||||
| width: "100%", | |||||
| padding: { lg: 5, md: 3 }, | |||||
| display: "flex", | |||||
| alignItems: "center", | |||||
| }} | |||||
| > | |||||
| <LoginForm sx={{ flex: 1 }} /> | |||||
| </Paper> | </Paper> | ||||
| </Grid> | </Grid> | ||||
| </Grid> | </Grid> | ||||
| @@ -11,7 +11,25 @@ export const neutral = { | |||||
| 900: "#111927", | 900: "#111927", | ||||
| }; | }; | ||||
| // export const primary = { | |||||
| // lightest: "#F5F7FF", | |||||
| // light: "#EBEEFE", | |||||
| // main: "#6366F1", | |||||
| // dark: "#4338CA", | |||||
| // darkest: "#312E81", | |||||
| // contrastText: "#FFFFFF", | |||||
| // }; | |||||
| export const primary = { | export const primary = { | ||||
| lightest: "#f9fff5", | |||||
| light: "#f9feeb", | |||||
| main: "#8dba00", | |||||
| dark: "#638a01", | |||||
| darkest: "#4a5f14", | |||||
| contrastText: "#FFFFFF", | |||||
| }; | |||||
| export const secondary = { | |||||
| lightest: "#F5F7FF", | lightest: "#F5F7FF", | ||||
| light: "#EBEEFE", | light: "#EBEEFE", | ||||
| main: "#6366F1", | main: "#6366F1", | ||||
| @@ -1,6 +1,14 @@ | |||||
| import { common } from "@mui/material/colors"; | import { common } from "@mui/material/colors"; | ||||
| import { PaletteOptions } from "@mui/material/styles"; | import { PaletteOptions } from "@mui/material/styles"; | ||||
| import { error, primary, info, neutral, success, warning } from "./colors"; | |||||
| import { | |||||
| error, | |||||
| primary, | |||||
| secondary, | |||||
| info, | |||||
| neutral, | |||||
| success, | |||||
| warning, | |||||
| } from "./colors"; | |||||
| const palette = { | const palette = { | ||||
| action: { | action: { | ||||
| @@ -20,6 +28,7 @@ const palette = { | |||||
| info, | info, | ||||
| mode: "light", | mode: "light", | ||||
| primary, | primary, | ||||
| secondary, | |||||
| success, | success, | ||||
| text: { | text: { | ||||
| primary: neutral[900], | primary: neutral[900], | ||||