| @@ -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 { Metadata } from "next"; | ||||
| import { getServerI18n } from "@/i18n"; | |||||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||||
| import Add from "@mui/icons-material/Add"; | import Add from "@mui/icons-material/Add"; | ||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| @@ -36,7 +36,9 @@ const Invoice: React.FC = async () => { | |||||
| </Button> */} | </Button> */} | ||||
| </Stack> | </Stack> | ||||
| <Suspense fallback={<InvoiceSearch.Loading />}> | <Suspense fallback={<InvoiceSearch.Loading />}> | ||||
| <InvoiceSearch /> | |||||
| <I18nProvider namespaces={["Invoice", "common"]}> | |||||
| <InvoiceSearch /> | |||||
| </I18nProvider> | |||||
| </Suspense> | </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 EmojiEventsIcon from "@mui/icons-material/EmojiEvents"; | ||||
| import FileUploadIcon from '@mui/icons-material/FileUpload'; | import FileUploadIcon from '@mui/icons-material/FileUpload'; | ||||
| import EmailIcon from "@mui/icons-material/Email"; | import EmailIcon from "@mui/icons-material/Email"; | ||||
| import RequestQuoteIcon from '@mui/icons-material/RequestQuote'; | |||||
| import { | import { | ||||
| IMPORT_INVOICE, | IMPORT_INVOICE, | ||||
| @@ -199,6 +200,14 @@ const NavigationContent: React.FC<Props> = ({ abilities, username }) => { | |||||
| abilities!.includes(ability), | abilities!.includes(ability), | ||||
| ), | ), | ||||
| }, | }, | ||||
| { | |||||
| icon: <RequestQuoteIcon />, | |||||
| label: "Expense", | |||||
| path: "/expense", | |||||
| isHidden: ![IMPORT_INVOICE, IMPORT_RECEIPT].some((ability) => | |||||
| abilities!.includes(ability), | |||||
| ), | |||||
| }, | |||||
| { | { | ||||
| icon: <Analytics />, | icon: <Analytics />, | ||||
| label: "Analysis Report", | label: "Analysis Report", | ||||