| @@ -73,6 +73,7 @@ const BulkAddPaymentModal: React.FC<Props> = ({ | |||
| const { register, reset, trigger, formState, watch, control } = | |||
| useForm<BulkAddPaymentForm>({ | |||
| mode: "onTouched", | |||
| defaultValues: { dateType: "monthly", dateReference: dayjs() }, | |||
| }); | |||
| @@ -87,7 +88,12 @@ const BulkAddPaymentModal: React.FC<Props> = ({ | |||
| description, | |||
| } = formValues; | |||
| if (numberOfEntries > 0 && amountToDivide && description) { | |||
| if ( | |||
| Number.isInteger(numberOfEntries) && | |||
| numberOfEntries > 0 && | |||
| amountToDivide && | |||
| description | |||
| ) { | |||
| const dividedAmount = amountToDivide / numberOfEntries; | |||
| return Array(numberOfEntries) | |||
| .fill(undefined) | |||
| @@ -157,6 +163,17 @@ const BulkAddPaymentModal: React.FC<Props> = ({ | |||
| {...register("numberOfEntries", { | |||
| valueAsNumber: true, | |||
| 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)} | |||
| 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 { TaskGroup } from "@/app/api/tasks"; | |||
| 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 { useTranslation } from "react-i18next"; | |||
| import PaymentSummary from "./PaymentSummary"; | |||
| interface Props { | |||
| 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 { t } = useTranslation(); | |||
| const { watch } = useFormContext<CreateProjectInputs>(); | |||
| const milestones = watch("milestones"); | |||
| const expectedTotalFee = watch("expectedProjectFee"); | |||
| const [paymentSummaryOpen, setPaymentSummaryOpen] = useState(false); | |||
| const closePaymentSummary = useCallback(() => { | |||
| setPaymentSummaryOpen(false); | |||
| }, []); | |||
| const openPaymentSummary = useCallback(() => { | |||
| setPaymentSummaryOpen(true); | |||
| }, []); | |||
| let projectTotal = 0; | |||
| return ( | |||
| @@ -37,9 +67,24 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||
| </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 }} /> | |||
| <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> | |||
| </Stack> | |||
| <Divider sx={{ paddingBlockStart: 2 }} /> | |||
| @@ -49,7 +94,9 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||
| </Stack> | |||
| {projectTotal !== expectedTotalFee && ( | |||
| <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> | |||
| )} | |||
| </Stack> | |||
| @@ -1,6 +1,6 @@ | |||
| "use client"; | |||
| import { FormHelperText } from "@mui/material"; | |||
| import { FormHelperText, SxProps } from "@mui/material"; | |||
| import Button from "@mui/material/Button"; | |||
| import Stack from "@mui/material/Stack"; | |||
| 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 { | |||
| register, | |||
| @@ -65,6 +65,7 @@ const LoginForm: React.FC = () => { | |||
| margin={5} | |||
| component="form" | |||
| onSubmit={handleSubmit(onSubmit)} | |||
| sx={sx} | |||
| > | |||
| <Typography variant="h1">{t("Sign In")}</Typography> | |||
| <TextField | |||
| @@ -7,14 +7,44 @@ import { Box } from "@mui/material"; | |||
| const LoginPage = () => { | |||
| return ( | |||
| <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 /> | |||
| </Box> | |||
| </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> | |||
| </Grid> | |||
| </Grid> | |||
| @@ -11,7 +11,25 @@ export const neutral = { | |||
| 900: "#111927", | |||
| }; | |||
| // export const primary = { | |||
| // lightest: "#F5F7FF", | |||
| // light: "#EBEEFE", | |||
| // main: "#6366F1", | |||
| // dark: "#4338CA", | |||
| // darkest: "#312E81", | |||
| // contrastText: "#FFFFFF", | |||
| // }; | |||
| export const primary = { | |||
| lightest: "#f9fff5", | |||
| light: "#f9feeb", | |||
| main: "#8dba00", | |||
| dark: "#638a01", | |||
| darkest: "#4a5f14", | |||
| contrastText: "#FFFFFF", | |||
| }; | |||
| export const secondary = { | |||
| lightest: "#F5F7FF", | |||
| light: "#EBEEFE", | |||
| main: "#6366F1", | |||
| @@ -1,6 +1,14 @@ | |||
| import { common } from "@mui/material/colors"; | |||
| 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 = { | |||
| action: { | |||
| @@ -20,6 +28,7 @@ const palette = { | |||
| info, | |||
| mode: "light", | |||
| primary, | |||
| secondary, | |||
| success, | |||
| text: { | |||
| primary: neutral[900], | |||