From 76d46816efa6a8f35407df2d92c2d36a1d8d0932 Mon Sep 17 00:00:00 2001 From: "MSI\\2Fi" Date: Fri, 19 Apr 2024 18:25:53 +0800 Subject: [PATCH] Add Invoice Search Page Update Salary number format Add remainign filed in Company Create page --- src/app/(main)/invoice/page.tsx | 5 + src/app/(main)/settings/salary/page.tsx | 15 +-- src/app/api/companys/actions.ts | 27 ++-- src/app/api/invoices/actions.ts | 35 ++++++ src/app/api/invoices/index.ts | 26 ++++ src/app/api/salarys/index.ts | 5 +- src/app/utils/formatUtil.ts | 5 + .../CreateCompany/CompanyDetails.tsx | 117 +++++++++++++++++- .../CreateCompany/CreateCompany.tsx | 15 ++- .../InvoiceSearch/InvoiceSearch.tsx | 91 ++++++++++++++ .../InvoiceSearch/InvoiceSearchLoading.tsx | 40 ++++++ .../InvoiceSearch/InvoiceSearchWrapper.tsx | 21 ++++ src/components/InvoiceSearch/index.ts | 1 + src/components/SalarySearch/SalarySearch.tsx | 6 +- .../SalarySearch/SalarySearchWrapper.tsx | 17 ++- src/i18n/en/salarys.json | 7 ++ src/i18n/zh/salarys.json | 7 ++ 17 files changed, 409 insertions(+), 31 deletions(-) create mode 100644 src/app/api/invoices/actions.ts create mode 100644 src/app/api/invoices/index.ts create mode 100644 src/components/InvoiceSearch/InvoiceSearch.tsx create mode 100644 src/components/InvoiceSearch/InvoiceSearchLoading.tsx create mode 100644 src/components/InvoiceSearch/InvoiceSearchWrapper.tsx create mode 100644 src/components/InvoiceSearch/index.ts create mode 100644 src/i18n/en/salarys.json create mode 100644 src/i18n/zh/salarys.json diff --git a/src/app/(main)/invoice/page.tsx b/src/app/(main)/invoice/page.tsx index 1806d2e..d9d18bf 100644 --- a/src/app/(main)/invoice/page.tsx +++ b/src/app/(main)/invoice/page.tsx @@ -5,6 +5,8 @@ import Button from "@mui/material/Button"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Link from "next/link"; +import { Suspense } from "react"; +import InvoiceSearch from "@/components/InvoiceSearch"; export const metadata: Metadata = { title: "Invoice", @@ -33,6 +35,9 @@ const Invoice: React.FC = async () => { {t("Create Invoice")} + }> + + ) }; diff --git a/src/app/(main)/settings/salary/page.tsx b/src/app/(main)/settings/salary/page.tsx index d1c1d63..2531757 100644 --- a/src/app/(main)/settings/salary/page.tsx +++ b/src/app/(main)/settings/salary/page.tsx @@ -1,6 +1,6 @@ import SalarySearch from "@/components/SalarySearch"; import { Metadata } from "next"; -import { getServerI18n } from "@/i18n"; +import { I18nProvider, getServerI18n } from "@/i18n"; import Add from "@mui/icons-material/Add"; import Button from "@mui/material/Button"; import Stack from "@mui/material/Stack"; @@ -15,7 +15,6 @@ export const metadata: Metadata = { const Salary: React.FC = async () => { const { t } = await getServerI18n("Salary"); - // Preload necessary dependencies // fetchSalarys(); // preloadSalarys(); @@ -31,18 +30,20 @@ const Salary: React.FC = async () => { {t("Salary")} - + */} - }> - - + + }> + + + ) }; diff --git a/src/app/api/companys/actions.ts b/src/app/api/companys/actions.ts index b0e6db0..f342177 100644 --- a/src/app/api/companys/actions.ts +++ b/src/app/api/companys/actions.ts @@ -2,6 +2,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; +import { Dayjs } from "dayjs"; import { cache } from "react"; export interface comboProp { @@ -14,19 +15,19 @@ export interface combo { } export interface CreateCompanyInputs { - companyCode: String; - companyName: String; - brNo: String; - contactName: String; - phone: String; - otHourTo: String; - otHourFrom: String; - normalHourTo: String; - normalHourFrom: String; - currency: String; - address: String; - distract: String; - email: String; + companyCode: string; + companyName: string; + brNo: string; + contactName: string; + phone: string; + otHourTo: string; + otHourFrom: string; + normalHourTo: string; + normalHourFrom: string; + currency: string; + address: string; + district: string; + email: string; } export const saveCompany = async (data: CreateCompanyInputs) => { diff --git a/src/app/api/invoices/actions.ts b/src/app/api/invoices/actions.ts new file mode 100644 index 0000000..c6bdfd2 --- /dev/null +++ b/src/app/api/invoices/actions.ts @@ -0,0 +1,35 @@ +"use server" + +import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { cache } from "react"; + + +export interface comboProp { + id: any; + label: string; +} + +export interface combo { + records: comboProp[]; +} +export interface CreateDepartmentInputs { + departmentCode: string; + departmentName: string; + description: string; +} + +export const saveDepartment = async (data: CreateDepartmentInputs) => { + return serverFetchJson(`${BASE_API_URL}/departments/new`, { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); + }; + + +export const fetchDepartmentCombo = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/departments/combo`, { + next: { tags: ["department"] }, + }); +}); \ No newline at end of file diff --git a/src/app/api/invoices/index.ts b/src/app/api/invoices/index.ts new file mode 100644 index 0000000..7c6fb55 --- /dev/null +++ b/src/app/api/invoices/index.ts @@ -0,0 +1,26 @@ +import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { cache } from "react"; +import "server-only"; + +export interface InvoiceResult { + id: number; + projectCode: string; + projectName: string; + stage: String; + comingPaymentMileStone: String; + paymentMilestoneDate: String; + resourceUsage: number; + unbilledHours: number; + reminder: String; +} + +export const preloadInvoices = () => { + fetchInvoices(); +}; + +export const fetchInvoices = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/invoices`, { + next: { tags: ["invoices"] }, + }); +}); \ No newline at end of file diff --git a/src/app/api/salarys/index.ts b/src/app/api/salarys/index.ts index 02fcf55..baed583 100644 --- a/src/app/api/salarys/index.ts +++ b/src/app/api/salarys/index.ts @@ -5,10 +5,11 @@ import "server-only"; export interface SalaryResult { id: number; - lowerLimit: number; - upperLimit: number; + lowerLimit: String; + upperLimit: String; salaryPoint: number; salary: number; + hourlyRate: String; } export const preloadSalarys = () => { diff --git a/src/app/utils/formatUtil.ts b/src/app/utils/formatUtil.ts index 616d205..14d2ed1 100644 --- a/src/app/utils/formatUtil.ts +++ b/src/app/utils/formatUtil.ts @@ -38,3 +38,8 @@ export const shortDateFormatter = (locale?: string) => { return shortDateFormatter_en; } }; + +export function convertLocaleStringToNumber(numberString: String): number { + const numberWithoutCommas = numberString.replace(/,/g, ""); + return parseFloat(numberWithoutCommas); +} diff --git a/src/components/CreateCompany/CompanyDetails.tsx b/src/components/CreateCompany/CompanyDetails.tsx index f3268c8..ccef906 100644 --- a/src/components/CreateCompany/CompanyDetails.tsx +++ b/src/components/CreateCompany/CompanyDetails.tsx @@ -15,8 +15,10 @@ import { useTranslation } from "react-i18next"; import CardActions from "@mui/material/CardActions"; import RestartAlt from "@mui/icons-material/RestartAlt"; import Button from "@mui/material/Button"; -import { Controller, useFormContext } from "react-hook-form"; +import { Controller, UseFormRegister, useFormContext } from "react-hook-form"; import { CreateCompanyInputs } from "@/app/api/companys/actions"; +import { TimePicker } from "@mui/x-date-pickers"; +import dayjs from 'dayjs'; const CompanyDetails: React.FC = ({ }) => { @@ -25,8 +27,11 @@ const CompanyDetails: React.FC = ({ register, formState: { errors }, control, + setValue, + getValues, } = useFormContext(); + return ( @@ -95,6 +100,116 @@ const CompanyDetails: React.FC = ({ error={Boolean(errors.email)} /> + + { + return ( + { + const formattedTime = time ? dayjs(time as string).format('HH:mm:ss') : ''; + field.onChange(formattedTime); + }} + sx={{ width: '100%' }} + /> + ); + }} + /> + + + { + return ( + { + const formattedTime = time ? dayjs(time as string).format('HH:mm:ss') : ''; + field.onChange(formattedTime); + }} + sx={{ width: '100%' }} + /> + ); + }} + /> + + + { + return ( + { + const formattedTime = time ? dayjs(time as string).format('HH:mm:ss') : ''; + field.onChange(formattedTime); + }} + sx={{ width: '100%' }} + /> + ); + }} + /> + + + { + return ( + { + const formattedTime = time ? dayjs(time as string).format('HH:mm:ss') : ''; + field.onChange(formattedTime); + }} + sx={{ width: '100%' }} + /> + ); + }} + /> + + + + + + + + + + {/* diff --git a/src/components/CreateCompany/CreateCompany.tsx b/src/components/CreateCompany/CreateCompany.tsx index 4394f51..522f307 100644 --- a/src/components/CreateCompany/CreateCompany.tsx +++ b/src/components/CreateCompany/CreateCompany.tsx @@ -16,6 +16,9 @@ import { useForm, } from "react-hook-form"; import CompanyDetails from "./CompanyDetails"; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' +import dayjs from "dayjs"; const CreateCompany: React.FC = ({ @@ -35,7 +38,7 @@ const CreateCompany: React.FC = ({ setServerError(""); // console.log(JSON.stringify(data)); await saveCompany(data) - router.replace("/settings/companys"); + router.replace("/settings/company"); } catch (e) { setServerError(t("An error has occurred. Please try again later.")); } @@ -59,11 +62,11 @@ const CreateCompany: React.FC = ({ phone: "", otHourTo: "", otHourFrom: "", - normalHourTo: "", - normalHourFrom: "", + normalHourTo: dayjs().format('HH:mm:ss'), + normalHourFrom: dayjs().format('HH:mm:ss'), currency: "", address: "", - distract: "", + district: "", email: "", }, }); @@ -78,7 +81,9 @@ const CreateCompany: React.FC = ({ onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} > { - + + + } diff --git a/src/components/InvoiceSearch/InvoiceSearch.tsx b/src/components/InvoiceSearch/InvoiceSearch.tsx new file mode 100644 index 0000000..56b6204 --- /dev/null +++ b/src/components/InvoiceSearch/InvoiceSearch.tsx @@ -0,0 +1,91 @@ +"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 EditNote from "@mui/icons-material/EditNote"; +import { InvoiceResult } from "@/app/api/invoices"; + +interface Props { + invoices: InvoiceResult[]; +} + +type SearchQuery = Partial>; +type SearchParamNames = keyof SearchQuery; + +const InvoiceSearch: React.FC = ({ invoices }) => { + const { t } = useTranslation("invoices"); + + const [filteredInvoices, setFilteredInvoices] = useState(invoices); + + const searchCriteria: Criterion[] = useMemo( + () => [ + { label: t("Project code"), paramName: "projectCode", type: "text" }, + { label: t("Project name"), paramName: "projectName", type: "text" }, + // { label: t("Stage"), paramName: "stage", type: "text" }, + { label: t("Coming payment milestone"), paramName: "comingPaymentMileStone", type: "text" }, + { label: t("Payment date"), paramName: "paymentMilestoneDate", type: "text" }, + // { label: t("Resource utilization %"), paramName: "resourceUsage", type: "text" }, + // { label: t("Unbilled hours"), paramName: "unbilledHours", type: "text" }, + // { label: t("Reminder to issue invoice"), paramName: "reminder", type: "text" }, + ], + [t, invoices], + ); + + const onReset = useCallback(() => { + setFilteredInvoices(invoices); + }, [invoices]); + + const onProjectClick = useCallback((project: InvoiceResult) => { + console.log(project); + }, []); + + const columns = useMemo[]>( + () => [ + { + name: "id", + label: t("Details"), + onClick: onProjectClick, + buttonIcon: , + }, + { name: "projectCode", label: t("Project code") }, + { name: "projectName", label: t("Project name") }, + { name: "stage", label: t("Stage") }, + { name: "comingPaymentMileStone", label: t("Coming payment milestone") }, + { name: "paymentMilestoneDate", label: t("Payment date") }, + { name: "resourceUsage", label: t("Resource utilization %") }, + { name: "unbilledHours", label: t("Unbilled hours") }, + { name: "reminder", label: t("Reminder to issue invoice") }, + ], + [t, onProjectClick], + ); + + return ( + <> + { + setFilteredInvoices( + invoices.filter( + (d) => + d.projectCode.toLowerCase().includes(query.projectCode.toLowerCase()) && + d.projectName.toLowerCase().includes(query.projectName.toLowerCase()) && + d.stage.toLowerCase().includes(query.stage.toLowerCase()) && + {/*(query.client === "All" || p.client === query.client) && + (query.category === "All" || p.category === query.category) && + (query.team === "All" || p.team === query.team), **/} + ), + ); + }} + onReset={onReset} + /> + + items={filteredInvoices} + columns={columns} + /> + + ); +}; + +export default InvoiceSearch; diff --git a/src/components/InvoiceSearch/InvoiceSearchLoading.tsx b/src/components/InvoiceSearch/InvoiceSearchLoading.tsx new file mode 100644 index 0000000..927c6c6 --- /dev/null +++ b/src/components/InvoiceSearch/InvoiceSearchLoading.tsx @@ -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 InvoiceSearchLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + Invoice + + + + + + + + + + + ); +}; + +export default InvoiceSearchLoading; diff --git a/src/components/InvoiceSearch/InvoiceSearchWrapper.tsx b/src/components/InvoiceSearch/InvoiceSearchWrapper.tsx new file mode 100644 index 0000000..4312fb9 --- /dev/null +++ b/src/components/InvoiceSearch/InvoiceSearchWrapper.tsx @@ -0,0 +1,21 @@ + +import React from "react"; +import InvoiceSearch from "./InvoiceSearch"; +import InvoiceSearchLoading from "./InvoiceSearchLoading"; +// For Later use +import { fetchInvoices } from "@/app/api/invoices"; + +interface SubComponents { + Loading: typeof InvoiceSearchLoading; +} + +const InvoiceSearchWrapper: React.FC & SubComponents = async () => { + // For Later use + const Invoices = await fetchInvoices(); + + return ; +}; + +InvoiceSearchWrapper.Loading = InvoiceSearchLoading; + +export default InvoiceSearchWrapper; diff --git a/src/components/InvoiceSearch/index.ts b/src/components/InvoiceSearch/index.ts new file mode 100644 index 0000000..14315af --- /dev/null +++ b/src/components/InvoiceSearch/index.ts @@ -0,0 +1 @@ +export { default } from "./InvoiceSearchWrapper"; diff --git a/src/components/SalarySearch/SalarySearch.tsx b/src/components/SalarySearch/SalarySearch.tsx index 8513fca..e7469fd 100644 --- a/src/components/SalarySearch/SalarySearch.tsx +++ b/src/components/SalarySearch/SalarySearch.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import SearchResults, { Column } from "../SearchResults"; import EditNote from "@mui/icons-material/EditNote"; import { SalaryResult } from "@/app/api/salarys"; +import { convertLocaleStringToNumber } from "@/app/utils/formatUtil" interface Props { salarys: SalaryResult[]; @@ -46,6 +47,7 @@ const SalarySearch: React.FC = ({ salarys }) => { { name: "salaryPoint", label: t("Salary Point") }, { name: "lowerLimit", label: t("Lower Limit") }, { name: "upperLimit", label: t("Upper Limit") }, + { name: "hourlyRate", label: t("Hourly Rate") }, ], [t, onSalaryClick], ); @@ -58,8 +60,8 @@ const SalarySearch: React.FC = ({ salarys }) => { setFilteredSalarys( salarys.filter( (s) => - ((s.lowerLimit <= Number(query.salary))&& - (s.upperLimit >= Number(query.salary)))|| + ((convertLocaleStringToNumber(s.lowerLimit) <= Number(query.salary))&& + (convertLocaleStringToNumber(s.upperLimit) >= Number(query.salary)))|| (s.salaryPoint === Number(query.salaryPoint)) ), ); diff --git a/src/components/SalarySearch/SalarySearchWrapper.tsx b/src/components/SalarySearch/SalarySearchWrapper.tsx index 4687c17..3c910f9 100644 --- a/src/components/SalarySearch/SalarySearchWrapper.tsx +++ b/src/components/SalarySearch/SalarySearchWrapper.tsx @@ -8,11 +8,26 @@ interface SubComponents { Loading: typeof SalarySearchLoading; } +function calculateHourlyRate(loweLimit: number, upperLimit: number, numOfWorkingDay: number, workingHour: number){ + const hourlyRate = (loweLimit + upperLimit)/2/numOfWorkingDay/workingHour + return hourlyRate.toLocaleString() +} + const SalarySearchWrapper: React.FC & SubComponents = async () => { const Salarys = await fetchSalarys(); // const Salarys:any[] = [] + const salarysWithHourlyRate = Salarys.map((salary) => { + const hourlyRate = calculateHourlyRate(Number(salary.lowerLimit), Number(salary.upperLimit),22, 8) + return { + ...salary, + upperLimit: salary.upperLimit.toLocaleString(), + lowerLimit: salary.lowerLimit.toLocaleString(), + hourlyRate: hourlyRate + } + }) + // console.log(salarysWithHourlyRate) - return ; + return ; }; SalarySearchWrapper.Loading = SalarySearchLoading; diff --git a/src/i18n/en/salarys.json b/src/i18n/en/salarys.json new file mode 100644 index 0000000..18a38ff --- /dev/null +++ b/src/i18n/en/salarys.json @@ -0,0 +1,7 @@ +{ + "Details": "Details", + "Salary Point": "Salary Point", + "Lower Limit": "Lower Limit(HKD)", + "Upper Limit": "Upper Limit(HKD)", + "Hourly Rate": "Hourly Rate(HKD)" +} \ No newline at end of file diff --git a/src/i18n/zh/salarys.json b/src/i18n/zh/salarys.json new file mode 100644 index 0000000..d28eb81 --- /dev/null +++ b/src/i18n/zh/salarys.json @@ -0,0 +1,7 @@ +{ + "Details": "詳請", + "Salary Point": "薪點", + "Lower Limit": "薪金下限(港元)", + "Upper Limit": "薪金上限(港元)", + "Hourly Rate": "時薪(港元)" +} \ No newline at end of file