@@ -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], | ||||