Browse Source

add staff master page

tags/Baseline_30082024_FRONTEND_UAT
MSI\derek 1 year ago
parent
commit
80953dc4bf
13 changed files with 563 additions and 53 deletions
  1. +1
    -0
      src/app/(main)/projects/create/page.tsx
  2. +93
    -18
      src/app/(main)/staff/create/page.tsx
  3. +48
    -0
      src/app/(main)/staff/page.tsx
  4. +39
    -0
      src/app/api/staff/actions.ts
  5. +40
    -1
      src/app/api/staff/index.ts
  6. +51
    -17
      src/components/CreateStaff/CreateStaffForm.tsx
  7. +51
    -17
      src/components/CustomInputForm/CustomInputForm.tsx
  8. +2
    -0
      src/components/NavigationContent/NavigationContent.tsx
  9. +156
    -0
      src/components/StaffSearch/StaffSearch.tsx
  10. +40
    -0
      src/components/StaffSearch/StaffSearchLoading.tsx
  11. +22
    -0
      src/components/StaffSearch/StaffSearchWrapper.tsx
  12. +1
    -0
      src/components/StaffSearch/index.ts
  13. +19
    -0
      src/i18n/zh/createStaff.json

+ 1
- 0
src/app/(main)/projects/create/page.tsx View File

@@ -1,3 +1,4 @@
"use client";
import { fetchProjectCategories } from "@/app/api/projects";
import { preloadStaff } from "@/app/api/staff";
import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks";


+ 93
- 18
src/app/(main)/staff/create/page.tsx View File

@@ -60,42 +60,117 @@ const CreateStaff: React.FC = async () => {
{
id: "name",
label: t("Staff Name"),
type: "textfield",
type: "text",
value: "asdasd",
// required: "asdasd",
// option: "asdasd",
},
{
id: "date",
label: "date test",
type: "multiDate",
value: "asdasd",
id: "currentPosition",
label: t("Current Position"),
type: "combo-Obj",
// value: "asdasd",
// required: "asdasd",
// option: "asdasd",
options: [{id: 1, key: 1, value: 1, label: "Potato"}, {id: 2, key: 2, value: 2, label: "Tomato"}],
},
{
id: "date2",
label: "combo test",
id: "joinPosition",
label: t("Join Position"),
type: "combo-Obj",
value: "asdasd",
// value: "asdasd",
// required: "asdasd",
options: [{id: 1, key: 1, value: 1, label: "first"}, {id: 2, key: 1, value: 2, label: "second"}],
options: [{id: 1, key: 1, value: 1, label: "Potato"}, {id: 2, key: 2, value: 2, label: "Tomato"}],
},
{
id: "field1",
label: "remarks test",
id: "companyId",
label: t("Company"),
type: "combo-Obj",
// value: "asdasd",
// required: "asdasd",
options: [{id: 1, key: 1, value: 1, label: "Company A"}, {id: 2, key: 2, value: 2, label: "Company B"}],
},
{
id: "gradeId",
label: t("Grade"),
type: "combo-Obj",
options: [{id: 1, key: 1, value: 1, label: "A"}, {id: 2, key: 2, value: 2, label: "B"}],
},
{
id: "teamId",
label: t("Team"),
type: "combo-Obj",
options: [{id: 1, key: 1, value: 1, label: "A"}, {id: 2, key: 2, value: 2, label: "B"}],
},
{
id: "salaryEffId",
label: t("Salary point ID with effective date"),
type: "combo-Obj",
options: [{id: 1, key: 1, value: 1, label: t("first")}, {id: 2, key: 2, value: 2, label: t("second")}],
},
{
id: "hourlyRate",
label: t("Hourly Rate"),
type: "numeric",
value: "",
},
{
id: "email",
label: t("Email"),
type: "text",
value: "",
},
{
id: "phone1",
label: t("Phone1"),
type: "numeric",
value: "",
},
{
id: "phone2",
label: t("Phone2"),
type: "numeric",
value: "",
},
{
id: "emergContactName",
label: t("Emergency Contact Name"),
type: "text",
value: "",
},
{
id: "emergContactPhone",
label: t("Emergency Contact Phone"),
type: "numeric",
value: "",
},
{
id: "employType",
label: t("Employ Type"),
type: "combo-Obj",
options: [{id: 1, key: "FT", value: "FT", label: t("FT")}, {id: 2, key: "PT", value: "PT", label: t("PT")}],
value: "",
},
{
id: "departDate",
label: t("Depart Date"),
type: "multiDate",
value: "",
},
{
id: "departReason",
label: t("Depart Reason"),
type: "text",
value: "",
},
{
id: "remark",
label: t("Remark"),
type: "remarks",
value: "",
// required: "asdasd",
// options: "asdasd",
},
],
];

