Procházet zdrojové kódy

Merge branch 'main' of https://git.2fi-solutions.com/wayne.lee/tsms

tags/Baseline_30082024_FRONTEND_UAT
leoho2fi před 1 rokem
rodič
revize
6544885965
13 změnil soubory, kde provedl 765 přidání a 0 odebrání
  1. +45
    -0
      src/app/(main)/settings/team/create/page.tsx
  2. +53
    -0
      src/app/(main)/settings/team/page.tsx
  3. +20
    -0
      src/app/api/team/index.ts
  4. +126
    -0
      src/components/CreateTeam/CreateTeam.tsx
  5. +40
    -0
      src/components/CreateTeam/CreateTeamLoading.tsx
  6. +26
    -0
      src/components/CreateTeam/CreateTeamWrapper.tsx
  7. +233
    -0
      src/components/CreateTeam/StaffAllocation.tsx
  8. +69
    -0
      src/components/CreateTeam/TeamInfo.tsx
  9. +1
    -0
      src/components/CreateTeam/index.ts
  10. +90
    -0
      src/components/TeamSearch/TeamSearch.tsx
  11. +40
    -0
      src/components/TeamSearch/TeamSearchLoading.tsx
  12. +21
    -0
      src/components/TeamSearch/TeamSearchWrapper.tsx
  13. +1
    -0
      src/components/TeamSearch/index.ts

+ 45
- 0
src/app/(main)/settings/team/create/page.tsx Zobrazit soubor

@@ -0,0 +1,45 @@
// 'use client';
import { I18nProvider, getServerI18n } from "@/i18n";
import CustomInputForm from "@/components/CustomInputForm";
import Check from "@mui/icons-material/Check";
import Close from "@mui/icons-material/Close";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import Tab from "@mui/material/Tab";
import Tabs, { TabsProps } from "@mui/material/Tabs";
import { useRouter } from "next/navigation";
import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Task, TaskTemplate } from "@/app/api/tasks";
import {
FieldErrors,
FormProvider,
SubmitErrorHandler,
SubmitHandler,
useForm,
} from "react-hook-form";
import { CreateProjectInputs, saveProject } from "@/app/api/projects/actions";
import { Error } from "@mui/icons-material";
import { ProjectCategory } from "@/app/api/projects";
import { Grid, Typography } from "@mui/material";
import CreateStaffForm from "@/components/CreateStaff/CreateStaff";
import CreateTeam from "@/components/CreateTeam";

const CreateTeamPage: React.FC = async () => {
const { t } = await getServerI18n("team");

const title = ['', t('Additional Info')]
// const regex = new RegExp("^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$")
// console.log(regex)

return (
<>
<Typography variant="h4">{t("Create Team")}</Typography>
<I18nProvider namespaces={["Team"]}>
<CreateTeam/>
</I18nProvider>
</>
);
};

export default CreateTeamPage;

+ 53
- 0
src/app/(main)/settings/team/page.tsx Zobrazit soubor

@@ -0,0 +1,53 @@
import { preloadClaims } from "@/app/api/claims";
import { preloadStaff, preloadTeamLeads } from "@/app/api/staff";
import StaffSearch from "@/components/StaffSearch";
import TeamSearch from "@/components/TeamSearch";
import { I18nProvider, 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: "Team",
};


const Team: React.FC = async () => {
const { t } = await getServerI18n("Team");
// preloadTeamLeads();
// preloadStaff();
return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Team")}
</Typography>
<Button
variant="contained"
startIcon={<Add />}
LinkComponent={Link}
href="/settings/team/create"
>
{t("Create Team")}
</Button>
</Stack>
<I18nProvider namespaces={["Team", "common"]}>
<Suspense fallback={<TeamSearch.Loading />}>
<TeamSearch />
</Suspense>
</I18nProvider>
</>
);
};
export default Team;

+ 20
- 0
src/app/api/team/index.ts Zobrazit soubor

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


export interface TeamResult {
action: any;
id: number;
name: string;
code: string;
description: string;
}

export const fetchTeam = cache(async () => {
return serverFetchJson<TeamResult[]>(`${BASE_API_URL}/team`, {
next: { tags: ["team"] },
});
});

+ 126
- 0
src/components/CreateTeam/CreateTeam.tsx Zobrazit soubor

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

