| @@ -0,0 +1,30 @@ | |||
| import ExpenseSearch from "@/components/ExpenseSearch" | |||
| import { getServerI18n, I18nProvider } from "@/i18n" | |||
| import { Stack, Typography } from "@mui/material" | |||
| import { Suspense } from "react" | |||
| const Expense: React.FC = async () => { | |||
| const { t } = await getServerI18n("expense") | |||
| return( | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| > | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Expense")} | |||
| </Typography> | |||
| <Suspense fallback={<ExpenseSearch.Loading />}> | |||
| <I18nProvider namespaces={["expense", "common"]}> | |||
| <ExpenseSearch /> | |||
| </I18nProvider> | |||
| </Suspense> | |||
| </Stack> | |||
| ) | |||
| } | |||
| export default Expense | |||
| @@ -0,0 +1,27 @@ | |||
| import { Metadata } from "next"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import Add from "@mui/icons-material/Add"; | |||
| import Button from "@mui/material/Button"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import Link from "next/link"; | |||
| import CreateInvoice from "@/components/CreateInvoice_forGen"; | |||
| export const metadata: Metadata = { | |||
| title: "Create Invoice", | |||
| }; | |||
| const Invoice: React.FC = async () => { | |||
| const { t } = await getServerI18n("Create Invoice"); | |||
| return ( | |||
| <> | |||
| <Typography variant="h4">{t("Create Invoice")}</Typography> | |||
| <I18nProvider namespaces={["invoice"]}> | |||
| <CreateInvoice /> | |||
| </I18nProvider> | |||
| </> | |||
| ) | |||
| }; | |||
| export default Invoice; | |||
| @@ -1,5 +1,5 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n } from "@/i18n"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import Add from "@mui/icons-material/Add"; | |||
| import Button from "@mui/material/Button"; | |||
| import Stack from "@mui/material/Stack"; | |||
| @@ -36,7 +36,9 @@ const Invoice: React.FC = async () => { | |||
| </Button> */} | |||
| </Stack> | |||
| <Suspense fallback={<InvoiceSearch.Loading />}> | |||
| <InvoiceSearch /> | |||
| <I18nProvider namespaces={["Invoice", "common"]}> | |||
| <InvoiceSearch /> | |||
| </I18nProvider> | |||
| </Suspense> | |||
| </> | |||
| ) | |||
| @@ -0,0 +1,87 @@ | |||
| import React, { useCallback, useState } from 'react'; | |||
| import { | |||
| Modal, | |||
| Box, | |||
| Typography, | |||
| Button, | |||
| SxProps, | |||
| CardContent, | |||
| CardActions, | |||
| Card | |||
| } from '@mui/material'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { FormProvider, SubmitHandler, useForm } from 'react-hook-form'; | |||
| import { Check, Close } from "@mui/icons-material"; | |||
| import InvoiceTable from './ExpenseTable'; | |||
| import { ProjectResult } from '@/app/api/projects'; | |||
| interface Props { | |||
| isOpen: boolean, | |||
| onClose: () => void | |||
| projects: ProjectResult[] | |||
| } | |||
| const modalSx: SxProps= { | |||
| position: "absolute", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| width: { xs: "calc(100% - 2rem)", sm: "90%" }, | |||
| maxHeight: "90%", | |||
| maxWidth: 1400, | |||
| bgcolor: 'background.paper', | |||
| }; | |||
| const CreateInvoiceModal: React.FC<Props> = ({isOpen, onClose, projects}) => { | |||
| const { t } = useTranslation() | |||
| const formProps = useForm<any>(); | |||
| const onSubmit = useCallback<SubmitHandler<any>>( | |||
| (data) => { | |||
| console.log(data) | |||
| } | |||
| , []) | |||
| return ( | |||
| <FormProvider {...formProps}> | |||
| <Modal | |||
| open={isOpen} | |||
| onClose={onClose} | |||
| > | |||
| <Card sx={modalSx}> | |||
| <CardContent | |||
| component="form" | |||
| onSubmit={formProps.handleSubmit(onSubmit)} | |||
| > | |||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||
| {t("Invoice Creation")} | |||
| </Typography> | |||
| <Box | |||
| sx={{ | |||
| display: 'flex', | |||
| justifyContent: 'center', | |||
| marginBlock: 2, | |||
| }} | |||
| > | |||
| <InvoiceTable projects={projects}/> | |||
| </Box> | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Close />} | |||
| onClick={onClose} | |||
| > | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button variant="contained" startIcon={<Check />} type="submit"> | |||
| {t("Save")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| </Modal> | |||
| </FormProvider> | |||
| ); | |||
| }; | |||
| export default CreateInvoiceModal; | |||
| @@ -0,0 +1,443 @@ | |||
| "use client"; | |||
| import React, { useCallback, useMemo, useState } from "react"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults"; | |||
| import { moneyFormatter } from "@/app/utils/formatUtil" | |||
| import { | |||
| Button, ButtonGroup, Stack, | |||
| Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, | |||
| CardContent, Typography, Divider, Card | |||
| } from "@mui/material"; | |||
| import AddIcon from '@mui/icons-material/Add'; | |||
| import { deleteInvoice, updateInvoice } from "@/app/api/invoices/actions"; | |||
| import { deleteDialog, errorDialogWithContent, successDialog } from "../Swal/CustomAlerts"; | |||
| import { invoiceList, issuedInvoiceList, issuedInvoiceSearchForm, receivedInvoiceList } from "@/app/api/invoices"; | |||
| import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; | |||
| import { GridCellParams, GridColDef, GridEventListener, GridRowId, GridRowModes, GridRowModesModel } from "@mui/x-data-grid"; | |||
| import { useGridApiRef } from "@mui/x-data-grid"; | |||
| import StyledDataGrid from "../StyledDataGrid"; | |||
| import { uniq } from "lodash"; | |||
| import CreateInvoiceModal from "./CreateExpenseModal"; | |||
| import { ProjectResult } from "@/app/api/projects"; | |||
| interface Props { | |||
| invoices: invoiceList[]; | |||
| projects: ProjectResult[]; | |||
| } | |||
| type InvoiceListError = { | |||
| [field in keyof invoiceList]?: string; | |||
| }; | |||
| type invoiceListRow = Partial< | |||
| invoiceList & { | |||
| _isNew: boolean; | |||
| _error: InvoiceListError; | |||
| } | |||
| >; | |||
| type SearchQuery = Partial<Omit<issuedInvoiceSearchForm, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const ExpenseSearch: React.FC<Props> = ({ invoices, projects }) => { | |||
| // console.log(invoices) | |||
| const { t } = useTranslation("expense"); | |||
| const [filteredIvoices, setFilterInovices] = useState(invoices); | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| { label: t("Invoice No"), paramName: "invoiceNo", type: "text" }, | |||
| { label: t("Project Code"), paramName: "projectCode", type: "text" }, | |||
| { | |||
| label: t("Team"), | |||
| paramName: "team", | |||
| type: "select", | |||
| options: uniq(invoices.map((invoice) => invoice.teamCodeName)), | |||
| }, | |||
| { label: t("Issue Date"), label2: t("Issue Date To"), paramName: "invoiceDate", type: "dateRange" }, | |||
| { label: t("Settle Date"), label2: t("Settle Date To"), paramName: "dueDate", type: "dateRange" }, | |||
| ], | |||
| [t, invoices], | |||
| ); | |||
| const onReset = useCallback(() => { | |||
| setFilterInovices(invoices) | |||
| }, [invoices]); | |||
| const [modelOpen, setModelOpen] = useState<boolean>(false); | |||
| const handleAddInvoiceClick = useCallback(() => { | |||
| setModelOpen(true) | |||
| },[]) | |||
| const handleModalClose = useCallback(() => { | |||
| setModelOpen(false) | |||
| },[]) | |||
| const showErrorDialog = (title: string, content: string) => { | |||
| errorDialogWithContent(title, content, t).then(() => { | |||
| window.location.reload(); | |||
| }); | |||
| }; | |||
| const columns = useMemo<Column<issuedInvoiceList>[]>( | |||
| () => [ | |||
| { name: "invoiceNo", label: t("Invoice No") }, | |||
| { name: "projectCode", label: t("Project Code") }, | |||
| { name: "stage", label: t("Stage") }, | |||
| { name: "paymentMilestone", label: t("Payment Milestone") }, | |||
| { name: "invoiceDate", label: t("Invoice Date") }, | |||
| { name: "dueDate", label: t("Due Date") }, | |||
| { name: "issuedAmount", label: t("Amount (HKD)") }, | |||
| ], | |||
| [t], | |||
| ); | |||
| const columns2 = useMemo<Column<receivedInvoiceList>[]>( | |||
| () => [ | |||
| { name: "invoiceNo", label: t("Invoice No") }, | |||
| { name: "projectCode", label: t("Project Code") }, | |||
| { name: "projectName", label: t("Project Name") }, | |||
| { name: "team", label: t("Team") }, | |||
| { name: "receiptDate", label: t("Receipt Date") }, | |||
| { name: "receivedAmount", label: t("Amount (HKD)") }, | |||
| ], | |||
| [t], | |||
| ); | |||
| const [selectedRow, setSelectedRow] = useState<invoiceListRow[] | []>([]); | |||
| const [dialogOpen, setDialogOpen] = useState(false); | |||
| const handleButtonClick = (row: invoiceList) => { | |||
| console.log(row) | |||
| setSelectedRow([row]); | |||
| setDialogOpen(true); | |||
| setRowModesModel((model) => ({ | |||
| ...model, | |||
| [row.id]: { mode: GridRowModes.Edit, fieldToFocus: "issuedAmount" }, | |||
| })); | |||
| }; | |||
| const handleCloseDialog = () => { | |||
| setDialogOpen(false); | |||
| }; | |||
| const handleDeleteInvoice = useCallback(() => { | |||
| deleteDialog(async() => { | |||
| //console.log(selectedRow[0]) | |||
| await deleteInvoice(selectedRow[0].id!!) | |||
| setDialogOpen(false); | |||
| const result = await successDialog("Delete Success", t); | |||
| if (result) { | |||
| window.location.reload() | |||
| } | |||
| }, t) | |||
| }, [selectedRow]); | |||
| const handleSaveDialog = async () => { | |||
| await updateInvoice(selectedRow[0]) | |||
| setDialogOpen(false); | |||
| successDialog(t("Update Success"), t).then(() => { | |||
| window.location.reload() | |||
| }) | |||
| }; | |||
| const combinedColumns = useMemo<Column<invoiceList>[]>( | |||
| () => [ | |||
| { | |||
| name: "invoiceNo", | |||
| label: t("Edit"), | |||
| onClick: (row: invoiceList) => ( | |||
| handleButtonClick(row) | |||
| ), | |||
| buttonIcon: <EditOutlinedIcon /> | |||
| }, | |||
| { name: "invoiceNo", label: t("Invoice No") }, | |||
| { name: "projectCode", label: t("Project Code") }, | |||
| { name: "projectName", label: t("Project Name") }, | |||
| { name: "team", label: t("Team") }, | |||
| { name: "issuedDate", label: t("Issue Date") }, | |||
| { name: "issuedAmount", label: t("Amount (HKD)"), type: 'money', needTranslation: true }, | |||
| { name: "receiptDate", label: t("Settle Date") }, | |||
| { name: "receivedAmount", label: t("Actual Received Amount (HKD)"), type: 'money', needTranslation: true }, | |||
| ], | |||
| [t] | |||
| ) | |||
| const editCombinedColumns = useMemo<GridColDef[]>( | |||
| () => [ | |||
| { field: "invoiceNo", headerName: t("Invoice No"), editable: true, flex: 0.5 }, | |||
| { field: "projectCode", headerName: t("Project Code"), editable: false, flex: 0.3 }, | |||
| { field: "projectName", headerName: t("Project Name"), flex: 1 }, | |||
| { field: "team", headerName: t("Team"), flex: 0.2 }, | |||
| { field: "issuedDate", | |||
| headerName: t("Issue Date"), | |||
| editable: true, | |||
| flex: 0.4, | |||
| // type: 'date', | |||
| // valueGetter: (params) => { | |||
| // // console.log(params.row.issuedDate) | |||
| // return new Date(params.row.issuedDate) | |||
| // }, | |||
| }, | |||
| { field: "issuedAmount", | |||
| headerName: t("Amount (HKD)"), | |||
| editable: true, | |||
| flex: 0.5, | |||
| type: 'number' | |||
| }, | |||
| { | |||
| field: "receiptDate", | |||
| headerName: t("Settle Date"), | |||
| editable: true, | |||
| flex: 0.4, | |||
| // renderCell: (params) => { | |||
| // console.log(params) | |||
| // return ( | |||
| // <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| // <DatePicker | |||
| // value={dayjs(params.value)} | |||
| // /> | |||
| // </LocalizationProvider> | |||
| // ); | |||
| // } | |||
| }, | |||
| { field: "receivedAmount", | |||
| headerName: t("Actual Received Amount (HKD)"), | |||
| editable: true, | |||
| flex: 0.5, | |||
| type: 'number' | |||
| }, | |||
| ], | |||
| [t] | |||
| ) | |||
| function isDateInRange(dateToCheck: string, startDate: string, endDate: string): boolean { | |||
| if ((!startDate || startDate === "Invalid Date") && (!endDate || endDate === "Invalid Date")) { | |||
| return true; | |||
| } | |||
| const dateToCheckObj = new Date(dateToCheck); | |||
| const startDateObj = new Date(startDate); | |||
| const endDateObj = new Date(endDate); | |||
| return ((!startDate || startDate === "Invalid Date") || dateToCheckObj >= startDateObj) && ((!endDate || endDate === "Invalid Date") || dateToCheckObj <= endDateObj); | |||
| } | |||
| const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | |||
| const apiRef = useGridApiRef(); | |||
| const validateInvoiceEntry = ( | |||
| entry: Partial<invoiceList>, | |||
| ): InvoiceListError | undefined => { | |||
| // Test for errors | |||
| const error: InvoiceListError = {}; | |||
| console.log(entry) | |||
| if (!entry.issuedAmount) { | |||
| error.issuedAmount = "Please input issued amount "; | |||
| } else if (!entry.issuedAmount) { | |||
| error.receivedAmount = "Please input received amount"; | |||
| } else if (entry.invoiceNo === "") { | |||
| error.invoiceNo = "Please input invoice number"; | |||
| } else if (!entry.issuedDate) { | |||
| error.issuedDate = "Please input issue date"; | |||
| } else if (!entry.receiptDate){ | |||
| error.receiptDate = "Please input receipt date"; | |||
| } | |||
| return Object.keys(error).length > 0 ? error : undefined; | |||
| } | |||
| const validateRow = useCallback( | |||
| (id: GridRowId) => { | |||
| const row = apiRef.current.getRowWithUpdatedValues( | |||
| id, | |||
| "", | |||
| ) | |||
| const error = validateInvoiceEntry(row); | |||
| console.log(error) | |||
| // Test for warnings | |||
| apiRef.current.updateRows([{ id, _error: error }]); | |||
| return !error; | |||
| }, | |||
| [apiRef], | |||
| ); | |||
| const handleEditStop = useCallback<GridEventListener<"rowEditStop">>( | |||
| (params, event) => { | |||
| // console.log(params.id) | |||
| if (validateRow(params.id) !== undefined || !validateRow(params.id)) { | |||
| setRowModesModel((model) => ({ | |||
| ...model, | |||
| [params.id]: { mode: GridRowModes.View}, | |||
| })); | |||
| const row = apiRef.current.getRowWithUpdatedValues( | |||
| params.id, | |||
| "", | |||
| ) | |||
| console.log(row) | |||
| setSelectedRow([{...row}] as invoiceList[]) | |||
| event.defaultMuiPrevented = true; | |||
| } | |||
| // console.log(row) | |||
| }, | |||
| [validateRow], | |||
| ); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="right" | |||
| flexWrap="wrap" | |||
| spacing={2} | |||
| > | |||
| <ButtonGroup variant="contained"> | |||
| <Button | |||
| startIcon={<AddIcon />} | |||
| variant="contained" | |||
| component="label" | |||
| onClick={handleAddInvoiceClick} | |||
| > | |||
| {t("Create Expense")} | |||
| </Button> | |||
| </ButtonGroup> | |||
| </Stack> | |||
| { | |||
| // tabIndex == 0 && | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={(query) => { | |||
| // console.log(query) | |||
| setFilterInovices( | |||
| invoices.filter( | |||
| (s) => (s.invoiceNo.toLowerCase().includes(query.invoiceNo.toLowerCase())) | |||
| && (s.projectCode.toLowerCase().includes(query.projectCode.toLowerCase())) | |||
| && (query.team === "All" || query.team.toLowerCase().includes(s.team.toLowerCase())) | |||
| && (isDateInRange(s.issuedDate, query.invoiceDate ?? undefined, query.invoiceDateTo ?? undefined)) | |||
| && (isDateInRange(s.receiptDate, query.dueDate ?? undefined, query.dueDateTo ?? undefined)) | |||
| ), | |||
| ); | |||
| }} | |||
| onReset={onReset} | |||
| /> | |||
| } | |||
| <Divider sx={{ paddingBlockStart: 2 }} /> | |||
| <Card sx={{ display: "block" }}> | |||
| <CardContent> | |||
| <Stack direction="row" justifyContent="space-between"> | |||
| <Typography variant="h6">{t('Total Issued Amount (HKD)')}:</Typography> | |||
| <Typography variant="h6">{moneyFormatter.format(filteredIvoices.reduce((acc, current) => (acc + current.issuedAmount), 0))}</Typography> | |||
| </Stack> | |||
| <Stack direction="row" justifyContent="space-between"> | |||
| <Typography variant="h6">{t('Total Received Amount (HKD)')}:</Typography> | |||
| <Typography variant="h6">{moneyFormatter.format(filteredIvoices.reduce((acc, current) => (acc + current.receivedAmount), 0))}</Typography> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| <Divider sx={{ paddingBlockEnd: 2 }} /> | |||
| <SearchResults<invoiceList> | |||
| items={filteredIvoices} | |||
| columns={combinedColumns} | |||
| autoRedirectToFirstPage | |||
| /> | |||
| <Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="lg" fullWidth> | |||
| <DialogTitle>{t("Edit Invoice")}</DialogTitle> | |||
| <DialogContent> | |||
| <DialogContentText> | |||
| {t("You can edit the invoice details here.")} | |||
| </DialogContentText> | |||
| {/* <SearchResults<invoiceList> | |||
| items={selectedRow ? [selectedRow] : []} | |||
| columns={editCombinedColumns} | |||
| /> */} | |||
| <StyledDataGrid | |||
| apiRef={apiRef} | |||
| autoHeight | |||
| sx={{ | |||
| "--DataGrid-overlayHeight": "100px", | |||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { | |||
| border: "1px solid", | |||
| borderColor: "error.main", | |||
| }, | |||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": { | |||
| border: "1px solid", | |||
| borderColor: "warning.main", | |||
| }, | |||
| '& .MuiDataGrid-columnHeaderTitle': { | |||
| whiteSpace: 'normal', | |||
| textWrap: 'pretty', | |||
| textAlign: 'center', | |||
| }, | |||
| '.MuiDataGrid-row:not(.MuiDataGrid-row--dynamicHeight)>.MuiDataGrid-cell': { | |||
| overflow: 'auto', | |||
| whiteSpace: 'nowrap', | |||
| textWrap: 'pretty', | |||
| }, | |||
| width: "100%", // Make the DataGrid wider | |||
| }} | |||
| disableColumnMenu | |||
| editMode="row" | |||
| rows={selectedRow} | |||
| rowModesModel={rowModesModel} | |||
| onRowModesModelChange={setRowModesModel} | |||
| onRowEditStop={handleEditStop} | |||
| columns={editCombinedColumns} | |||
| getCellClassName={(params: GridCellParams<invoiceListRow>) => { | |||
| let classname = ""; | |||
| if (params.row._error?.[params.field as keyof invoiceList]) { | |||
| classname = "hasError"; | |||
| } | |||
| return classname; | |||
| }} | |||
| /> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={handleDeleteInvoice} color="error"> | |||
| {t("Delete")} | |||
| </Button> | |||
| <Button onClick={handleCloseDialog} color="primary"> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| onClick={handleSaveDialog} | |||
| color="primary" | |||
| disabled={ | |||
| Object.values(rowModesModel).some((mode) => mode.mode === GridRowModes.Edit) || | |||
| selectedRow.some((row) => row._error) | |||
| } | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <CreateInvoiceModal | |||
| isOpen={modelOpen} | |||
| onClose={handleModalClose} | |||
| projects={projects} | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default ExpenseSearch; | |||
| @@ -0,0 +1,40 @@ | |||
| import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import Skeleton from "@mui/material/Skeleton"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import React from "react"; | |||
| // Can make this nicer | |||
| export const ExpenseSearchLoading: React.FC = () => { | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton | |||
| variant="rounded" | |||
| height={50} | |||
| width={100} | |||
| sx={{ alignSelf: "flex-end" }} | |||
| /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| <Card>Salary | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| </> | |||
| ); | |||
| }; | |||
| export default ExpenseSearchLoading; | |||
| @@ -0,0 +1,22 @@ | |||
| import React from "react"; | |||
| import ExpenseSearch from "./ExpenseSearch"; | |||
| import ExpenseSearchLoading from "./ExpenseSearchLoading"; | |||
| interface SubComponents { | |||
| Loading: typeof ExpenseSearchLoading; | |||
| } | |||
| const ExpenseSearchWrapper: React.FC & SubComponents = async () => { | |||
| return <ExpenseSearch | |||
| invoices={[]} | |||
| projects={[]} | |||
| /> | |||
| }; | |||
| ExpenseSearchWrapper.Loading = ExpenseSearchLoading; | |||
| export default ExpenseSearchWrapper; | |||
| @@ -0,0 +1,341 @@ | |||
| import React, { useCallback, useMemo, useState, useEffect } from "react"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults"; | |||
| import EditNote from "@mui/icons-material/EditNote"; | |||
| import { moneyFormatter } from "@/app/utils/formatUtil" | |||
| import { Button, ButtonGroup, Stack, Tab, Tabs, TabsProps, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, TextField, CardContent, Typography, Divider, Card, Box, Autocomplete, MenuItem } from "@mui/material"; | |||
| import FileUploadIcon from '@mui/icons-material/FileUpload'; | |||
| import { Add, Check, Close, Delete } from "@mui/icons-material"; | |||
| import { deleteInvoice, importIssuedInovice, importReceivedInovice, updateInvoice } from "@/app/api/invoices/actions"; | |||
| import { deleteDialog, errorDialogWithContent, successDialog } from "../Swal/CustomAlerts"; | |||
| import { invoiceList, issuedInvoiceList, issuedInvoiceSearchForm, receivedInvoiceList, receivedInvoiceSearchForm } from "@/app/api/invoices"; | |||
| import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; | |||
| import { | |||
| GridCellParams, | |||
| GridColDef, | |||
| GridEventListener, | |||
| GridRowId, | |||
| GridRowModel, | |||
| GridRowModes, | |||
| GridRowModesModel, | |||
| GridRenderEditCellParams, | |||
| } from "@mui/x-data-grid"; | |||
| import { useGridApiRef } from "@mui/x-data-grid"; | |||
| import StyledDataGrid from "../StyledDataGrid"; | |||
| import { uniq } from "lodash"; | |||
| import CreateInvoiceModal from "./CreateExpenseModal"; | |||
| import { GridToolbarContainer } from "@mui/x-data-grid"; | |||
| import { FooterPropsOverrides } from "@mui/x-data-grid"; | |||
| import { th } from "@faker-js/faker"; | |||
| import { GridRowIdGetter } from "@mui/x-data-grid"; | |||
| import { useFormContext } from "react-hook-form"; | |||
| import { ProjectResult } from "@/app/api/projects"; | |||
| type InvoiceListError = { | |||
| [field in keyof invoiceList]?: string; | |||
| }; | |||
| type invoiceListRow = Partial< | |||
| invoiceList & { | |||
| _isNew: boolean; | |||
| _error: InvoiceListError; | |||
| } | |||
| >; | |||
| interface Props { | |||
| projects: ProjectResult[]; | |||
| } | |||
| class ProcessRowUpdateError extends Error { | |||
| public readonly row: invoiceListRow; | |||
| public readonly errors: InvoiceListError | undefined; | |||
| constructor( | |||
| row: invoiceListRow, | |||
| message?: string, | |||
| errors?: InvoiceListError, | |||
| ) { | |||
| super(message); | |||
| this.row = row; | |||
| this.errors = errors; | |||
| Object.setPrototypeOf(this, ProcessRowUpdateError.prototype); | |||
| } | |||
| } | |||
| type project = { | |||
| label: string; | |||
| value: number; | |||
| } | |||
| const InvoiceTable: React.FC<Props> = ({ projects }) => { | |||
| console.log(projects) | |||
| const { t } = useTranslation() | |||
| const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | |||
| const [selectedRow, setSelectedRow] = useState<invoiceListRow[] | []>([]); | |||
| const { getValues, setValue, clearErrors, setError } = | |||
| useFormContext<any>(); | |||
| const apiRef = useGridApiRef(); | |||
| const [projectCode, setProjectCode] = useState<project>({label: "", value: 0}) | |||
| const validateInvoiceEntry = ( | |||
| entry: Partial<invoiceList>, | |||
| ): InvoiceListError | undefined => { | |||
| // Test for errors | |||
| const error: any = {}; | |||
| console.log(entry) | |||
| if (!entry.issuedAmount) { | |||
| error.issuedAmount = "Please input issued amount "; | |||
| } else if (!entry.issuedAmount) { | |||
| error.receivedAmount = "Please input received amount"; | |||
| } else if (entry.invoiceNo === "") { | |||
| error.invoiceNo = "Please input invoice number"; | |||
| } else if (!entry.issuedDate) { | |||
| error.issuedDate = "Please input issue date"; | |||
| } else if (!entry.receiptDate){ | |||
| } | |||
| return Object.keys(error).length > 0 ? error : undefined; | |||
| } | |||
| const validateRow = useCallback( | |||
| (id: GridRowId) => { | |||
| const row = apiRef.current.getRowWithUpdatedValues( | |||
| id, | |||
| "", | |||
| ) | |||
| const error = validateInvoiceEntry(row); | |||
| // console.log(error) | |||
| // Test for warnings | |||
| // apiRef.current.updateRows([{ id, _error: error }]); | |||
| return error; | |||
| }, | |||
| [], | |||
| ); | |||
| const handleEditStop = useCallback<GridEventListener<"rowEditStop">>( | |||
| (params, event) => { | |||
| const row = apiRef.current.getRowWithUpdatedValues( | |||
| params.id, | |||
| "", | |||
| ) | |||
| console.log(validateRow(params.id) !== undefined) | |||
| console.log(!validateRow(params.id)) | |||
| if (validateRow(params.id) !== undefined && !validateRow(params.id)) { | |||
| setRowModesModel((model) => ({ | |||
| ...model, | |||
| [params.id]: { mode: GridRowModes.View}, | |||
| })); | |||
| console.log(row) | |||
| setSelectedRow((row) => [...row] as any[]) | |||
| event.defaultMuiPrevented = true; | |||
| }else{ | |||
| console.log(row) | |||
| const error = validateRow(params.id) | |||
| setSelectedRow((row) => { | |||
| const updatedRow = row.map(r => r.id === params.id ? { ...r, _error: error } : r); | |||
| return updatedRow; | |||
| }) | |||
| } | |||
| // console.log(row) | |||
| }, | |||
| [validateRow], | |||
| ); | |||
| const addRow = useCallback(() => { | |||
| const id = Date.now(); | |||
| setSelectedRow((e) => [...e, { id, _isNew: true }]); | |||
| setRowModesModel((model) => ({ | |||
| ...model, | |||
| [id]: { mode: GridRowModes.Edit }, | |||
| })); | |||
| }, []); | |||
| const processRowUpdate = useCallback( | |||
| ( | |||
| newRow: GridRowModel<invoiceListRow>, | |||
| originalRow: GridRowModel<invoiceListRow>, | |||
| ) => { | |||
| const errors = validateRow(newRow.id!!); | |||
| if (errors) { | |||
| // console.log(errors) | |||
| // throw new error for error checking | |||
| throw new ProcessRowUpdateError( | |||
| originalRow, | |||
| "validation error", | |||
| errors, | |||
| ) | |||
| } | |||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | |||
| const { _isNew, _error, ...updatedRow } = newRow; | |||
| const rowToSave = { | |||
| ...updatedRow, | |||
| } satisfies invoiceListRow; | |||
| console.log(newRow) | |||
| setSelectedRow((es) => | |||
| es.map((e) => (e.id === originalRow.id ? rowToSave : e)) | |||
| ); | |||
| console.log(rowToSave) | |||
| return rowToSave; | |||
| }, | |||
| [validateRow], | |||
| ); | |||
| /** | |||
| * Add callback to check error | |||
| */ | |||
| const onProcessRowUpdateError = useCallback( | |||
| (updateError: ProcessRowUpdateError) => { | |||
| const errors = updateError.errors; | |||
| const oldRow = updateError.row; | |||
| // console.log(errors) | |||
| apiRef.current.updateRows([{ ...oldRow, _error: errors }]); | |||
| }, | |||
| [apiRef] | |||
| ) | |||
| useEffect(() => { | |||
| console.log(selectedRow) | |||
| setValue("data", selectedRow) | |||
| }, [selectedRow, setValue]); | |||
| const editCombinedColumns = useMemo<GridColDef[]>( | |||
| () => [ | |||
| { field: "invoiceNo", headerName: t("Invoice No"), editable: true, flex: 0.5 }, | |||
| { field: "projectCode", | |||
| headerName: t("Project Code"), | |||
| editable: true, | |||
| flex: 0.3, | |||
| renderEditCell(params: GridRenderEditCellParams<invoiceListRow, number>){ | |||
| return( | |||
| <Autocomplete | |||
| disablePortal | |||
| options={[]} | |||
| sx={{width: '100%'}} | |||
| renderInput={(params) => <TextField {...params} />} | |||
| /> | |||
| ) | |||
| } | |||
| }, | |||
| { field: "issuedDate", | |||
| headerName: t("Issue Date"), | |||
| editable: true, | |||
| flex: 0.4, | |||
| // type: 'date', | |||
| // valueGetter: (params) => { | |||
| // // console.log(params.row.issuedDate) | |||
| // return new Date(params.row.issuedDate) | |||
| // }, | |||
| }, | |||
| { field: "issuedAmount", | |||
| headerName: t("Amount (HKD)"), | |||
| editable: true, | |||
| flex: 0.5, | |||
| type: 'number' | |||
| }, | |||
| { | |||
| field: "receiptDate", | |||
| headerName: t("Settle Date"), | |||
| editable: true, | |||
| flex: 0.4, | |||
| }, | |||
| { field: "receivedAmount", | |||
| headerName: t("Actual Received Amount (HKD)"), | |||
| editable: true, | |||
| flex: 0.5, | |||
| type: 'number' | |||
| }, | |||
| ], | |||
| [t] | |||
| ) | |||
| const footer = ( | |||
| <Box display="flex" gap={2} alignItems="center"> | |||
| <Button | |||
| disableRipple | |||
| variant="outlined" | |||
| startIcon={<Add />} | |||
| onClick={addRow} | |||
| size="small" | |||
| > | |||
| {t("Create Invoice")} | |||
| </Button> | |||
| </Box> | |||
| ); | |||
| return ( | |||
| <> | |||
| <StyledDataGrid | |||
| apiRef={apiRef} | |||
| sx={{ | |||
| "--DataGrid-overlayHeight": "100px", | |||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { | |||
| border: "1px solid", | |||
| borderColor: "error.main", | |||
| }, | |||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": { | |||
| border: "1px solid", | |||
| borderColor: "warning.main", | |||
| }, | |||
| height: 400, width: '95%' | |||
| }} | |||
| disableColumnMenu | |||
| editMode="row" | |||
| rows={selectedRow} | |||
| rowModesModel={rowModesModel} | |||
| onRowModesModelChange={setRowModesModel} | |||
| onRowEditStop={handleEditStop} | |||
| columns={editCombinedColumns} | |||
| processRowUpdate={processRowUpdate} | |||
| onProcessRowUpdateError={onProcessRowUpdateError} | |||
| getCellClassName={(params: GridCellParams<invoiceListRow>) => { | |||
| let classname = ""; | |||
| if (params.row._error?.[params.field as keyof invoiceList]) { | |||
| classname = "hasError"; | |||
| } | |||
| return classname; | |||
| }} | |||
| slots={{ | |||
| footer: FooterToolbar, | |||
| noRowsOverlay: NoRowsOverlay, | |||
| }} | |||
| slotProps={{ | |||
| footer: { child: footer }, | |||
| }} | |||
| /> | |||
| </> | |||
| ) | |||
| } | |||
| export default InvoiceTable | |||
| const NoRowsOverlay: React.FC = () => { | |||
| const { t } = useTranslation("home"); | |||
| return ( | |||
| <Box | |||
| display="flex" | |||
| justifyContent="center" | |||
| alignItems="center" | |||
| height="100%" | |||
| > | |||
| <Typography variant="caption">{t("Add some time entries!")}</Typography> | |||
| </Box> | |||
| ); | |||
| }; | |||
| const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => { | |||
| return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>; | |||
| }; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./ExpenseSearchWrapper"; | |||
| @@ -36,6 +36,7 @@ import ManageAccountsIcon from "@mui/icons-material/ManageAccounts"; | |||
| import EmojiEventsIcon from "@mui/icons-material/EmojiEvents"; | |||
| import FileUploadIcon from '@mui/icons-material/FileUpload'; | |||
| import EmailIcon from "@mui/icons-material/Email"; | |||
| import RequestQuoteIcon from '@mui/icons-material/RequestQuote'; | |||
| import { | |||
| IMPORT_INVOICE, | |||
| @@ -199,6 +200,14 @@ const NavigationContent: React.FC<Props> = ({ abilities, username }) => { | |||
| abilities!.includes(ability), | |||
| ), | |||
| }, | |||
| { | |||
| icon: <RequestQuoteIcon />, | |||
| label: "Expense", | |||
| path: "/expense", | |||
| isHidden: ![IMPORT_INVOICE, IMPORT_RECEIPT].some((ability) => | |||
| abilities!.includes(ability), | |||
| ), | |||
| }, | |||
| { | |||
| icon: <Analytics />, | |||
| label: "Analysis Report", | |||