| @@ -11,6 +11,7 @@ export interface UserInputs { | |||||
| email?: string; | email?: string; | ||||
| addAuthIds?: number[]; | addAuthIds?: number[]; | ||||
| removeAuthIds?: number[]; | removeAuthIds?: number[]; | ||||
| password?: string; | |||||
| } | } | ||||
| export interface PasswordInputs { | export interface PasswordInputs { | ||||
| @@ -50,4 +51,12 @@ export const changePassword = async (data: any) => { | |||||
| body: JSON.stringify(data), | body: JSON.stringify(data), | ||||
| headers: { "Content-Type": "application/json" }, | headers: { "Content-Type": "application/json" }, | ||||
| }); | }); | ||||
| }; | |||||
| export const adminChangePassword = async (data: any) => { | |||||
| return serverFetchWithNoContent(`${BASE_API_URL}/user/admin-change-password`, { | |||||
| method: "PATCH", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }); | |||||
| }; | }; | ||||
| @@ -34,6 +34,15 @@ export interface UserDetail { | |||||
| auths: any[] | auths: any[] | ||||
| } | } | ||||
| export type passwordRule = { | |||||
| min: number; | |||||
| max: number; | |||||
| number: boolean; | |||||
| upperEng: boolean; | |||||
| lowerEng: boolean; | |||||
| specialChar: boolean; | |||||
| } | |||||
| export const preloadUser = () => { | export const preloadUser = () => { | ||||
| fetchUser(); | fetchUser(); | ||||
| }; | }; | ||||
| @@ -52,4 +61,10 @@ export interface UserDetail { | |||||
| return serverFetchJson<UserResult[]>(`${BASE_API_URL}/user/${id}`, { | return serverFetchJson<UserResult[]>(`${BASE_API_URL}/user/${id}`, { | ||||
| next: { tags: ["user"] }, | next: { tags: ["user"] }, | ||||
| }); | }); | ||||
| }); | |||||
| export const fetchPwRules = cache(async () => { | |||||
| return serverFetchJson<passwordRule>(`${BASE_API_URL}/user/password-rule`, { | |||||
| next: { tags: ["pwRule"] }, | |||||
| }); | |||||
| }); | }); | ||||
| @@ -26,17 +26,19 @@ import { | |||||
| } from "react-hook-form"; | } from "react-hook-form"; | ||||
| import { Check, Close, Error, RestartAlt } from "@mui/icons-material"; | import { Check, Close, Error, RestartAlt } from "@mui/icons-material"; | ||||
| import { StaffResult } from "@/app/api/staff"; | import { StaffResult } from "@/app/api/staff"; | ||||
| import { UserInputs, editUser, fetchUserDetails } from "@/app/api/user/actions"; | |||||
| import { UserInputs, adminChangePassword, editUser, fetchUserDetails } from "@/app/api/user/actions"; | |||||
| import UserDetail from "./UserDetail"; | import UserDetail from "./UserDetail"; | ||||
| import { UserResult } from "@/app/api/user"; | |||||
| import { UserResult, passwordRule } from "@/app/api/user"; | |||||
| import { auth, fetchAuth } from "@/app/api/group/actions"; | import { auth, fetchAuth } from "@/app/api/group/actions"; | ||||
| import AuthAllocation from "./AuthAllocation"; | import AuthAllocation from "./AuthAllocation"; | ||||
| interface Props { | interface Props { | ||||
| // users: UserResult[] | |||||
| } | |||||
| rules: passwordRule | |||||
| } | |||||
| const EditUser: React.FC<Props> = async ({ }) => { | |||||
| const EditUser: React.FC<Props> = async ({ | |||||
| rules | |||||
| }) => { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const formProps = useForm<UserInputs>(); | const formProps = useForm<UserInputs>(); | ||||
| const searchParams = useSearchParams(); | const searchParams = useSearchParams(); | ||||
| @@ -53,11 +55,11 @@ const EditUser: React.FC<Props> = async ({ }) => { | |||||
| }, | }, | ||||
| [] | [] | ||||
| ); | ); | ||||
| console.log(rules); | |||||
| const errors = formProps.formState.errors; | const errors = formProps.formState.errors; | ||||
| const fetchUserDetail = async () => { | const fetchUserDetail = async () => { | ||||
| console.log(id); | |||||
| try { | try { | ||||
| // fetch user info | // fetch user info | ||||
| const userDetail = await fetchUserDetails(id); | const userDetail = await fetchUserDetails(id); | ||||
| @@ -112,16 +114,45 @@ const EditUser: React.FC<Props> = async ({ }) => { | |||||
| const onSubmit = useCallback<SubmitHandler<UserInputs>>( | const onSubmit = useCallback<SubmitHandler<UserInputs>>( | ||||
| async (data) => { | async (data) => { | ||||
| try { | try { | ||||
| let haveError = false | |||||
| let regex_pw = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/ | |||||
| let pw = '' | |||||
| if (data.password && data.password.length > 0) { | |||||
| pw = data.password | |||||
| if (pw.length < rules.min) { | |||||
| haveError = true | |||||
| formProps.setError("password", { message: t("The password requires 8-20 characters."), type: "required" }) | |||||
| } | |||||
| if (pw.length > rules.max) { | |||||
| haveError = true | |||||
| formProps.setError("password", { message: t("The password requires 8-20 characters."), type: "required" }) | |||||
| } | |||||
| if (!regex_pw.test(pw)) { | |||||
| haveError = true | |||||
| formProps.setError("password", { message: "A combination of uppercase letters, lowercase letters, numbers, and symbols is required.", type: "required" }) | |||||
| } | |||||
| } | |||||
| console.log(data); | console.log(data); | ||||
| const tempData = { | |||||
| const userData = { | |||||
| name: data.name, | name: data.name, | ||||
| email: data.email, | |||||
| email: '', | |||||
| locked: false, | locked: false, | ||||
| addAuthIds: data.addAuthIds || [], | addAuthIds: data.addAuthIds || [], | ||||
| removeAuthIds: data.removeAuthIds || [], | removeAuthIds: data.removeAuthIds || [], | ||||
| } | } | ||||
| console.log(tempData); | |||||
| await editUser(id, tempData); | |||||
| const pwData = { | |||||
| id: id, | |||||
| password: "", | |||||
| newPassword: pw | |||||
| } | |||||
| console.log(userData); | |||||
| if (haveError) { | |||||
| return | |||||
| } | |||||
| await editUser(id, userData); | |||||
| if (data.password && data.password.length > 0) { | |||||
| await adminChangePassword(pwData); | |||||
| } | |||||
| router.replace("/settings/staff"); | router.replace("/settings/staff"); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.log(e); | console.log(e); | ||||
| @@ -5,7 +5,7 @@ import EditUserLoading from "./EditUserLoading"; | |||||
| import { useSearchParams } from "next/navigation"; | import { useSearchParams } from "next/navigation"; | ||||
| import { fetchTeam, fetchTeamDetail } from "@/app/api/team"; | import { fetchTeam, fetchTeamDetail } from "@/app/api/team"; | ||||
| import { fetchStaff } from "@/app/api/staff"; | import { fetchStaff } from "@/app/api/staff"; | ||||
| import { fetchUser, fetchUserDetail } from "@/app/api/user"; | |||||
| import { fetchPwRules, fetchUser, fetchUserDetail } from "@/app/api/user"; | |||||
| interface SubComponents { | interface SubComponents { | ||||
| Loading: typeof EditUserLoading; | Loading: typeof EditUserLoading; | ||||
| @@ -17,10 +17,9 @@ interface Props { | |||||
| const EditUserWrapper: React.FC<Props> & SubComponents = async ({ | const EditUserWrapper: React.FC<Props> & SubComponents = async ({ | ||||
| // id | // id | ||||
| }) => { | }) => { | ||||
| // const users = await fetchUser() | |||||
| // const userDetail = await fetchUserDetail(id) | |||||
| const pwRule = await fetchPwRules() | |||||
| return <EditUser /> | |||||
| return <EditUser rules={pwRule} /> | |||||
| }; | }; | ||||
| EditUserWrapper.Loading = EditUserLoading; | EditUserWrapper.Loading = EditUserLoading; | ||||
| @@ -47,12 +47,10 @@ const UserDetail: React.FC<Props> = ({ | |||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| label={t("email")} | |||||
| label={t("password")} | |||||
| fullWidth | fullWidth | ||||
| {...register("email", { | |||||
| required: "email required!", | |||||
| })} | |||||
| error={Boolean(errors.email)} | |||||
| {...register("password")} | |||||
| error={Boolean(errors.password)} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| </Grid> | </Grid> | ||||
| @@ -24,11 +24,18 @@ const GenerateMonthlyWorkHoursReport: React.FC<Props> = ({ staffs }) => { | |||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| { label: t("Staff"), | |||||
| paramName: "staff", | |||||
| type: "select", | |||||
| options: staffCombo, | |||||
| needAll: false}, | |||||
| { | |||||
| label: t("Staff"), | |||||
| paramName: "staff", | |||||
| type: "select", | |||||
| options: staffCombo, | |||||
| needAll: false | |||||
| }, | |||||
| { | |||||
| label: t("date"), | |||||
| paramName: "date", | |||||
| type: "monthYear", | |||||
| }, | |||||
| ], | ], | ||||
| [t], | [t], | ||||
| ); | ); | ||||
| @@ -38,9 +45,10 @@ return ( | |||||
| <SearchBox | <SearchBox | ||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={async (query: any) => { | onSearch={async (query: any) => { | ||||
| if (query.staff.length > 0 && query.staff.toLocaleLowerCase() !== "all") { | |||||
| console.log(query) | |||||
| if (query.staff.length > 0 && query.staff.toLocaleLowerCase() !== "all" && query.date.length < 0) { | |||||
| const index = staffCombo.findIndex(staff => staff === query.staff) | const index = staffCombo.findIndex(staff => staff === query.staff) | ||||
| const response = await fetchMonthlyWorkHoursReport({ id: staffs[index].id, yearMonth: "2023-03" }) | |||||
| const response = await fetchMonthlyWorkHoursReport({ id: staffs[index].id, yearMonth: query.date }) | |||||
| if (response) { | if (response) { | ||||
| downloadFile(new Uint8Array(response.blobValue), response.filename!!) | downloadFile(new Uint8Array(response.blobValue), response.filename!!) | ||||
| } | } | ||||
| @@ -21,6 +21,7 @@ import { DatePicker } from "@mui/x-date-pickers/DatePicker"; | |||||
| import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; | import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; | ||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| import { Box } from "@mui/material"; | import { Box } from "@mui/material"; | ||||
| import { DateCalendar } from "@mui/x-date-pickers"; | |||||
| interface BaseCriterion<T extends string> { | interface BaseCriterion<T extends string> { | ||||
| label: string; | label: string; | ||||
| @@ -43,10 +44,15 @@ interface DateRangeCriterion<T extends string> extends BaseCriterion<T> { | |||||
| type: "dateRange"; | type: "dateRange"; | ||||
| } | } | ||||
| interface MonthYearCriterion<T extends string> extends BaseCriterion<T> { | |||||
| type: "monthYear"; | |||||
| } | |||||
| export type Criterion<T extends string> = | export type Criterion<T extends string> = | ||||
| | TextCriterion<T> | | TextCriterion<T> | ||||
| | SelectCriterion<T> | | SelectCriterion<T> | ||||
| | DateRangeCriterion<T>; | |||||
| | DateRangeCriterion<T> | |||||
| | MonthYearCriterion<T>; | |||||
| interface Props<T extends string> { | interface Props<T extends string> { | ||||
| criteria: Criterion<T>[]; | criteria: Criterion<T>[]; | ||||
| @@ -66,19 +72,19 @@ function SearchBox<T extends string>({ | |||||
| (acc, c) => { | (acc, c) => { | ||||
| return { ...acc, [c.paramName]: c.type === "select" ? "All" : "" }; | return { ...acc, [c.paramName]: c.type === "select" ? "All" : "" }; | ||||
| }, | }, | ||||
| {} as Record<T, string>, | |||||
| {} as Record<T, string> | |||||
| ), | ), | ||||
| [criteria], | |||||
| [criteria] | |||||
| ); | ); | ||||
| const [inputs, setInputs] = useState(defaultInputs); | const [inputs, setInputs] = useState(defaultInputs); | ||||
| const makeInputChangeHandler = useCallback( | const makeInputChangeHandler = useCallback( | ||||
| (paramName: T): React.ChangeEventHandler<HTMLInputElement> => { | (paramName: T): React.ChangeEventHandler<HTMLInputElement> => { | ||||
| return (e) => { | return (e) => { | ||||
| setInputs((i) => ({ ...i, [paramName]: e.target.value })); | setInputs((i) => ({ ...i, [paramName]: e.target.value })); | ||||
| }; | }; | ||||
| }, | }, | ||||
| [], | |||||
| [] | |||||
| ); | ); | ||||
| const makeSelectChangeHandler = useCallback((paramName: T) => { | const makeSelectChangeHandler = useCallback((paramName: T) => { | ||||
| @@ -93,6 +99,13 @@ function SearchBox<T extends string>({ | |||||
| }; | }; | ||||
| }, []); | }, []); | ||||
| const makeMonthYearChangeHandler = useCallback((paramName: T) => { | |||||
| return (e: any) => { | |||||
| console.log(dayjs(e).format("YYYY-MM")) | |||||
| setInputs((i) => ({ ...i, [paramName]: dayjs(e).format("YYYY-MM") })); | |||||
| }; | |||||
| }, []); | |||||
| const makeDateToChangeHandler = useCallback((paramName: T) => { | const makeDateToChangeHandler = useCallback((paramName: T) => { | ||||
| return (e: any) => { | return (e: any) => { | ||||
| setInputs((i) => ({ | setInputs((i) => ({ | ||||
| @@ -135,7 +148,9 @@ function SearchBox<T extends string>({ | |||||
| onChange={makeSelectChangeHandler(c.paramName)} | onChange={makeSelectChangeHandler(c.paramName)} | ||||
| value={inputs[c.paramName]} | value={inputs[c.paramName]} | ||||
| > | > | ||||
| {!(c.needAll === false) && <MenuItem value={"All"}>{t("All")}</MenuItem>} | |||||
| {!(c.needAll === false) && ( | |||||
| <MenuItem value={"All"}>{t("All")}</MenuItem> | |||||
| )} | |||||
| {c.options.map((option, index) => ( | {c.options.map((option, index) => ( | ||||
| <MenuItem key={`${option}-${index}`} value={option}> | <MenuItem key={`${option}-${index}`} value={option}> | ||||
| {t(option)} | {t(option)} | ||||
| @@ -144,6 +159,26 @@ function SearchBox<T extends string>({ | |||||
| </Select> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| )} | )} | ||||
| {c.type === "monthYear" && ( | |||||
| <LocalizationProvider | |||||
| dateAdapter={AdapterDayjs} | |||||
| // TODO: Should maybe use a custom adapterLocale here to support YYYY-MM-DD | |||||
| adapterLocale="zh-hk" | |||||
| > | |||||
| <Box display="flex"> | |||||
| <DateCalendar | |||||
| views={["month", "year"]} | |||||
| openTo="month" | |||||
| onChange={makeMonthYearChangeHandler(c.paramName)} | |||||
| value={ | |||||
| inputs[c.paramName] | |||||
| ? dayjs(inputs[c.paramName]) | |||||
| : null | |||||
| } | |||||
| /> | |||||
| </Box> | |||||
| </LocalizationProvider> | |||||
| )} | |||||
| {c.type === "dateRange" && ( | {c.type === "dateRange" && ( | ||||
| <LocalizationProvider | <LocalizationProvider | ||||
| dateAdapter={AdapterDayjs} | dateAdapter={AdapterDayjs} | ||||
| @@ -155,7 +190,11 @@ function SearchBox<T extends string>({ | |||||
| <DatePicker | <DatePicker | ||||
| label={c.label} | label={c.label} | ||||
| onChange={makeDateChangeHandler(c.paramName)} | onChange={makeDateChangeHandler(c.paramName)} | ||||
| value={inputs[c.paramName] ? dayjs(inputs[c.paramName]) : null} | |||||
| value={ | |||||
| inputs[c.paramName] | |||||
| ? dayjs(inputs[c.paramName]) | |||||
| : null | |||||
| } | |||||
| /> | /> | ||||
| </FormControl> | </FormControl> | ||||
| <Box | <Box | ||||
| @@ -170,7 +209,11 @@ function SearchBox<T extends string>({ | |||||
| <DatePicker | <DatePicker | ||||
| label={c.label2} | label={c.label2} | ||||
| onChange={makeDateToChangeHandler(c.paramName)} | onChange={makeDateToChangeHandler(c.paramName)} | ||||
| value={inputs[c.paramName.concat("To") as T] ? dayjs(inputs[c.paramName.concat("To") as T]) : null} | |||||
| value={ | |||||
| inputs[c.paramName.concat("To") as T] | |||||
| ? dayjs(inputs[c.paramName.concat("To") as T]) | |||||
| : null | |||||
| } | |||||
| /> | /> | ||||
| </FormControl> | </FormControl> | ||||
| </Box> | </Box> | ||||
| @@ -180,22 +223,22 @@ function SearchBox<T extends string>({ | |||||
| ); | ); | ||||
| })} | })} | ||||
| </Grid> | </Grid> | ||||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||||
| <Button | |||||
| variant="text" | |||||
| startIcon={<RestartAlt />} | |||||
| onClick={handleReset} | |||||
| > | |||||
| {t("Reset")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Search />} | |||||
| onClick={handleSearch} | |||||
| > | |||||
| {t("Search")} | |||||
| </Button> | |||||
| </CardActions> | |||||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||||
| <Button | |||||
| variant="text" | |||||
| startIcon={<RestartAlt />} | |||||
| onClick={handleReset} | |||||
| > | |||||
| {t("Reset")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Search />} | |||||
| onClick={handleSearch} | |||||
| > | |||||
| {t("Search")} | |||||
| </Button> | |||||
| </CardActions> | |||||
| </CardContent> | </CardContent> | ||||
| </Card> | </Card> | ||||
| ); | ); | ||||