import {
FieldErrors,
FormProvider,
SubmitErrorHandler,
SubmitHandler,
useForm,
} from "react-hook-form";
import StaffAllocation from "./StaffAllocation";
import { StaffResult } from "@/app/api/staff";
import { CreateTeamInputs, saveTeam } from "@/app/api/team/actions";
import { Button, Stack, Tab, Tabs, TabsProps, Typography } from "@mui/material";
import { Check, Close } from "@mui/icons-material";
import { useCallback, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { Error } from "@mui/icons-material";
import TeamInfo from "./TeamInfo";

export interface Props {
allstaff: StaffResult[];
}

const CreateTeam: React.FC<Props> = ({ allstaff }) => {
const formProps = useForm<CreateTeamInputs>();
const [serverError, setServerError] = useState("");
const router = useRouter();
const [tabIndex, setTabIndex] = useState(0);
const { t } = useTranslation();
const searchParams = useSearchParams()

const errors = formProps.formState.errors;

const onSubmit = useCallback<SubmitHandler<CreateTeamInputs>>(
async (data) => {
try {
console.log(data);
await saveTeam(data);
router.replace("/settings/team");
} catch (e) {
console.log(e);
setServerError(t("An error has occurred. Please try again later."));
}
},
[router]
);

const handleCancel = () => {
router.back();
};

const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
},
[],
);
const hasErrorsInTab = (
tabIndex: number,
errors: FieldErrors<CreateTeamInputs>,
) => {
switch (tabIndex) {
case 0:
return Object.keys(errors).length > 0;
default:
false;
}
};
return (
<>
<FormProvider {...formProps}>
<Stack
spacing={2}
component="form"
onSubmit={formProps.handleSubmit(onSubmit)}
>
<Tabs
value={tabIndex}
onChange={handleTabChange}
variant="scrollable"
>
<Tab
label={t("Team Info")}
icon={
hasErrorsInTab(0, errors) ? (
<Error sx={{ marginInlineEnd: 1 }} color="error" />
) : undefined
}
iconPosition="end"
/>
<Tab label={t("Subsidiary Allocation")} iconPosition="end" />
</Tabs>
{serverError && (
<Typography variant="body2" color="error" alignSelf="flex-end">
{serverError}
</Typography>
)}
{tabIndex === 0 && <TeamInfo/>}
{tabIndex === 1 && <StaffAllocation allStaffs={allstaff} />}

{/* <StaffAllocation allStaffs={allstaff} /> */}
<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button
variant="outlined"
startIcon={<Close />}
onClick={handleCancel}
>
{t("Cancel")}
</Button>
<Button
variant="contained"
startIcon={<Check />}
type="submit"
// disabled={Boolean(formProps.watch("isGridEditing"))}
>
{t("Confirm")}
</Button>
</Stack>
</Stack>
</FormProvider>
</>
);
};

export default CreateTeam;

+ 40
- 0
src/components/CreateTeam/CreateTeamLoading.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 CreateTeamLoading: 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>CreateTeam
<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 CreateTeamLoading;

+ 26
- 0
src/components/CreateTeam/CreateTeamWrapper.tsx Zobrazit soubor

@@ -0,0 +1,26 @@
import React from "react";
import CreateTeam from "./CreateTeam";
import CreateTeamLoading from "./CreateTeamLoading";
// import { fetchTeam, fetchTeamLeads } from "@/app/api/team";
import { useSearchParams } from "next/navigation";
import { fetchStaffCombo } from "@/app/api/staff/actions";
import { fetchStaff } from "@/app/api/staff";

interface SubComponents {
Loading: typeof CreateTeamLoading;
}

const CreateTeamWrapper: React.FC & SubComponents = async () => {

const [
staff,
] = await Promise.all([
fetchStaff(),
]);

return <CreateTeam allstaff={staff}/>;
};

CreateTeamWrapper.Loading = CreateTeamLoading;

export default CreateTeamWrapper;

+ 233
- 0
src/components/CreateTeam/StaffAllocation.tsx Zobrazit soubor

@@ -0,0 +1,233 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import CustomInputForm from "../CustomInputForm";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import {
FieldErrors,
FormProvider,
SubmitErrorHandler,
SubmitHandler,
useForm,
useFormContext,
} from "react-hook-form";
import CreateTeamForm from "../CreateTeamForm";
import { CreateTeamInputs } from "@/app/api/team/actions";
import { Staff4TransferList, fetchStaffCombo } from "@/app/api/staff/actions";
import { StaffResult, StaffTeamTable } from "@/app/api/staff";
import SearchResults, { Column } from "../SearchResults";
import { Clear, PersonAdd, PersonRemove, Search } from "@mui/icons-material";
import { Card } from "reactstrap";
import { Box, CardContent, Grid, IconButton, InputAdornment, Stack, Tab, Tabs, TabsProps, TextField, Typography } from "@mui/material";
import { differenceBy } from "lodash";
import StarsIcon from '@mui/icons-material/Stars';

export interface Props {
allStaffs: StaffResult[];
}

