Procházet zdrojové kódy

Add Invoice Search Page

Update Salary number format
Add remainign filed in Company Create page
tags/Baseline_30082024_FRONTEND_UAT
MSI\2Fi před 1 rokem
rodič
revize
76d46816ef
17 změnil soubory, kde provedl 409 přidání a 31 odebrání
  1. +5
    -0
      src/app/(main)/invoice/page.tsx
  2. +8
    -7
      src/app/(main)/settings/salary/page.tsx
  3. +14
    -13
      src/app/api/companys/actions.ts
  4. +35
    -0
      src/app/api/invoices/actions.ts
  5. +26
    -0
      src/app/api/invoices/index.ts
  6. +3
    -2
      src/app/api/salarys/index.ts
  7. +5
    -0
      src/app/utils/formatUtil.ts
  8. +116
    -1
      src/components/CreateCompany/CompanyDetails.tsx
  9. +10
    -5
      src/components/CreateCompany/CreateCompany.tsx
  10. +91
    -0
      src/components/InvoiceSearch/InvoiceSearch.tsx
  11. +40
    -0
      src/components/InvoiceSearch/InvoiceSearchLoading.tsx
  12. +21
    -0
      src/components/InvoiceSearch/InvoiceSearchWrapper.tsx
  13. +1
    -0
      src/components/InvoiceSearch/index.ts
  14. +4
    -2
      src/components/SalarySearch/SalarySearch.tsx
  15. +16
    -1
      src/components/SalarySearch/SalarySearchWrapper.tsx
  16. +7
    -0
      src/i18n/en/salarys.json
  17. +7
    -0
      src/i18n/zh/salarys.json

+ 5
- 0
src/app/(main)/invoice/page.tsx Zobrazit soubor

@@ -5,6 +5,8 @@ import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import Link from "next/link"; import Link from "next/link";
import { Suspense } from "react";
import InvoiceSearch from "@/components/InvoiceSearch";


export const metadata: Metadata = { export const metadata: Metadata = {
title: "Invoice", title: "Invoice",
@@ -33,6 +35,9 @@ const Invoice: React.FC = async () => {
{t("Create Invoice")} {t("Create Invoice")}
</Button> </Button>
</Stack> </Stack>
<Suspense fallback={<InvoiceSearch.Loading />}>
<InvoiceSearch />
</Suspense>
</> </>
) )
}; };


+ 8
- 7
src/app/(main)/settings/salary/page.tsx Zobrazit soubor

@@ -1,6 +1,6 @@
import SalarySearch from "@/components/SalarySearch"; import SalarySearch from "@/components/SalarySearch";
import { Metadata } from "next"; import { Metadata } from "next";
import { getServerI18n } from "@/i18n";
import { I18nProvider, getServerI18n } from "@/i18n";
import Add from "@mui/icons-material/Add"; import Add from "@mui/icons-material/Add";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
@@ -15,7 +15,6 @@ export const metadata: Metadata = {


const Salary: React.FC = async () => { const Salary: React.FC = async () => {
const { t } = await getServerI18n("Salary"); const { t } = await getServerI18n("Salary");
// Preload necessary dependencies // Preload necessary dependencies
// fetchSalarys(); // fetchSalarys();
// preloadSalarys(); // preloadSalarys();
@@ -31,18 +30,20 @@ const Salary: React.FC = async () => {
<Typography variant="h4" marginInlineEnd={2}> <Typography variant="h4" marginInlineEnd={2}>
{t("Salary")} {t("Salary")}
</Typography> </Typography>
<Button
{/* <Button
variant="contained" variant="contained"
startIcon={<Add />} startIcon={<Add />}
LinkComponent={Link} LinkComponent={Link}
href="/settings/position/new" href="/settings/position/new"
> >
{t("Create Salary")} {t("Create Salary")}
</Button>
</Button> */}
</Stack> </Stack>
<Suspense fallback={<SalarySearch.Loading />}>
<SalarySearch/>
</Suspense>
<I18nProvider namespaces={["salarys"]}>
<Suspense fallback={<SalarySearch.Loading />}>
<SalarySearch/>
</Suspense>
</I18nProvider>
</> </>
) )
}; };


