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