const StaffAllocation: React.FC<Props> = ({ allStaffs: staff }) => {
const { t } = useTranslation();
const {
setValue,
getValues,
formState: { defaultValues },
reset,
resetField,
} = useFormContext<CreateTeamInputs>();
const initialStaffs = staff.map((s) => ({ ...s }));
// console.log(initialStaffs)
const [filteredStaff, setFilteredStaff] = useState(initialStaffs);
const [selectedStaff, setSelectedStaff] = useState<typeof filteredStaff>(
initialStaffs.filter((s) => getValues("addStaffIds")?.includes(s.id))
);
const [seletedTeamLead, setSeletedTeamLead] = useState<number>()
// Adding / Removing staff

const addStaff = useCallback((staff: StaffResult) => {
setSelectedStaff((s) => [...s, staff]);
}, []);

const removeStaff = useCallback((staff: StaffResult) => {
setSelectedStaff((s) => s.filter((s) => s.id !== staff.id));
}, []);

const setTeamLead = useCallback((staff: StaffResult) => {
setSeletedTeamLead(staff.id)
const rearrangedList = getValues("addStaffIds").reduce<number[]>((acc, num, index) => {
if (num === staff.id && index !== 0) {
acc.splice(index, 1);
acc.unshift(num);
}
return acc;
}, getValues("addStaffIds"));
console.log(rearrangedList)
console.log(selectedStaff)

const rearrangedStaff = rearrangedList.map((id) => {
return selectedStaff.find((staff) => staff.id === id);
});
console.log(rearrangedStaff)
// setSelectedStaff(rearrangedStaff as StaffResult[]);

setValue("addStaffIds", rearrangedList)
}, []);

const clearSubsidiary = useCallback(() => {
if (defaultValues !== undefined) {
resetField("addStaffIds");
setSelectedStaff(
initialStaffs.filter((s) => defaultValues.addStaffIds?.includes(s.id))
);
}
}, [defaultValues]);

// Sync with form
useEffect(() => {
console.log(selectedStaff)
setValue(
"addStaffIds",
selectedStaff.map((s) => s.id)
);
}, [selectedStaff, setValue]);

const StaffPoolColumns = useMemo<Column<StaffResult>[]>(
() => [
{
label: t("Add"),
name: "id",
onClick: addStaff,
buttonIcon: <PersonAdd />,
},
{ label: t("Staff Id"), name: "staffId" },
{ label: t("Staff Name"), name: "name" },
{ label: t("Current Position"), name: "currentPosition" },
],
[addStaff, t]
);

const allocatedStaffColumns = useMemo<Column<StaffResult>[]>(
() => [
{
label: t("Remove"),
name: "action",
onClick: removeStaff,
buttonIcon: <PersonRemove />,
},
{ label: t("Staff Id"), name: "staffId" },
{ label: t("Staff Name"), name: "name" },
{ label: t("Current Position"), name: "currentPosition" },
{
label: t("Team Lead"),
name: "action",
onClick: setTeamLead,
buttonIcon: <StarsIcon />,
},
],
[removeStaff, t]
);

const [query, setQuery] = React.useState("");
const onQueryInputChange = React.useCallback<
React.ChangeEventHandler<HTMLInputElement>
>((e) => {
setQuery(e.target.value);
}, []);
const clearQueryInput = React.useCallback(() => {
setQuery("");
}, []);

React.useEffect(() => {
// setFilteredStaff(
// initialStaffs.filter((s) => {
// const q = query.toLowerCase();
// // s.staffId.toLowerCase().includes(q)
// // const q = query.toLowerCase();
// // return s.name.toLowerCase().includes(q);
// // s.code.toString().includes(q) ||
// // (s.brNo != null && s.brNo.toLowerCase().includes(q))
// })
// );
}, [staff, query]);

const resetStaff = React.useCallback(() => {
clearQueryInput();
clearSubsidiary();
}, [clearQueryInput, clearSubsidiary]);

const formProps = useForm({
});

// Tab related
const [tabIndex, setTabIndex] = React.useState(0);
const handleTabChange = React.useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
},
[],
);