+ 14
- 13
src/app/api/companys/actions.ts Zobrazit soubor

@@ -2,6 +2,7 @@


import { serverFetchJson } from "@/app/utils/fetchUtil"; import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api"; import { BASE_API_URL } from "@/config/api";
import { Dayjs } from "dayjs";
import { cache } from "react"; import { cache } from "react";


export interface comboProp { export interface comboProp {
@@ -14,19 +15,19 @@ export interface combo {
} }


export interface CreateCompanyInputs { 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) => { export const saveCompany = async (data: CreateCompanyInputs) => {


+ 35
- 0
src/app/api/invoices/actions.ts Zobrazit soubor

@@ -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<combo>(`${BASE_API_URL}/departments/combo`, {
next: { tags: ["department"] },
});
});

+ 26
- 0
src/app/api/invoices/index.ts Zobrazit soubor

@@ -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<InvoiceResult[]>(`${BASE_API_URL}/invoices`, {
next: { tags: ["invoices"] },
});
});

+ 3
- 2
src/app/api/salarys/index.ts Zobrazit soubor

@@ -5,10 +5,11 @@ import "server-only";


export interface SalaryResult { export interface SalaryResult {
id: number; id: number;
lowerLimit: number;
upperLimit: number;
lowerLimit: String;
upperLimit: String;
salaryPoint: number; salaryPoint: number;
salary: number; salary: number;
hourlyRate: String;
} }


export const preloadSalarys = () => { export const preloadSalarys = () => {


+ 5
- 0
src/app/utils/formatUtil.ts Zobrazit soubor

@@ -38,3 +38,8 @@ export const shortDateFormatter = (locale?: string) => {
return shortDateFormatter_en; return shortDateFormatter_en;
} }
}; };

export function convertLocaleStringToNumber(numberString: String): number {
const numberWithoutCommas = numberString.replace(/,/g, "");
return parseFloat(numberWithoutCommas);
}

+ 116
- 1
src/components/CreateCompany/CompanyDetails.tsx Zobrazit soubor

@@ -15,8 +15,10 @@ import { useTranslation } from "react-i18next";
import CardActions from "@mui/material/CardActions"; import CardActions from "@mui/material/CardActions";
import RestartAlt from "@mui/icons-material/RestartAlt"; import RestartAlt from "@mui/icons-material/RestartAlt";
import Button from "@mui/material/Button"; 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 { CreateCompanyInputs } from "@/app/api/companys/actions";
import { TimePicker } from "@mui/x-date-pickers";
import dayjs from 'dayjs';


