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