Update Salary number format Add remainign filed in Company Create pagetags/Baseline_30082024_FRONTEND_UAT
@@ -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")} | |||
</Button> | |||
</Stack> | |||
<Suspense fallback={<InvoiceSearch.Loading />}> | |||
<InvoiceSearch /> | |||
</Suspense> | |||
</> | |||
) | |||
}; | |||
@@ -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 () => { | |||
<Typography variant="h4" marginInlineEnd={2}> | |||
{t("Salary")} | |||
</Typography> | |||
<Button | |||
{/* <Button | |||
variant="contained" | |||
startIcon={<Add />} | |||
LinkComponent={Link} | |||
href="/settings/position/new" | |||
> | |||
{t("Create Salary")} | |||
</Button> | |||
</Button> */} | |||
</Stack> | |||
<Suspense fallback={<SalarySearch.Loading />}> | |||
<SalarySearch/> | |||
</Suspense> | |||
<I18nProvider namespaces={["salarys"]}> | |||
<Suspense fallback={<SalarySearch.Loading />}> | |||
<SalarySearch/> | |||
</Suspense> | |||
</I18nProvider> | |||
</> | |||
) | |||
}; | |||
@@ -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) => { | |||
@@ -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"] }, | |||
}); | |||
}); |
@@ -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"] }, | |||
}); | |||
}); |
@@ -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 = () => { | |||
@@ -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); | |||
} |
@@ -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<CreateCompanyInputs>(); | |||
return ( | |||
<Card> | |||
<CardContent component={Stack} spacing={4}> | |||
@@ -95,6 +100,116 @@ const CompanyDetails: React.FC = ({ | |||
error={Boolean(errors.email)} | |||
/> | |||
</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> | |||
</Box> | |||
{/* <CardActions sx={{ justifyContent: "flex-end" }}> | |||
@@ -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)} | |||
> | |||
{ | |||
<CompanyDetails /> | |||
<LocalizationProvider dateAdapter={AdapterDayjs}> | |||
<CompanyDetails /> | |||
</LocalizationProvider> | |||
} | |||
<Stack direction="row" justifyContent="flex-end" gap={1}> | |||
@@ -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; |
@@ -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; |
@@ -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; |
@@ -0,0 +1 @@ | |||
export { default } from "./InvoiceSearchWrapper"; |
@@ -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<Props> = ({ 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<Props> = ({ 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)) | |||
), | |||
); | |||
@@ -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 <SalarySearch salarys={Salarys} />; | |||
return <SalarySearch salarys={salarysWithHourlyRate} />; | |||
}; | |||
SalarySearchWrapper.Loading = SalarySearchLoading; | |||
@@ -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)" | |||
} |
@@ -0,0 +1,7 @@ | |||
{ | |||
"Details": "詳請", | |||
"Salary Point": "薪點", | |||
"Lower Limit": "薪金下限(港元)", | |||
"Upper Limit": "薪金上限(港元)", | |||
"Hourly Rate": "時薪(港元)" | |||
} |