You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

590 regels
19 KiB

  1. "use client";
  2. import React, { useCallback, useMemo, useState } from "react";
  3. import SearchBox, { Criterion } from "../SearchBox";
  4. import { useTranslation } from "react-i18next";
  5. import SearchResults, { Column } from "../SearchResults";
  6. import EditNote from "@mui/icons-material/EditNote";
  7. import { moneyFormatter } from "@/app/utils/formatUtil"
  8. import { Button, ButtonGroup, Stack, Tab, Tabs, TabsProps, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, TextField, CardContent, Typography, Divider, Card } from "@mui/material";
  9. import FileUploadIcon from '@mui/icons-material/FileUpload';
  10. import AddIcon from '@mui/icons-material/Add';
  11. import { deleteInvoice, importIssuedInovice, importReceivedInovice, updateInvoice } from "@/app/api/invoices/actions";
  12. import { deleteDialog, errorDialogWithContent, successDialog } from "../Swal/CustomAlerts";
  13. import { invoiceList, issuedInvoiceList, issuedInvoiceSearchForm, receivedInvoiceList, receivedInvoiceSearchForm } from "@/app/api/invoices";
  14. import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
  15. import { GridCellParams, GridColDef, GridEventListener, GridRowId, GridRowModes, GridRowModesModel } from "@mui/x-data-grid";
  16. import { useGridApiRef } from "@mui/x-data-grid";
  17. import StyledDataGrid from "../StyledDataGrid";
  18. import { uniq } from "lodash";
  19. import CreateInvoiceModal from "./CreateInvoiceModal";
  20. import { ProjectResult } from "@/app/api/projects";
  21. import { IMPORT_INVOICE, IMPORT_RECEIPT } from "@/middleware";
  22. interface Props {
  23. invoices: invoiceList[];
  24. projects: ProjectResult[];
  25. abilities: string[];
  26. }
  27. type InvoiceListError = {
  28. [field in keyof invoiceList]?: string;
  29. };
  30. type invoiceListRow = Partial<
  31. invoiceList & {
  32. _isNew: boolean;
  33. _error: InvoiceListError;
  34. }
  35. >;
  36. type SearchQuery = Partial<Omit<issuedInvoiceSearchForm, "id">>;
  37. type SearchParamNames = keyof SearchQuery;
  38. type SearchQuery2 = Partial<Omit<receivedInvoiceSearchForm, "id">>;
  39. type SearchParamNames2 = keyof SearchQuery2;
  40. const InvoiceSearch: React.FC<Props> = ({ invoices, projects, abilities }) => {
  41. console.log(abilities)
  42. const { t } = useTranslation("Invoice");
  43. const [filteredIvoices, setFilterInovices] = useState(invoices);
  44. const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
  45. () => [
  46. { label: t("Invoice No"), paramName: "invoiceNo", type: "text" },
  47. { label: t("Project Code"), paramName: "projectCode", type: "text" },
  48. {
  49. label: t("Team"),
  50. paramName: "team",
  51. type: "select",
  52. options: uniq(invoices.map((invoice) => invoice.teamCodeName)),
  53. },
  54. { label: t("Issue Date"), label2: t("Issue Date To"), paramName: "invoiceDate", type: "dateRange" },
  55. { label: t("Settle Date"), label2: t("Settle Date To"), paramName: "dueDate", type: "dateRange" },
  56. ],
  57. [t, invoices],
  58. );
  59. const onReset = useCallback(() => {
  60. // setFilteredIssuedInvoices(issuedInvoice);
  61. setFilterInovices(invoices)
  62. }, [invoices]);
  63. function concatListOfObject(obj: any[]): string {
  64. return obj.map(obj => `Cannot find "${obj.paymentMilestone}" in ${obj.invoiceNo}`).join(", ")
  65. }
  66. function concatListOfObject2(obj: any[]): string {
  67. return obj.map(obj => `"${obj.projectCode}" does not match with ${obj.invoicesNo}`).join(", ")
  68. }
  69. const handleImportClick = useCallback(async (event: any) => {
  70. try {
  71. const file = event.target.files[0];
  72. if (!file) {
  73. console.log('No file selected');
  74. return;
  75. }
  76. if (file.type !== 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') {
  77. console.log('Invalid file format. Only XLSX files are allowed.');
  78. return;
  79. }
  80. const formData = new FormData();
  81. formData.append('multipartFileList', file);
  82. const response = await importIssuedInovice(formData);
  83. // response: status, message, projectList, emptyRowList, invoiceList
  84. if (response.status) {
  85. successDialog(t("Import Success"), t).then(() => {
  86. window.location.reload();
  87. });
  88. } else {
  89. handleImportError(response);
  90. }
  91. } catch (err) {
  92. console.log(err);
  93. return false;
  94. }
  95. }, []);
  96. const [modelOpen, setModelOpen] = useState<boolean>(false);
  97. const handleAddInvoiceClick = useCallback(() => {
  98. setModelOpen(true)
  99. },[])
  100. const handleModalClose = useCallback(() => {
  101. setModelOpen(false)
  102. },[])
  103. const handleImportError = (response: any) => {
  104. if (response.emptyRowList.length >= 1) {
  105. showErrorDialog(
  106. t("Import Fail"),
  107. t(`Please fill the mandatory field at Row <br> ${response.emptyRowList.join(", ")}`)
  108. );
  109. } else if (response.projectList.length >= 1) {
  110. showErrorDialog(
  111. t("Import Fail"),
  112. t(`Please check the corresponding project code <br> ${response.projectList.join(", ")}`)
  113. );
  114. } else if (response.invoiceList.length >= 1) {
  115. showErrorDialog(
  116. t("Import Fail"),
  117. t(`Please check the corresponding Invoice No. The invoice is imported. <br>`) + `${response.invoiceList.join(", ")}`
  118. );
  119. } else if (response.duplicateItem.length >= 1) {
  120. showErrorDialog(
  121. t("Import Fail"),
  122. t(`Please check the corresponding Invoice No. The below invoice has duplicated number. <br>`)+ `${response.duplicateItem.join(", ")}`
  123. );
  124. } else if (response.paymentMilestones.length >= 1) {
  125. showErrorDialog(
  126. t("Import Fail"),
  127. t(`The payment milestone does not match with records. Please check the corresponding Invoice No. <br>`) + `${concatListOfObject(response.paymentMilestones)}`
  128. );
  129. }
  130. };
  131. const showErrorDialog = (title: string, content: string) => {
  132. errorDialogWithContent(title, content, t).then(() => {
  133. window.location.reload();
  134. });
  135. };
  136. const handleRecImportClick = useCallback(async (event:any) => {
  137. try {
  138. const file = event.target.files[0];
  139. if (!file) {
  140. console.log('No file selected');
  141. return;
  142. }
  143. if (file.type !== 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') {
  144. console.log('Invalid file format. Only XLSX files are allowed.');
  145. return;
  146. }
  147. const formData = new FormData();
  148. formData.append('multipartFileList', file);
  149. const response = await importReceivedInovice(formData)
  150. console.log(response)
  151. if (response.status) {
  152. successDialog(t("Import Success"), t).then(() => {
  153. window.location.reload()
  154. })
  155. }else{
  156. if (response.emptyRowList.length >= 1){
  157. errorDialogWithContent(t("Import Fail"),
  158. t(`Please fill the mandatory field at Row <br> ${response.emptyRowList.join(", ")}`), t)
  159. .then(() => {
  160. window.location.reload()
  161. })
  162. }
  163. else if (response.projectList.length >= 1){
  164. errorDialogWithContent(t("Import Fail"),
  165. t(`Please check the corresponding project code <br> ${response.projectList.join(", ")}`), t)
  166. .then(() => {
  167. // window.location.reload()
  168. })
  169. }
  170. else if (response.invoiceList.length >= 1){
  171. errorDialogWithContent(t("Import Fail"),
  172. t(`Please check the corresponding Invoice No. The invoice has not yet issued. <br>`)+ `${response.invoiceList.join(", ")}`, t)
  173. .then(() => {
  174. window.location.reload()
  175. })
  176. }
  177. else if (response.duplicateItem.length >= 1){
  178. errorDialogWithContent(t("Import Fail"),
  179. t(`Please check the corresponding Invoice No. The below invoice has duplicated number. <br>`)+ `${response.duplicateItem.join(", ")}`, t)
  180. .then(() => {
  181. window.location.reload()
  182. })
  183. }else if (response.paymentMilestones.length >= 1){
  184. errorDialogWithContent(t("Import Fail"),
  185. t(`The payment milestone does not match with records. Please check the corresponding Invoice No. <br>`)+ `${concatListOfObject2(response.paymentMilestones)}`, t)
  186. .then(() => {
  187. window.location.reload()
  188. })
  189. }
  190. }
  191. }catch(error){
  192. console.log(error)
  193. }
  194. }, []);
  195. const [selectedRow, setSelectedRow] = useState<invoiceListRow[] | []>([]);
  196. const [dialogOpen, setDialogOpen] = useState(false);
  197. const handleButtonClick = (row: invoiceList) => {
  198. console.log(row)
  199. setSelectedRow([row]);
  200. setDialogOpen(true);
  201. setRowModesModel((model) => ({
  202. ...model,
  203. [row.id]: { mode: GridRowModes.Edit, fieldToFocus: "issuedAmount" },
  204. }));
  205. };
  206. const handleCloseDialog = () => {
  207. setDialogOpen(false);
  208. };
  209. const handleDeleteInvoice = useCallback(() => {
  210. deleteDialog(async() => {
  211. //console.log(selectedRow[0])
  212. await deleteInvoice(selectedRow[0].id!!)
  213. setDialogOpen(false);
  214. const result = await successDialog("Delete Success", t);
  215. if (result) {
  216. window.location.reload()
  217. }
  218. }, t)
  219. }, [selectedRow]);
  220. const handleSaveDialog = async () => {
  221. // setDialogOpen(false);
  222. await updateInvoice(selectedRow[0])
  223. setDialogOpen(false);
  224. successDialog(t("Update Success"), t).then(() => {
  225. window.location.reload()
  226. })
  227. // console.log(selectedRow[0])
  228. // setSelectedRow([]);
  229. };
  230. const combinedColumns = useMemo<Column<invoiceList>[]>(
  231. () => [
  232. {
  233. name: "invoiceNo",
  234. label: t("Edit"),
  235. onClick: (row: invoiceList) => (
  236. handleButtonClick(row)
  237. ),
  238. buttonIcon: <EditOutlinedIcon />
  239. },
  240. { name: "invoiceNo", label: t("Invoice No") },
  241. { name: "projectCode", label: t("Project Code") },
  242. { name: "projectName", label: t("Project Name") },
  243. { name: "team", label: t("Team") },
  244. { name: "issuedDate", label: t("Issue Date") },
  245. { name: "issuedAmount", label: t("Amount (HKD)"), type: 'money', needTranslation: true },
  246. { name: "receiptDate", label: t("Settle Date") },
  247. { name: "receivedAmount", label: t("Actual Received Amount (HKD)"), type: 'money', needTranslation: true },
  248. ],
  249. [t]
  250. )
  251. const editCombinedColumns = useMemo<GridColDef[]>(
  252. () => [
  253. { field: "invoiceNo", headerName: t("Invoice No"), editable: true, flex: 0.5 },
  254. { field: "projectCode", headerName: t("Project Code"), editable: false, flex: 0.3 },
  255. { field: "projectName", headerName: t("Project Name"), flex: 1 },
  256. { field: "team", headerName: t("Team"), flex: 0.2 },
  257. { field: "issuedDate",
  258. headerName: t("Issue Date"),
  259. editable: true,
  260. flex: 0.4,
  261. // type: 'date',
  262. // valueGetter: (params) => {
  263. // // console.log(params.row.issuedDate)
  264. // return new Date(params.row.issuedDate)
  265. // },
  266. },
  267. { field: "issuedAmount",
  268. headerName: t("Amount (HKD)"),
  269. editable: true,
  270. flex: 0.5,
  271. type: 'number'
  272. },
  273. {
  274. field: "receiptDate",
  275. headerName: t("Settle Date"),
  276. editable: true,
  277. flex: 0.4,
  278. // renderCell: (params) => {
  279. // console.log(params)
  280. // return (
  281. // <LocalizationProvider dateAdapter={AdapterDayjs}>
  282. // <DatePicker
  283. // value={dayjs(params.value)}
  284. // />
  285. // </LocalizationProvider>
  286. // );
  287. // }
  288. },
  289. { field: "receivedAmount",
  290. headerName: t("Actual Received Amount (HKD)"),
  291. editable: true,
  292. flex: 0.5,
  293. type: 'number'
  294. },
  295. ],
  296. [t]
  297. )
  298. function isDateInRange(dateToCheck: string, startDate: string, endDate: string): boolean {
  299. if ((!startDate || startDate === "Invalid Date") && (!endDate || endDate === "Invalid Date")) {
  300. return true;
  301. }
  302. const dateToCheckObj = new Date(dateToCheck);
  303. const startDateObj = new Date(startDate);
  304. const endDateObj = new Date(endDate);
  305. return ((!startDate || startDate === "Invalid Date") || dateToCheckObj >= startDateObj) && ((!endDate || endDate === "Invalid Date") || dateToCheckObj <= endDateObj);
  306. }
  307. const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
  308. const apiRef = useGridApiRef();
  309. const validateInvoiceEntry = (
  310. entry: Partial<invoiceList>,
  311. ): InvoiceListError | undefined => {
  312. // Test for errors
  313. const error: InvoiceListError = {};
  314. console.log(entry)
  315. if (!entry.issuedAmount) {
  316. error.issuedAmount = "Please input issued amount ";
  317. } else if (!entry.issuedAmount) {
  318. error.receivedAmount = "Please input received amount";
  319. } else if (entry.invoiceNo === "") {
  320. error.invoiceNo = "Please input invoice number";
  321. } else if (!entry.issuedDate) {
  322. error.issuedDate = "Please input issue date";
  323. } else if (!entry.receiptDate){
  324. error.receiptDate = "Please input receipt date";
  325. }
  326. return Object.keys(error).length > 0 ? error : undefined;
  327. }
  328. const validateRow = useCallback(
  329. (id: GridRowId) => {
  330. const row = apiRef.current.getRowWithUpdatedValues(
  331. id,
  332. "",
  333. )
  334. const error = validateInvoiceEntry(row);
  335. console.log(error)
  336. // Test for warnings
  337. apiRef.current.updateRows([{ id, _error: error }]);
  338. return !error;
  339. },
  340. [apiRef],
  341. );
  342. const handleEditStop = useCallback<GridEventListener<"rowEditStop">>(
  343. (params, event) => {
  344. // console.log(params.id)
  345. if (validateRow(params.id) !== undefined || !validateRow(params.id)) {
  346. setRowModesModel((model) => ({
  347. ...model,
  348. [params.id]: { mode: GridRowModes.View},
  349. }));
  350. const row = apiRef.current.getRowWithUpdatedValues(
  351. params.id,
  352. "",
  353. )
  354. console.log(row)
  355. setSelectedRow([{...row}] as invoiceList[])
  356. event.defaultMuiPrevented = true;
  357. }
  358. // console.log(row)
  359. },
  360. [validateRow],
  361. );
  362. const isAddInvoiceRightExist = () => {
  363. const importRight = [IMPORT_INVOICE, IMPORT_RECEIPT].some((ability) => abilities.includes(ability))
  364. return importRight
  365. }
  366. return (
  367. <>
  368. {
  369. isAddInvoiceRightExist() &&
  370. <Stack
  371. direction="row"
  372. justifyContent="right"
  373. flexWrap="wrap"
  374. spacing={2}
  375. >
  376. <ButtonGroup variant="contained">
  377. <Button
  378. startIcon={<AddIcon />}
  379. variant="outlined"
  380. component="label"
  381. onClick={handleAddInvoiceClick}
  382. >
  383. {t("Create Invoice")}
  384. </Button>
  385. <Button startIcon={<FileUploadIcon />} variant="contained" component="label">
  386. <input
  387. id='importExcel'
  388. type='file'
  389. accept='.xlsx, .csv'
  390. hidden
  391. onChange={(event) => {handleImportClick(event)}}
  392. />
  393. {t("Import Invoice Issue Summary")}
  394. </Button>
  395. <Button startIcon={<FileUploadIcon />} component="label" variant="contained">
  396. <input
  397. id='importExcel'
  398. type='file'
  399. accept='.xlsx, .csv'
  400. hidden
  401. onChange={(event) => {handleRecImportClick(event)}}
  402. />
  403. {t("Import Invoice Amount Receive Summary")}
  404. </Button>
  405. </ButtonGroup>
  406. </Stack>
  407. }
  408. {
  409. // tabIndex == 0 &&
  410. <SearchBox
  411. criteria={searchCriteria}
  412. onSearch={(query) => {
  413. // console.log(query)
  414. setFilterInovices(
  415. invoices.filter(
  416. (s) => (s.invoiceNo.toLowerCase().includes(query.invoiceNo.toLowerCase()))
  417. && (s.projectCode.toLowerCase().includes(query.projectCode.toLowerCase()))
  418. && (query.team === "All" || query.team.toLowerCase().includes(s.team.toLowerCase()))
  419. && (isDateInRange(s.issuedDate, query.invoiceDate ?? undefined, query.invoiceDateTo ?? undefined))
  420. && (isDateInRange(s.receiptDate, query.dueDate ?? undefined, query.dueDateTo ?? undefined))
  421. ),
  422. );
  423. }}
  424. onReset={onReset}
  425. />
  426. }
  427. <Divider sx={{ paddingBlockStart: 2 }} />
  428. <Card sx={{ display: "block" }}>
  429. <CardContent>
  430. <Stack direction="row" justifyContent="space-between">
  431. <Typography variant="h6">{t('Total Issued Amount (HKD)')}:</Typography>
  432. <Typography variant="h6">{moneyFormatter.format(filteredIvoices.reduce((acc, current) => (acc + current.issuedAmount), 0))}</Typography>
  433. </Stack>
  434. <Stack direction="row" justifyContent="space-between">
  435. <Typography variant="h6">{t('Total Received Amount (HKD)')}:</Typography>
  436. <Typography variant="h6">{moneyFormatter.format(filteredIvoices.reduce((acc, current) => (acc + current.receivedAmount), 0))}</Typography>
  437. </Stack>
  438. </CardContent>
  439. </Card>
  440. <Divider sx={{ paddingBlockEnd: 2 }} />
  441. {
  442. // tabIndex == 0 &&
  443. <SearchResults<invoiceList>
  444. items={filteredIvoices}
  445. columns={combinedColumns}
  446. autoRedirectToFirstPage
  447. />
  448. }
  449. <Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="lg" fullWidth>
  450. <DialogTitle>{t("Edit Invoice")}</DialogTitle>
  451. <DialogContent>
  452. <DialogContentText>
  453. {t("You can edit the invoice details here.")}
  454. </DialogContentText>
  455. <StyledDataGrid
  456. apiRef={apiRef}
  457. autoHeight
  458. sx={{
  459. "--DataGrid-overlayHeight": "100px",
  460. ".MuiDataGrid-row .MuiDataGrid-cell.hasError": {
  461. border: "1px solid",
  462. borderColor: "error.main",
  463. },
  464. ".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": {
  465. border: "1px solid",
  466. borderColor: "warning.main",
  467. },
  468. '& .MuiDataGrid-columnHeaderTitle': {
  469. whiteSpace: 'normal',
  470. textWrap: 'pretty',
  471. textAlign: 'center',
  472. },
  473. '.MuiDataGrid-row:not(.MuiDataGrid-row--dynamicHeight)>.MuiDataGrid-cell': {
  474. overflow: 'auto',
  475. whiteSpace: 'nowrap',
  476. textWrap: 'pretty',
  477. },
  478. width: "100%", // Make the DataGrid wider
  479. }}
  480. disableColumnMenu
  481. editMode="row"
  482. rows={selectedRow}
  483. rowModesModel={rowModesModel}
  484. onRowModesModelChange={setRowModesModel}
  485. onRowEditStop={handleEditStop}
  486. columns={editCombinedColumns}
  487. getCellClassName={(params: GridCellParams<invoiceListRow>) => {
  488. let classname = "";
  489. if (params.row._error?.[params.field as keyof invoiceList]) {
  490. classname = "hasError";
  491. }
  492. return classname;
  493. }}
  494. />
  495. </DialogContent>
  496. <DialogActions>
  497. <Button onClick={handleDeleteInvoice} color="error">
  498. {t("Delete")}
  499. </Button>
  500. <Button onClick={handleCloseDialog} color="primary">
  501. {t("Cancel")}
  502. </Button>
  503. <Button
  504. onClick={handleSaveDialog}
  505. color="primary"
  506. disabled={
  507. Object.values(rowModesModel).some((mode) => mode.mode === GridRowModes.Edit) ||
  508. selectedRow.some((row) => row._error)
  509. }
  510. >
  511. {t("Save")}
  512. </Button>
  513. </DialogActions>
  514. </Dialog>
  515. <CreateInvoiceModal
  516. isOpen={modelOpen}
  517. onClose={handleModalClose}
  518. projects={projects}
  519. invoices={invoices}
  520. />
  521. </>
  522. );
  523. };
  524. export default InvoiceSearch;