return (
<>
<FormProvider {...formProps}>
<Card sx={{ display: "block" }}>
<CardContent
sx={{ display: "flex", flexDirection: "column", gap: 1 }}
>
<Stack gap={2}>
<Typography variant="overline" display="block">
{t("staff")}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={6} display="flex" alignItems="center">
<Search sx={{ marginInlineEnd: 1 }} />
<TextField
variant="standard"
fullWidth
onChange={onQueryInputChange}
value={query}
placeholder={t("Search by subsidiary code, name or br no.")}
InputProps={{
endAdornment: query && (
<InputAdornment position="end">
<IconButton onClick={clearQueryInput}>
<Clear />
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
</Grid>
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={t("Staff Pool")} />
<Tab
label={`${t("Allocated Staff")} (${selectedStaff.length})`}
/>
</Tabs>
<Box sx={{ marginInline: -3 }}>
{tabIndex === 0 && (
<SearchResults
noWrapper
items={differenceBy(filteredStaff, selectedStaff, "id")}
columns={StaffPoolColumns}
/>
)}
{tabIndex === 1 && (
<SearchResults
noWrapper
items={selectedStaff}
columns={allocatedStaffColumns}
/>
)}
</Box>
</Stack>
</CardContent>
</Card>
</FormProvider>
</>
);
};

export default StaffAllocation;

+ 69
- 0
src/components/CreateTeam/TeamInfo.tsx Zobrazit soubor

@@ -0,0 +1,69 @@
"use client";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Grid from "@mui/material/Grid";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
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 { FormControl, InputLabel, MenuItem, Select } from "@mui/material";
import { useCallback } from "react";
import { CreateTeamInputs } from "@/app/api/team/actions";

const TeamInfo: React.FC = (
{
// customerTypes,
}
) => {
const { t } = useTranslation();
const {
register,
formState: { errors, defaultValues },
control,
reset,
resetField,
setValue,
} = useFormContext<CreateTeamInputs>();

const resetCustomer = useCallback(() => {
console.log(defaultValues);
if (defaultValues !== undefined) {
resetField("description");
}
}, [defaultValues]);

return (
<>
<Card sx={{ display: "block" }}>
<CardContent component={Stack} spacing={4}>
<Box>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Team Info")}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={12}>
<TextField
label={t("Team Description")}
fullWidth
multiline
rows={4}
{...register("description", {
required: true,
})}
error={Boolean(errors.description)}
helperText={Boolean(errors.description) && (errors.description?.message ? t(errors.description.message) : t("Please input correct description"))}
/>
</Grid>
</Grid>
</Box>
</CardContent>
</Card>
</>
);
};
export default TeamInfo;

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

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

+ 90
- 0
src/components/TeamSearch/TeamSearch.tsx Zobrazit soubor

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

import { TeamResult } from "@/app/api/team";
import SearchBox, { Criterion } from "../SearchBox";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import SearchResults, { Column } from "../SearchResults/index";
import EditNote from "@mui/icons-material/EditNote";
import DeleteIcon from '@mui/icons-material/Delete';
import { deleteStaff } from "@/app/api/staff/actions";
import { useRouter } from "next/navigation";

interface Props {
team: TeamResult[];
}
type SearchQuery = Partial<Omit<TeamResult, "id">>;
type SearchParamNames = keyof SearchQuery;

const TeamSearch: React.FC<Props> = ({ team }) => {
const { t } = useTranslation();
const [filteredTeam, setFilteredTeam] = useState(team);
const [data, setData] = useState<TeamResult>();
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{
label: t("Team Name"),
paramName: "name",
type: "text",
},
{
label: t("Team Code"),
paramName: "code",
type: "text",
},
{
label: t("Team Description"),
paramName: "description",
type: "text",
},
],
[t],
);

const columns = useMemo<Column<TeamResult>[]>(
() => [
// {
// name: "action",
// label: t("Actions"),
// onClick: onStaffClick,
// buttonIcon: <EditNote />,
// },
{ name: "name", label: t("Name") },
{ name: "code", label: t("Code") },
{ name: "description", label: t("description") },
// {
// name: "action",
// label: t("Actions"),
// onClick: deleteClick,
// buttonIcon: <DeleteIcon />,
// },
],
[t],
);

return (
<>
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
// setFilteredStaff(
// staff.filter(
// (s) =>
// s.staffId.toLowerCase().includes(query.staffId.toLowerCase()) &&
// s.name.toLowerCase().includes(query.name.toLowerCase())
// // (query.team === "All" || s.team === query.team) &&
// // (query.category === "All" || s.category === query.category) &&
// // (query.team === "All" || s.team === query.team),
// )
// )
}}
/>
<SearchResults<TeamResult> items={filteredTeam} columns={columns} />

</>
);
};
export default TeamSearch;

+ 40
- 0
src/components/TeamSearch/TeamSearchLoading.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 TeamSearchLoading: 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 TeamSearchLoading;

+ 21
- 0
src/components/TeamSearch/TeamSearchWrapper.tsx Zobrazit soubor

@@ -0,0 +1,21 @@
// import { fetchTeam, fetchTeamLeads } from "@/app/api/Team";
import React from "react";
import TeamSearch from "./TeamSearch";
import TeamSearchLoading from "./TeamSearchLoading";
import { fetchTeam } from "@/app/api/team";
// import { preloadTeam } from "@/app/api/Team";

interface SubComponents {
Loading: typeof TeamSearchLoading;
}

const TeamSearchWrapper: React.FC & SubComponents = async () => {
const Team = await fetchTeam();
console.log(Team);

return <TeamSearch team={Team} />;
};

TeamSearchWrapper.Loading = TeamSearchLoading;

export default TeamSearchWrapper;

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

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

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