Переглянути джерело

update expense

tags/Baseline_180220205_Frontend
MSI\derek 11 місяці тому
джерело
коміт
b5786710f5
5 змінених файлів з 152 додано та 410 видалено
  1. +2
    -1
      src/app/(main)/expense/page.tsx
  2. +28
    -0
      src/app/api/expenses/index.ts
  3. +101
    -400
      src/components/ExpenseSearch/ExpenseSearch.tsx
  4. +19
    -4
      src/components/ExpenseSearch/ExpenseSearchWrapper.tsx
  5. +2
    -5
      src/components/InvoiceSearch/InvoiceTable.tsx

+ 2
- 1
src/app/(main)/expense/page.tsx Переглянути файл

@@ -1,15 +1,16 @@
import { preloadExpense } from "@/app/api/expenses"
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 () => {
preloadExpense()
const { t } = await getServerI18n("expense")

return(

<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}


+ 28
- 0
src/app/api/expenses/index.ts Переглянути файл

@@ -0,0 +1,28 @@
"use server";
import { cache } from "react";
import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";

export type ExpensesResult = {
id: number
projectCode: string
projectName: string
staffCode: string
staffName: string
description: string
amount: number
approvedAmount: number
verifiedDatetime: number[]
remark: string
}

export const preloadExpense = () => {
fetchExpenses()
};
export const fetchExpenses = cache(async () => {
return serverFetchJson<ExpensesResult[]>(`${BASE_API_URL}/expense`, {
next: { tags: ["expense"] },
});
});

+ 101
- 400
src/components/ExpenseSearch/ExpenseSearch.tsx Переглянути файл

@@ -1,443 +1,144 @@
"use client";

import React, { useCallback, useMemo, useState } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { ExpensesResult } from "@/app/api/expenses";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import SearchBox, { Criterion } from "../SearchBox";
import SearchResults, { Column } from "../SearchResults";
import { moneyFormatter } from "@/app/utils/formatUtil"
import {
Button, ButtonGroup, Stack,
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions,
CardContent, Typography, Divider, Card
import { useRouter } from "next/navigation";
import {
Button,
ButtonGroup,
Card,
CardContent,
Divider,
Grid,
Stack,
Typography,
} from "@mui/material";
import { moneyFormatter } from "@/app/utils/formatUtil";
import { EditNote } from "@mui/icons-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[];
expenses: ExpensesResult[]
}

type InvoiceListError = {
[field in keyof invoiceList]?: string;
};

type invoiceListRow = Partial<
invoiceList & {
_isNew: boolean;
_error: InvoiceListError;
}
>;

type SearchQuery = Partial<Omit<issuedInvoiceSearchForm, "id">>;
type SearchQuery = Partial<Omit<ExpensesResult, "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 ExpenseSearch: React.FC<Props> = ({ expenses }) => {
console.log(expenses)
const router = useRouter();
const { t } = useTranslation("expenses");
const [filteredExpenses, setFilteredExpenses] = useState(expenses);

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{ label: t("Invoice No"), paramName: "invoiceNo", type: "text" },
// { label: t("Expense No"), paramName: "ExpenseNo", type: "text" },
{ label: t("Project Code"), paramName: "projectCode", type: "text" },
{ label: t("Project Name"), paramName: "projectName", type: "text" },
{
label: t("Team"),
paramName: "team",
type: "select",
options: uniq(invoices.map((invoice) => invoice.teamCodeName)),
label: t("Verified Date"),
label2: t("Verified Date To"),
paramName: "verifiedDatetime",
type: "dateRange",
},
{ 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 onExpenseClick = useCallback(
(expenses?: ExpensesResult) => {},
[router]
);

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>[]>(
const columns = useMemo<Column<ExpensesResult>[]>(
() => [
{
name: "invoiceNo",
label: t("Edit"),
onClick: (row: invoiceList) => (
handleButtonClick(row)
),
buttonIcon: <EditOutlinedIcon />
{
name: "id",
label: t("Details"),
onClick: onExpenseClick,
buttonIcon: <EditNote />,
// disabled: !abilities.includes(MAINTAIN_PROJECT),
},
{ 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'
},
{ name: "verifiedDatetime", label: t("verifiedDatetime") },
],
[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],
[t, onExpenseClick]
);
const onReset = useCallback(() => {
// setFilteredExpenses();
}, []);

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}
/>
}
<Stack
direction="row"
justifyContent="right"
flexWrap="wrap"
spacing={2}
>
<Grid xs={12} justifyContent="right">
<ButtonGroup variant="contained">
<Button
startIcon={<AddIcon />}
variant="outlined"
component="label"
onClick={() => console.log()}
>
{t("Create expense")}
</Button>
</ButtonGroup>
</Grid>
</Stack>
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
// setFilteredExpenses(
// projects.filter(
// (p) =>
// p.code.toLowerCase().includes(query.code.toLowerCase()) &&
// p.name.toLowerCase().includes(query.name.toLowerCase()) &&
// (query.client === "All" || p.client === query.client) &&
// (query.category === "All" || p.category === query.category) &&
// // (query.team === "All" || p.team === query.team) &&
// (query.team === "All" || query.team.toLowerCase().includes(p.team.toLowerCase())) &&
// (query.status === "All" || p.status === query.status),
// ),
// );
}}
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>
<Stack direction="row" justifyContent="space-between">
<Typography variant="h6">
{t("Total Issued Amount (HKD)")}:
</Typography>
<Typography variant="h6">
{/* {moneyFormatter.format(filteredExpenses.reduce((acc, curr) => (acc + curr.issuedAmount), 0))} */}
</Typography>
</Stack>
<Stack direction="row" justifyContent="space-between">
<Typography variant="h6">
{t("Total Received Amount (HKD)")}:
</Typography>
<Typography variant="h6">
{/* {moneyFormatter.format(filteredExpenses.reduce((acc, curr) => (acc + curr.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;
}}
<SearchResults<ExpensesResult>
items={filteredExpenses}
columns={columns}
/>
</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}
/>
</>
</Stack>
);
};

export default ExpenseSearch;

+ 19
- 4
src/components/ExpenseSearch/ExpenseSearchWrapper.tsx Переглянути файл

@@ -1,18 +1,33 @@
import React from "react";
import ExpenseSearch from "./ExpenseSearch";
import ExpenseSearchLoading from "./ExpenseSearchLoading";
import { fetchExpenses } from "@/app/api/expenses";
import dayjs from "dayjs";
import arraySupport from "dayjs/plugin/arraySupport";
import { OUTPUT_DATE_FORMAT, OUTPUT_TIME_FORMAT } from "@/app/utils/formatUtil";


interface SubComponents {
Loading: typeof ExpenseSearchLoading;
}
dayjs.extend(arraySupport)

const ExpenseSearchWrapper: React.FC & SubComponents = async () => {


const [
Expenses
] = await Promise.all([
fetchExpenses(),
]);
const _expenses = Expenses.map((e) => {
const date: number[] = e.verifiedDatetime as number[];
const formattedDate = dayjs([date[0], date[1], date[2]].join()).format(OUTPUT_DATE_FORMAT)
return ({
...e,
verifiedDatetime: formattedDate
})
})
return <ExpenseSearch
invoices={[]}
projects={[]}
expenses={_expenses}
/>
};


+ 2
- 5
src/components/InvoiceSearch/InvoiceTable.tsx Переглянути файл

@@ -58,11 +58,8 @@ type project = {
value: number;
}
const InvoiceTable: React.FC<Props> = ({ projects }) => {
console.log(projects)
// const projectCombos: project[] = projects.map(item => ({
// value: item.id,
// label: item.code
// }))
// if change this to code - name,
// also change the submit function
const projectCombos = projects.map(item => item.code)
const { t } = useTranslation()
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});


Завантаження…
Відмінити
Зберегти