const handleSubmit = (data: any) => {
console.log(data);
};

return (
<>
<Typography variant="h4">{t("Create Staff")}</Typography>


+ 48
- 0
src/app/(main)/staff/page.tsx View File

@@ -0,0 +1,48 @@
// 'use client';
import { preloadClaims } from "@/app/api/claims";
import { preloadStaff, preloadTeamLeads } from "@/app/api/staff";
import StaffSearch from "@/components/StaffSearch";
import { getServerI18n } from "@/i18n";
import Add from "@mui/icons-material/Add";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Metadata } from "next";
import Link from "next/link";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "Staff",
};

const Staff: React.FC = async () => {
const { t } = await getServerI18n("projects");
preloadTeamLeads()
return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Staff")}
</Typography>
<Button
variant="contained"
startIcon={<Add />}
LinkComponent={Link}
href="/staff/create"
>
{t("Create Staff")}
</Button>
</Stack>
<Suspense fallback={<StaffSearch.Loading />}>
<StaffSearch />
</Suspense>
</>
);
};

export default Staff;

+ 39
- 0
src/app/api/staff/actions.ts View File

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

export interface CreateCustomInputs {
// Project details
projectCode: string;
projectName: string;
// Miscellaneous
expectedProjectFee: string;
}
export interface CreateStaffInputs {
name: string;
currentPositionId: number;
joinPositionId: number;
companyId: number;
gradeId: number;
teamId: number;
salaryEffId: number;
email: string;
phone1: string;
phone2: string;
emergContactName: string;
emergContactPhone: string;
employType: string;
departDate: string;
departReason: string;
remark: string;
}

export const saveStaff = async (data: CreateStaffInputs) => {
return serverFetchJson(`${BASE_API_URL}/staffs/new`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};

+ 40
- 1
src/app/api/staff/index.ts View File

@@ -13,8 +13,36 @@ export interface Staff {
};
}

