瀏覽代碼

update expense page

tags/Baseline_180220205_Frontend
MSI\derek 11 月之前
父節點
當前提交
72297f4983
共有 9 個檔案被更改,包括 236 行新增139 行删除
  1. +2
    -2
      src/app/(main)/expense/page.tsx
  2. +0
    -28
      src/app/api/expenses/index.ts
  3. +25
    -0
      src/app/api/projectExpenses/actions.ts
  4. +31
    -0
      src/app/api/projectExpenses/index.ts
  5. +27
    -7
      src/components/ExpenseSearch/CreateExpenseModal.tsx
  6. +40
    -17
      src/components/ExpenseSearch/ExpenseSearch.tsx
  7. +20
    -7
      src/components/ExpenseSearch/ExpenseSearchWrapper.tsx
  8. +82
    -69
      src/components/ExpenseSearch/ExpenseTable.tsx
  9. +9
    -9
      src/components/InvoiceSearch/InvoiceSearchWrapper.tsx

+ 2
- 2
src/app/(main)/expense/page.tsx 查看文件

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

return(


+ 0
- 28
src/app/api/expenses/index.ts 查看文件

@@ -1,28 +0,0 @@
"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"] },
});
});

+ 25
- 0
src/app/api/projectExpenses/actions.ts 查看文件

@@ -0,0 +1,25 @@
"use server"

import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { cache } from "react";
import { revalidateTag } from "next/cache";


export type CreateNewExpense = {
expenseNo: string | undefined,
projectCode: string | undefined,
issueDate: Date
issuedAmount: number,
receiptDate: Date
receivedAmount: number
}
export type PostExpenseData = {
expenseNo?: string
projectId: number
projectCode: string,
amount: number
issueDate: string
receiptDate?: string
}

+ 31
- 0
src/app/api/projectExpenses/index.ts 查看文件

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

export type ProjectExpensesResult = {
id: number
expenseNo?: string
projectCode: string
projectName: string
teamCode: string
teamName: string
amount: number
issueDate: number[]
receiptDate: number[]
}
export type ProjectExpensesResultFormatted = Omit<ProjectExpensesResult, 'issueDate' | 'receiptDate'> & {
issueDate: string;
receiptDate: string;
};

export const preloadProjectExpenses = () => {
fetchProjectExpenses()
};
export const fetchProjectExpenses = cache(async () => {
return serverFetchJson<ProjectExpensesResult[]>(`${BASE_API_URL}/project-expense`, {
next: { tags: ["projectExpenses"] },
});
});

+ 27
- 7
src/components/ExpenseSearch/CreateExpenseModal.tsx 查看文件