const CompanyDetails: React.FC = ({ const CompanyDetails: React.FC = ({
}) => { }) => {
@@ -25,8 +27,11 @@ const CompanyDetails: React.FC = ({
register, register,
formState: { errors }, formState: { errors },
control, control,
setValue,
getValues,
} = useFormContext<CreateCompanyInputs>(); } = useFormContext<CreateCompanyInputs>();



return ( return (
<Card> <Card>
<CardContent component={Stack} spacing={4}> <CardContent component={Stack} spacing={4}>
@@ -95,6 +100,116 @@ const CompanyDetails: React.FC = ({
error={Boolean(errors.email)} error={Boolean(errors.email)}
/> />
</Grid> </Grid>
<Grid item xs={3}>
<Controller
control={control}
name="normalHourFrom"
rules={{ required: true }}
render={({ field }) => {
return (
<TimePicker
label="Normal Hour From"
inputRef={field.ref}
onChange={(time) => {
const formattedTime = time ? dayjs(time as string).format('HH:mm:ss') : '';
field.onChange(formattedTime);
}}
sx={{ width: '100%' }}
/>
);
}}
/>
</Grid>
<Grid item xs={3}>
<Controller
control={control}
name="normalHourTo"
rules={{ required: true }}
render={({ field }) => {
return (
<TimePicker
label="Normal Hour To"
inputRef={field.ref}
onChange={(time) => {
const formattedTime = time ? dayjs(time as string).format('HH:mm:ss') : '';
field.onChange(formattedTime);
}}
sx={{ width: '100%' }}
/>
);
}}
/>
</Grid>
<Grid item xs={3}>
<Controller
control={control}
name="otHourFrom"
rules={{ required: true }}
render={({ field }) => {
return (
<TimePicker
label="OT Hour From"
inputRef={field.ref}
onChange={(time) => {
const formattedTime = time ? dayjs(time as string).format('HH:mm:ss') : '';
field.onChange(formattedTime);
}}
sx={{ width: '100%' }}
/>
);
}}
/>
</Grid>
<Grid item xs={3}>
<Controller
control={control}
name="otHourTo"
rules={{ required: true }}
render={({ field }) => {
return (
<TimePicker
label="OT Hour To"
inputRef={field.ref}
onChange={(time) => {
const formattedTime = time ? dayjs(time as string).format('HH:mm:ss') : '';
field.onChange(formattedTime);
}}
sx={{ width: '100%' }}
/>
);
}}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Company address")}
fullWidth
{...register("address", {
required: "Please enter a address",
})}
error={Boolean(errors.address)}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Company district")}
fullWidth
{...register("district", {
required: "Please enter a district",
})}
error={Boolean(errors.district)}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("currency")}
fullWidth
{...register("currency", {
required: "Please enter a currency",
})}
error={Boolean(errors.currency)}
/>
</Grid>
</Grid> </Grid>
</Box> </Box>
{/* <CardActions sx={{ justifyContent: "flex-end" }}> {/* <CardActions sx={{ justifyContent: "flex-end" }}>


+ 10
- 5
src/components/CreateCompany/CreateCompany.tsx Zobrazit soubor

@@ -16,6 +16,9 @@ import {
useForm, useForm,
} from "react-hook-form"; } from "react-hook-form";
import CompanyDetails from "./CompanyDetails"; 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 = ({ const CreateCompany: React.FC = ({


@@ -35,7 +38,7 @@ const CreateCompany: React.FC = ({
setServerError(""); setServerError("");
// console.log(JSON.stringify(data)); // console.log(JSON.stringify(data));
await saveCompany(data) await saveCompany(data)
router.replace("/settings/companys");
router.replace("/settings/company");
} catch (e) { } catch (e) {
setServerError(t("An error has occurred. Please try again later.")); setServerError(t("An error has occurred. Please try again later."));
} }
@@ -59,11 +62,11 @@ const CreateCompany: React.FC = ({
phone: "", phone: "",
otHourTo: "", otHourTo: "",
otHourFrom: "", otHourFrom: "",
normalHourTo: "",
normalHourFrom: "",
normalHourTo: dayjs().format('HH:mm:ss'),
normalHourFrom: dayjs().format('HH:mm:ss'),
currency: "", currency: "",
address: "", address: "",
distract: "",
district: "",
email: "", email: "",
}, },
}); });
@@ -78,7 +81,9 @@ const CreateCompany: React.FC = ({
onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)}
> >
{ {
<CompanyDetails />
<LocalizationProvider dateAdapter={AdapterDayjs}>
<CompanyDetails />
</LocalizationProvider>
} }


<Stack direction="row" justifyContent="flex-end" gap={1}> <Stack direction="row" justifyContent="flex-end" gap={1}>


+ 91
- 0
src/components/InvoiceSearch/InvoiceSearch.tsx Zobrazit soubor

@@ -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<Omit<InvoiceResult, "id">>;
type SearchParamNames = keyof SearchQuery;

const InvoiceSearch: React.FC<Props> = ({ invoices }) => {
const { t } = useTranslation("invoices");

const [filteredInvoices, setFilteredInvoices] = useState(invoices);

const searchCriteria: Criterion<SearchParamNames>[] = 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<Column<InvoiceResult>[]>(
() => [
{
name: "id",
label: t("Details"),
onClick: onProjectClick,
buttonIcon: <EditNote />,
},
{ 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 (
<>
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
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}
/>
<SearchResults<InvoiceResult>
items={filteredInvoices}
columns={columns}
/>
</>
);
};

export default InvoiceSearch;

+ 40
- 0
src/components/InvoiceSearch/InvoiceSearchLoading.tsx Zobrazit soubor

@@ -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 (
<>
<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>Invoice
<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 InvoiceSearchLoading;

+ 21
- 0
src/components/InvoiceSearch/InvoiceSearchWrapper.tsx Zobrazit soubor

@@ -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 <InvoiceSearch invoices={Invoices} />;
};

InvoiceSearchWrapper.Loading = InvoiceSearchLoading;

export default InvoiceSearchWrapper;

+ 1
- 0
src/components/InvoiceSearch/index.ts Zobrazit soubor

@@ -0,0 +1 @@
export { default } from "./InvoiceSearchWrapper";

+ 4
- 2
src/components/SalarySearch/SalarySearch.tsx Zobrazit soubor

@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import SearchResults, { Column } from "../SearchResults"; import SearchResults, { Column } from "../SearchResults";
import EditNote from "@mui/icons-material/EditNote"; import EditNote from "@mui/icons-material/EditNote";
import { SalaryResult } from "@/app/api/salarys"; import { SalaryResult } from "@/app/api/salarys";
import { convertLocaleStringToNumber } from "@/app/utils/formatUtil"


interface Props { interface Props {
salarys: SalaryResult[]; salarys: SalaryResult[];
@@ -46,6 +47,7 @@ const SalarySearch: React.FC<Props> = ({ salarys }) => {
{ name: "salaryPoint", label: t("Salary Point") }, { name: "salaryPoint", label: t("Salary Point") },
{ name: "lowerLimit", label: t("Lower Limit") }, { name: "lowerLimit", label: t("Lower Limit") },
{ name: "upperLimit", label: t("Upper Limit") }, { name: "upperLimit", label: t("Upper Limit") },
{ name: "hourlyRate", label: t("Hourly Rate") },
], ],
[t, onSalaryClick], [t, onSalaryClick],
); );
@@ -58,8 +60,8 @@ const SalarySearch: React.FC<Props> = ({ salarys }) => {
setFilteredSalarys( setFilteredSalarys(
salarys.filter( salarys.filter(
(s) => (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)) (s.salaryPoint === Number(query.salaryPoint))
), ),
); );


+ 16
- 1
src/components/SalarySearch/SalarySearchWrapper.tsx Zobrazit soubor

@@ -8,11 +8,26 @@ interface SubComponents {
Loading: typeof SalarySearchLoading; 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 SalarySearchWrapper: React.FC & SubComponents = async () => {
const Salarys = await fetchSalarys(); const Salarys = await fetchSalarys();
// const Salarys:any[] = [] // 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 <SalarySearch salarys={Salarys} />;
return <SalarySearch salarys={salarysWithHourlyRate} />;
}; };


SalarySearchWrapper.Loading = SalarySearchLoading; SalarySearchWrapper.Loading = SalarySearchLoading;


+ 7
- 0
src/i18n/en/salarys.json Zobrazit soubor

@@ -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)"
}

+ 7
- 0
src/i18n/zh/salarys.json Zobrazit soubor

@@ -0,0 +1,7 @@
{
"Details": "詳請",
"Salary Point": "薪點",
"Lower Limit": "薪金下限(港元)",
"Upper Limit": "薪金上限(港元)",
"Hourly Rate": "時薪(港元)"
}

Načítá se…
Zrušit
Uložit