export const preloadStaff = () => {
export interface resultObj {
records: StaffResult[]
}
export interface StaffResult {
id: number;
created: string;
name: string;
cost: number;
staffId: string;
type: string;
currPos: string;
joinPos: string;
companyId: string;
skillSetId: number;
departmentId: number;
phone1: string;
phone2: string;
email: string;
emergContactName: string;
emergContactPhone: string;
employType: string;
departDate: string;
departReason: string;
remarks: string;
}


export const preloadTeamLeads = () => {
fetchTeamLeads();
fetchStaff();
};

export const fetchTeamLeads = cache(async () => {
@@ -22,3 +50,14 @@ export const fetchTeamLeads = cache(async () => {
next: { tags: ["teamLeads"] },
});
});

export const preloadStaff = () => {
fetchStaff();
};

export const fetchStaff = cache(async () => {
return serverFetchJson<resultObj>(`${BASE_API_URL}/staffs/newlist`, {
next: { tags: ["teamLeads"] },
});
});


+ 51
- 17
src/components/CreateStaff/CreateStaffForm.tsx View File

@@ -1,6 +1,17 @@
'use client'
import { useState } from 'react';
import CustomInputForm from '../CustomInputForm';
"use client";
import { useCallback, useState } from "react";
import CustomInputForm from "../CustomInputForm";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import {
FieldErrors,
FormProvider,
SubmitErrorHandler,
SubmitHandler,
useForm,
} from "react-hook-form";
import { CreateStaffInputs, saveStaff } from "@/app/api/staff/actions";
import { Typography } from "@mui/material";

interface Field {
// subtitle: string;
@@ -21,25 +32,48 @@ interface formProps {
fieldLists: Field[][];
}

const CreateStaffForm: React.FC<formProps> = ({
fieldLists
}) => {
const CreateStaffForm: React.FC<formProps> = ({ fieldLists }) => {
// const [formData, setFormData] = useState<any>(null);
const router = useRouter();
const { t } = useTranslation();
const [serverError, setServerError] = useState("");

const handleSubmit = (data: any) => {
console.log(data);
// Handle the form submission logic here
// setFormData(data);
// const handleSubmit = (data: any) => {
// console.log(data);
// // Handle the form submission logic here
// // setFormData(data);
// };
const handleCancel = () => {
router.back();
};
const onSubmit = useCallback<SubmitHandler<CreateStaffInputs>>(
async (data) => {
try {
console.log(data);
setServerError("");
await saveStaff(data);
} catch (e) {
setServerError(t("An error has occurred. Please try again later."));
}
},
[router, t]
);

return (
<CustomInputForm
// Pass other props to CustomInputForm as needed
fieldLists={fieldLists}
isActive={true}
onSubmit={handleSubmit}
/>
<>
{serverError && (
<Typography variant="body2" color="error" alignSelf="flex-end">
{serverError}
</Typography>
)}
<CustomInputForm
fieldLists={fieldLists}
isActive={true}
onSubmit={onSubmit}
onCancel={handleCancel}
/>
</>
);
};

export default CreateStaffForm;
export default CreateStaffForm;

+ 51
- 17
src/components/CustomInputForm/CustomInputForm.tsx View File

@@ -28,7 +28,7 @@ import { DemoItem } from "@mui/x-date-pickers/internals/demo";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { Check } from "@mui/icons-material";
import { Check, Close } from "@mui/icons-material";

// interface Option {
// // Define properties of each option object
@@ -47,10 +47,13 @@ interface Field {
required?: boolean;
options?: any[];
readOnly?: boolean;
size?: number;
setValue?: any[];
}

interface CustomInputFormProps {
onSubmit: (data: any) => void;
onCancel: () => void;
// resetForm: () => void;
Title?: string[];
isActive: boolean;
@@ -62,6 +65,7 @@ const CustomInputForm: React.FC<CustomInputFormProps> = ({
isActive,
fieldLists,
onSubmit,
onCancel,
// resetForm,
}) => {
const { t } = useTranslation();
@@ -130,6 +134,30 @@ const CustomInputForm: React.FC<CustomInputFormProps> = ({
}));
};

const handleCancel = () => {
reset();
// resetForm();
// setFromDate(null);
setDateObj(null);
setValue({});
if (onCancel) {
onCancel();
}
// if fields include setValue func
// fieldLists.map((list) => {
// list.map((field) => {
// if (typeof field.setValue === 'function') {
// field.setValue(typeof field.value === 'boolean' ? false : null);
// } else if (typeof field.setValue === 'object') {
// field.setValue.map((setFunc: any) => {
// setFunc(null);
// });
// }
// })
// });
// setToDate(null);
};

fieldLists.forEach((list) => {
list.forEach((obj) => {
if (
@@ -168,9 +196,9 @@ const CustomInputForm: React.FC<CustomInputFormProps> = ({
) : null}
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
{fieldList.map((field: Field) => {
if (field.type === "textfield") {
if (field.type === "text") {
return (
<Grid item xs={6} key={field.id}>
<Grid item xs={field.size ?? 6} key={field.id}>
<TextField
label={t(`${field.label}`)}
fullWidth
@@ -184,13 +212,19 @@ const CustomInputForm: React.FC<CustomInputFormProps> = ({
);
} else if (field.type === "multiDate") {
return (
<Grid item xs={6} key={field.id}>
<Grid item xs={field.size ?? 6} key={field.id}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DemoItem>
<DatePicker
key={field.id}
label={field.label}
value={!dateObj ? null : !dateObj[field.id] ? null : dayjs(dateObj[field.id])} // Set initial value or use a default value from state
value={
!dateObj
? null
: !dateObj[field.id]
? null
: dayjs(dateObj[field.id])
} // Set initial value or use a default value from state
onChange={(newValue) => {
handleDateChange(field.id, newValue);
}}
@@ -202,7 +236,7 @@ const CustomInputForm: React.FC<CustomInputFormProps> = ({
);
} else if (field.type === "combo-Obj") {
return (
<Grid item xs={6} key={field.id}>
<Grid item xs={field.size ?? 6} key={field.id}>
<FormControl fullWidth>
<InputLabel id={`${field.id}-label`}>
{field.label}
@@ -259,7 +293,7 @@ const CustomInputForm: React.FC<CustomInputFormProps> = ({
);
} else if (field.type === "numeric") {
return (
<Grid item xs={6} key={field.id}>
<Grid item xs={field.size ?? 6} key={field.id}>
<TextField
fullWidth
{...register(field.id)}
@@ -276,7 +310,7 @@ const CustomInputForm: React.FC<CustomInputFormProps> = ({
);
} else if (field.type === "numeric-positive") {
return (
<Grid item xs={6} key={field.id}>
<Grid item xs={field.size ?? 6} key={field.id}>
<TextField
fullWidth
{...register(field.id)}
@@ -293,7 +327,7 @@ const CustomInputForm: React.FC<CustomInputFormProps> = ({
);
} else if (field.type === "checkbox") {
return (
<Grid item xs={6} key={field.id}>
<Grid item xs={field.size ?? 6} key={field.id}>
<FormControlLabel
control={
<Checkbox
@@ -333,7 +367,7 @@ const CustomInputForm: React.FC<CustomInputFormProps> = ({
);
} else {
return (
<Grid item xs={6} key={field.id}>
<Grid item xs={field.size ?? 6} key={field.id}>
<TextField
fullWidth
{...register(
@@ -356,13 +390,13 @@ const CustomInputForm: React.FC<CustomInputFormProps> = ({
</Box>
))}
<Stack direction="row" justifyContent="flex-end" gap={1}>
{/* <Button
variant="outlined"
startIcon={<Close />}
onClick={handleCancel}
>
{t("Cancel")}
</Button> */}
<Button
variant="outlined"
startIcon={<Close />}
onClick={handleCancel}
>
{t("Cancel")}
</Button>
<Button variant="contained" startIcon={<Check />} type="submit">
{t("Confirm")}
</Button>


+ 2
- 0
src/components/NavigationContent/NavigationContent.tsx View File

@@ -17,6 +17,7 @@ import Assignment from "@mui/icons-material/Assignment";
import Settings from "@mui/icons-material/Settings";
import Analytics from "@mui/icons-material/Analytics";
import Payments from "@mui/icons-material/Payments";
import Staff from "@mui/icons-material/PeopleAlt";
import { useTranslation } from "react-i18next";
import Typography from "@mui/material/Typography";
import { usePathname } from "next/navigation";
@@ -87,6 +88,7 @@ const navigationItems: NavigationItem[] = [
{ icon: <Payments />, label: "Invoice", path: "/invoice" },
{ icon: <Analytics />, label: "Analysis Report", path: "/analytics" },
{ icon: <Settings />, label: "Setting", path: "/settings" },
{ icon: <Staff />, label: "Staff", path: "/staff" },
];

const NavigationContent: React.FC = () => {


+ 156
- 0
src/components/StaffSearch/StaffSearch.tsx View File

@@ -0,0 +1,156 @@
"use client";

import { StaffResult } from "@/app/api/staff";
import React, { useCallback, useMemo, useState } from "react";
import SearchBox, { Criterion } from "../SearchBox/index";
import { useTranslation } from "react-i18next";
import SearchResults, { Column } from "../SearchResults/index";
import EditNote from "@mui/icons-material/EditNote";

interface Props {
staff: StaffResult[];
}

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

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

// If claim searching is done on the server-side, then no need for this.
const [filteredClaims, setFilteredClaims] = useState(staff);

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{
label: t("Staff Name"),
paramName: "name",
type: "text"
},
{
label: t("Cost (HKD)"),
paramName: "cost",
type: "text",
},
{
label: t("Expense Type"),
paramName: "type",
type: "select",
options: ["Expense", "Petty Cash"],
},
{
label: t("Current Position"),
paramName: "currPos",
type: "select",
options: ["Small Potato", "CEO"],
},
{
label: t("Join Position"),
paramName: "joinPos",
type: "select",
options: ["Small Potato", "CEO"],
},
{
label: t("Company"),
paramName: "companyId",
type: "select",
options: ["1", "2"],
},
// {
// label: t("Skillset"),
// paramName: "skillSetId",
// type: "select",
// options: ["Fly", "Boxing"],
// },
// {
// label: t("Department"),
// paramName: "departmentId",
// type: "select",
// options: ["Fly", "Boxing"],
// },
// {
// label: t("phone1"),
// paramName: "phone1",
// type: "text",
// },
// {
// label: t("phone2"),
// paramName: "phone2",
// type: "text",
// },
// {
// label: t("email"),
// paramName: "email",
// type: "text",
// },
// {
// label: t("Emergency Contact Name"),
// paramName: "emergContactName",
// type: "text",
// },
// {
// label: t("Emergency Contact Phone"),
// paramName: "emergContactPhone",
// type: "text",
// },
// {
// label: t("Employ Type"),
// paramName: "employType",
// type: "select",
// options: ["Full-time", "Part-time"],
// },
// {
// label: t("Depart Date"),
// paramName: "departDate",
// type: "date",
// },
// {
// label: t("Depart Reason"),
// paramName: "departReason",
// type: "text",
// },
// {
// label: t("Remarks"),
// paramName: "remarks",
// type: "text",
// },
],
[t],
);

const onStaffClick = useCallback((staff: StaffResult) => {
console.log(staff);
}, []);

const columns = useMemo<Column<StaffResult>[]>(
() => [
// {
// name: "action",
// label: t("Actions"),
// onClick: onClaimClick,
// buttonIcon: <EditNote />,
// },
{ name: "created", label: t("Creation Date") },
{ name: "name", label: t("Related Project Name") },
{ name: "staffId", label: t("Staff Id") },
{ name: "type", label: t("Expense Type") },
// { name: "status", label: t("Status") },
{ name: "remarks", label: t("Remarks") },
],
[t, onStaffClick],
);

return (
<>
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
console.log(query);
}}
/>
<SearchResults<StaffResult> items={filteredClaims} columns={columns} />
</>
);
};

export default StaffSearch;

+ 40
- 0
src/components/StaffSearch/StaffSearchLoading.tsx View File

@@ -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 StaffSearchLoading: 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>
<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 StaffSearchLoading;

+ 22
- 0
src/components/StaffSearch/StaffSearchWrapper.tsx View File

@@ -0,0 +1,22 @@
import { fetchStaff, fetchTeamLeads } from "@/app/api/staff";
import React from "react";
import StaffSearch from "./StaffSearch";
import StaffSearchLoading from "./StaffSearchLoading";
// import { preloadStaff } from "@/app/api/staff";

interface SubComponents {
Loading: typeof StaffSearchLoading;
}

const StaffSearchWrapper: React.FC & SubComponents = async () => {
const staff = await fetchStaff();
// const try = ...staff
console.log(staff)
const records = staff.records;
return <StaffSearch staff={staff.records} />;
};

StaffSearchWrapper.Loading = StaffSearchLoading;

export default StaffSearchWrapper;

+ 1
- 0
src/components/StaffSearch/index.ts View File

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

+ 19
- 0
src/i18n/zh/createStaff.json View File

@@ -0,0 +1,19 @@
{
"Staff Name": "Staff Name",
"Current Position": "Current Position",
"Join Position": "Join Position",
"Company": "Company",
"Grade": "Grade",
"Team": "Team",
"Salary point ID with effective date": "Salary point ID with effective date",
"Hourly Rate": "Hourly Rate",
"Email": "Email",
"Phone1": "Phone1",
"Phone2": "Phone2",
"Emergency Contact Name": "Emergency Contact Name",
"Emergency Contact Phone": "Emergency Contact Phone",
"Employ Type": "Employ Type",
"Depart Date": "Depart Date",
"Depart Reason": "Depart Reason",
"Remark": "Remark"
}

Loading…
Cancel
Save