@@ -12,8 +12,11 @@ import {
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';
import { CreateNewExpense, PostExpenseData } from '@/app/api/projectExpenses/actions';
import ExpenseTable from './ExpenseTable';
import dayjs from 'dayjs';
import { INPUT_DATE_FORMAT } from '@/app/utils/formatUtil';

interface Props {
isOpen: boolean,
@@ -32,13 +35,30 @@ const modalSx: SxProps= {
bgcolor: 'background.paper',
};

const CreateInvoiceModal: React.FC<Props> = ({isOpen, onClose, projects}) => {
type postData = {
data: PostExpenseData[]
}
const CreateExpenseModal: React.FC<Props> = ({isOpen, onClose, projects}) => {
const { t } = useTranslation()
const formProps = useForm<any>();
const formProps = useForm<postData>();

const onSubmit = useCallback<SubmitHandler<any>>(
const onSubmit = useCallback<SubmitHandler<postData>>(
(data) => {
console.log(data)
const _data = data.data
console.log(_data[0])
console.log(_data[0].issueDate)
console.log(_data[1].issueDate)
const postData: PostExpenseData[] = _data.map(item => {
console.log(item.issueDate)
return ({
expenseNo: item.expenseNo,
issueDate: dayjs(item.issueDate).format(INPUT_DATE_FORMAT),
amount: item.amount,
projectId: projects.find(p => p.code === item.projectCode)!.id,
projectCode: item.projectCode,
})}
)
console.log(postData)
}
, [])

@@ -63,7 +83,7 @@ const CreateInvoiceModal: React.FC<Props> = ({isOpen, onClose, projects}) => {
marginBlock: 2,
}}
>
<InvoiceTable projects={projects}/>
<ExpenseTable projects={projects}/>
</Box>
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button
@@ -84,4 +104,4 @@ const CreateInvoiceModal: React.FC<Props> = ({isOpen, onClose, projects}) => {
);
};

export default CreateInvoiceModal;
export default CreateExpenseModal;

+ 40
- 17
src/components/ExpenseSearch/ExpenseSearch.tsx 查看文件

@@ -1,6 +1,6 @@
"use client";
import { ExpensesResult } from "@/app/api/expenses";
import { useCallback, useMemo, useState } from "react";
import { ProjectExpensesResult, ProjectExpensesResultFormatted } from "@/app/api/projectExpenses";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import SearchBox, { Criterion } from "../SearchBox";
import SearchResults, { Column } from "../SearchResults";
@@ -18,41 +18,55 @@ import {
import { moneyFormatter } from "@/app/utils/formatUtil";
import { EditNote } from "@mui/icons-material";
import AddIcon from '@mui/icons-material/Add';
import { uniq } from "lodash";
import CreateExpenseModal from "./CreateExpenseModal";
import { ProjectResult } from "@/app/api/projects";

interface Props {
expenses: ExpensesResult[]
expenses: ProjectExpensesResultFormatted[]
projects: ProjectResult[];
}

type SearchQuery = Partial<Omit<ExpensesResult, "id">>;
type SearchQuery = Partial<Omit<ProjectExpensesResultFormatted, "id">>;
type SearchParamNames = keyof SearchQuery;

const ExpenseSearch: React.FC<Props> = ({ expenses }) => {
console.log(expenses)
type Modals = {
createInvoiceModal: boolean
}
const initState: Modals = {
createInvoiceModal: false,
}

const ExpenseSearch: React.FC<Props> = ({ expenses, projects }) => {
const router = useRouter();
const { t } = useTranslation("expenses");
const [filteredExpenses, setFilteredExpenses] = useState(expenses);
const [modalsOpen, setModalsOpen] = useState(initState)
const toggleModals = useCallback((key: keyof Modals) => {
setModalsOpen((prev) => ({...prev, [key]: !prev[key]}))
}, [modalsOpen]);

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
// { 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("Verified Date"),
label2: t("Verified Date To"),
paramName: "verifiedDatetime",
type: "dateRange",
},
// {
// label: t("Team"),
// paramName: "team",
// type: "select",
// options: uniq(expenses.map((expenses) => expenses.teamCode)),
// },
],
[]
);

const onExpenseClick = useCallback(
(expenses?: ExpensesResult) => {},
(expenses?: ProjectExpensesResultFormatted) => {},
[router]
);

const columns = useMemo<Column<ExpensesResult>[]>(
const columns = useMemo<Column<ProjectExpensesResultFormatted>[]>(
() => [
{
name: "id",
@@ -63,7 +77,9 @@ const ExpenseSearch: React.FC<Props> = ({ expenses }) => {
},
{ name: "projectCode", label: t("Project Code") },
{ name: "projectName", label: t("Project Name") },
{ name: "verifiedDatetime", label: t("verifiedDatetime") },
{ name: "amount", label: t("Amount") },
{ name: "teamCode", label: t("Team") },
{ name: "issueDate", label: t("Issue Date") },
],
[t, onExpenseClick]
);
@@ -72,6 +88,7 @@ const ExpenseSearch: React.FC<Props> = ({ expenses }) => {
}, []);

return (
<>
<Stack
spacing={2}
>
@@ -87,7 +104,7 @@ const ExpenseSearch: React.FC<Props> = ({ expenses }) => {
startIcon={<AddIcon />}
variant="outlined"
component="label"
onClick={() => console.log()}
onClick={() => toggleModals("createInvoiceModal")}
>
{t("Create expense")}
</Button>
@@ -134,11 +151,17 @@ const ExpenseSearch: React.FC<Props> = ({ expenses }) => {
</CardContent>
</Card>
<Divider sx={{ paddingBlockEnd: 2 }} />
<SearchResults<ExpensesResult>
<SearchResults<ProjectExpensesResultFormatted>
items={filteredExpenses}
columns={columns}
/>
</Stack>
<CreateExpenseModal
isOpen={modalsOpen.createInvoiceModal}
onClose={() => toggleModals("createInvoiceModal")}
projects={projects}
/>
</>
);
};
export default ExpenseSearch;

+ 20
- 7
src/components/ExpenseSearch/ExpenseSearchWrapper.tsx 查看文件

@@ -1,10 +1,12 @@
import React from "react";
import ExpenseSearch from "./ExpenseSearch";
import ExpenseSearchLoading from "./ExpenseSearchLoading";
import { fetchExpenses } from "@/app/api/expenses";
import { fetchProjectExpenses } from "@/app/api/projectExpenses";
import dayjs from "dayjs";
import arraySupport from "dayjs/plugin/arraySupport";
import { OUTPUT_DATE_FORMAT, OUTPUT_TIME_FORMAT } from "@/app/utils/formatUtil";
import { fetchUserStaff } from "@/app/utils/fetchUtil";
import { fetchProjects } from "@/app/api/projects";


interface SubComponents {
@@ -14,20 +16,31 @@ dayjs.extend(arraySupport)

const ExpenseSearchWrapper: React.FC & SubComponents = async () => {
const [
Expenses
expenses,
projects
] = await Promise.all([
fetchExpenses(),
fetchProjectExpenses(),
fetchProjects(),
]);
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)
// const userStaff = await fetchUserStaff()
// const teamId = userStaff?.teamId
// const _projects = projects

console.log(expenses)
const _expenses = expenses.map((e) => {
const issueDate = e.issueDate;
const receiptDate = e.receiptDate;
const formattedIssueDate = dayjs([issueDate[0], issueDate[1], issueDate[2]].join()).format(OUTPUT_DATE_FORMAT)
const formattedReceiptDate = dayjs([receiptDate[0], receiptDate[1], receiptDate[2]].join()).format(OUTPUT_DATE_FORMAT)
return ({
...e,
verifiedDatetime: formattedDate
issueDate: formattedIssueDate,
receiptDate: formattedReceiptDate
})
})
return <ExpenseSearch
expenses={_expenses}
projects={projects}
/>
};


+ 82
- 69
src/components/ExpenseSearch/ExpenseTable.tsx 查看文件

@@ -7,8 +7,6 @@ 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 {
@@ -19,7 +17,8 @@ import {
GridRowModel,
GridRowModes,
GridRowModesModel,
GridRenderEditCellParams,
GridRenderEditCellParams,
useGridApiContext,
} from "@mui/x-data-grid";
import { useGridApiRef } from "@mui/x-data-grid";
import StyledDataGrid from "../StyledDataGrid";
@@ -32,15 +31,17 @@ 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";
import { ProjectExpensesResultFormatted } from "@/app/api/projectExpenses";
import { GridRenderCellParams } from "@mui/x-data-grid";

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

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

@@ -49,12 +50,12 @@ interface Props {
}

class ProcessRowUpdateError extends Error {
public readonly row: invoiceListRow;
public readonly errors: InvoiceListError | undefined;
public readonly row: ExpenseListRow;
public readonly errors: ExpenseListError | undefined;
constructor(
row: invoiceListRow,
row: ExpenseListRow,
message?: string,
errors?: InvoiceListError,
errors?: ExpenseListError,
) {
super(message);
this.row = row;
@@ -67,34 +68,25 @@ type project = {
label: string;
value: number;
}
const InvoiceTable: React.FC<Props> = ({ projects }) => {
const ExpenseTable: React.FC<Props> = ({ projects }) => {
console.log(projects)
const projectCombos = projects.map(item => item.code)
const { t } = useTranslation()
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
const [selectedRow, setSelectedRow] = useState<invoiceListRow[] | []>([]);
const [selectedRow, setSelectedRow] = useState<ExpenseListRow[] | []>([]);
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 => {
const validateExpenseEntry = (
entry: Partial<ProjectExpensesResultFormatted>,
): ExpenseListError | 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){
}
const error: ExpenseListError = {};
if (!entry.issueDate) error.issueDate = "Please input issued date";
if (!entry.amount) error.amount = "Please input amount";
if (!entry.projectCode) error.projectCode = "Please input project code";

console.log(error)
return Object.keys(error).length > 0 ? error : undefined;
}

@@ -105,8 +97,8 @@ const InvoiceTable: React.FC<Props> = ({ projects }) => {
"",
)
const error = validateInvoiceEntry(row);
// console.log(error)
const error = validateExpenseEntry(row);
console.log(error)
// Test for warnings
// apiRef.current.updateRows([{ id, _error: error }]);
@@ -159,8 +151,8 @@ const InvoiceTable: React.FC<Props> = ({ projects }) => {

const processRowUpdate = useCallback(
(
newRow: GridRowModel<invoiceListRow>,
originalRow: GridRowModel<invoiceListRow>,
newRow: GridRowModel<ExpenseListRow>,
originalRow: GridRowModel<ExpenseListRow>,
) => {
const errors = validateRow(newRow.id!!);
if (errors) {
@@ -178,7 +170,7 @@ const InvoiceTable: React.FC<Props> = ({ projects }) => {
const rowToSave = {
...updatedRow,
} satisfies invoiceListRow;
} satisfies ExpenseListRow;

console.log(newRow)

@@ -198,7 +190,7 @@ const InvoiceTable: React.FC<Props> = ({ projects }) => {
(updateError: ProcessRowUpdateError) => {
const errors = updateError.errors;
const oldRow = updateError.row;
// console.log(errors)
console.log(errors)
apiRef.current.updateRows([{ ...oldRow, _error: errors }]);
},
[apiRef]
@@ -209,38 +201,64 @@ const InvoiceTable: React.FC<Props> = ({ projects }) => {
setValue("data", selectedRow)
}, [selectedRow, setValue]);

function renderAutocomplete(params: GridRenderCellParams<any, number>) {
return(
<Box sx={{ display: 'flex', alignItems: 'center', pr: 2 }}>
<Autocomplete
readOnly
sx={{ width: 300 }}
value={params.row.projectCode}
options={projectCombos}
renderInput={(params) => <TextField {...params} />}
/>
</Box>
)
}

function AutocompleteInput(props: GridRenderCellParams<any, number>) {
const { id, value, field, hasFocus } = props;
const apiRef = useGridApiContext();
const ref = React.useRef<HTMLElement>(null);
const handleValueChange = useCallback((newValue: any) => {
console.log(newValue)
apiRef.current.setEditCellValue({ id, field, value: newValue })
}, []);

return (
<Box sx={{ display: 'flex', alignItems: 'center', pr: 2 }}>
<Autocomplete
disablePortal
options={projectCombos}
sx={{ width: 300 }}
onChange={(event: React.SyntheticEvent<Element, Event>, value: string | null, ) => handleValueChange(value)}
renderInput={(params) => <TextField {...params} />}
/>
</Box>
);
}

const renderAutocompleteInput: GridColDef['renderCell'] = (params) => {
return <AutocompleteInput {...params} />;
};

const editCombinedColumns = useMemo<GridColDef[]>(
() => [
{ field: "invoiceNo", headerName: t("Invoice No"), editable: true, flex: 0.5 },
{ field: "expenseNo", headerName: t("Expense 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} />}
/>

)
}
renderCell: renderAutocomplete,
renderEditCell: renderAutocompleteInput
},
{ field: "issuedDate",
{ field: "issueDate",
headerName: t("Issue Date"),
editable: true,
flex: 0.4,
// type: 'date',
// valueGetter: (params) => {
// // console.log(params.row.issuedDate)
// return new Date(params.row.issuedDate)
// },
type: 'date',
},
{ field: "issuedAmount",
{ field: "amount",
headerName: t("Amount (HKD)"),
editable: true,
flex: 0.5,
@@ -251,13 +269,8 @@ const editCombinedColumns = useMemo<GridColDef[]>(
headerName: t("Settle Date"),
editable: true,
flex: 0.4,
},
{ field: "receivedAmount",
headerName: t("Actual Received Amount (HKD)"),
editable: true,
flex: 0.5,
type: 'number'
},
type: 'date',
},
],
[t]
)
@@ -271,7 +284,7 @@ const footer = (
onClick={addRow}
size="small"
>
{t("Create Invoice")}
{t("Create Expense")}
</Button>
</Box>
);
@@ -301,9 +314,9 @@ const footer = (
columns={editCombinedColumns}
processRowUpdate={processRowUpdate}
onProcessRowUpdateError={onProcessRowUpdateError}
getCellClassName={(params: GridCellParams<invoiceListRow>) => {
getCellClassName={(params: GridCellParams<ExpenseListRow>) => {
let classname = "";
if (params.row._error?.[params.field as keyof invoiceList]) {
if (params.row._error?.[params.field as keyof ProjectExpensesResultFormatted]) {
classname = "hasError";
}
return classname;
@@ -320,7 +333,7 @@ const footer = (
)
}

export default InvoiceTable
export default ExpenseTable

const NoRowsOverlay: React.FC = () => {
const { t } = useTranslation("home");


+ 9
- 9
src/components/InvoiceSearch/InvoiceSearchWrapper.tsx 查看文件

@@ -19,18 +19,18 @@ interface SubComponents {
// }

const InvoiceSearchWrapper: React.FC & SubComponents = async () => {
const userStaff = await fetchUserStaff()
const teamId = userStaff?.teamId
// const userStaff = await fetchUserStaff()
// const teamId = userStaff?.teamId
const invoices = await fetchInvoicesV3()
const projects = await fetchProjects()
const filteredProjects = projects.filter(project => project.teamId === teamId)
// const filteredProjects = projects.filter(project => project.teamId === teamId)

let filteredInvoice = invoices
if (teamId) {
filteredInvoice = invoices.filter(invoice => invoice.teamId === teamId)
}
// let filteredInvoice = invoices
// if (teamId) {
// filteredInvoice = invoices.filter(invoice => invoice.teamId === teamId)
// }

const convertedInvoices = filteredInvoice.map((invoice)=>{
const convertedInvoices = invoices.map((invoice)=>{
return{
id: invoice.id,
invoiceNo: invoice.invoiceNo,
@@ -47,7 +47,7 @@ const InvoiceSearchWrapper: React.FC & SubComponents = async () => {

return <InvoiceSearch
invoices={convertedInvoices}
projects={filteredProjects}
projects={projects}
/>
};


Loading…
取消
儲存