| @@ -11,6 +11,7 @@ export interface UserInputs { | |||
| email?: string; | |||
| addAuthIds?: number[]; | |||
| removeAuthIds?: number[]; | |||
| password?: string; | |||
| } | |||
| export interface PasswordInputs { | |||
| @@ -50,4 +51,12 @@ export const changePassword = async (data: any) => { | |||
| body: JSON.stringify(data), | |||
| 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[] | |||
| } | |||
| export type passwordRule = { | |||
| min: number; | |||
| max: number; | |||
| number: boolean; | |||
| upperEng: boolean; | |||
| lowerEng: boolean; | |||
| specialChar: boolean; | |||
| } | |||
| export const preloadUser = () => { | |||
| fetchUser(); | |||
| }; | |||
| @@ -52,4 +61,10 @@ export interface UserDetail { | |||
| return serverFetchJson<UserResult[]>(`${BASE_API_URL}/user/${id}`, { | |||
| 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"; | |||
| import { Check, Close, Error, RestartAlt } from "@mui/icons-material"; | |||
| 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 { UserResult } from "@/app/api/user"; | |||
| import { UserResult, passwordRule } from "@/app/api/user"; | |||
| import { auth, fetchAuth } from "@/app/api/group/actions"; | |||
| import AuthAllocation from "./AuthAllocation"; | |||
| interface Props { | |||
| // users: UserResult[] | |||
| } | |||
| rules: passwordRule | |||
| } | |||
| const EditUser: React.FC<Props> = async ({ }) => { | |||
| const EditUser: React.FC<Props> = async ({ | |||
| rules | |||
| }) => { | |||
| const { t } = useTranslation(); | |||
| const formProps = useForm<UserInputs>(); | |||
| const searchParams = useSearchParams(); | |||
| @@ -53,11 +55,11 @@ const EditUser: React.FC<Props> = async ({ }) => { | |||
| }, | |||
| [] | |||
| ); | |||
| console.log(rules); | |||
| const errors = formProps.formState.errors; | |||
| const fetchUserDetail = async () => { | |||
| console.log(id); | |||
| try { | |||
| // fetch user info | |||
| const userDetail = await fetchUserDetails(id); | |||
| @@ -112,16 +114,45 @@ const EditUser: React.FC<Props> = async ({ }) => { | |||
| const onSubmit = useCallback<SubmitHandler<UserInputs>>( | |||
| async (data) => { | |||
| 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); | |||
| const tempData = { | |||
| const userData = { | |||
| name: data.name, | |||
| email: data.email, | |||
| email: '', | |||
| locked: false, | |||
| addAuthIds: data.addAuthIds || [], | |||
| 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"); | |||
| } catch (e) { | |||
| console.log(e); | |||
| @@ -5,7 +5,7 @@ import EditUserLoading from "./EditUserLoading"; | |||
| import { useSearchParams } from "next/navigation"; | |||
| import { fetchTeam, fetchTeamDetail } from "@/app/api/team"; | |||
| import { fetchStaff } from "@/app/api/staff"; | |||
| import { fetchUser, fetchUserDetail } from "@/app/api/user"; | |||
| import { fetchPwRules, fetchUser, fetchUserDetail } from "@/app/api/user"; | |||
| interface SubComponents { | |||
| Loading: typeof EditUserLoading; | |||
| @@ -17,10 +17,9 @@ interface Props { | |||
| const EditUserWrapper: React.FC<Props> & SubComponents = async ({ | |||
| // id | |||
| }) => { | |||
| // const users = await fetchUser() | |||
| // const userDetail = await fetchUserDetail(id) | |||
| const pwRule = await fetchPwRules() | |||
| return <EditUser /> | |||
| return <EditUser rules={pwRule} /> | |||
| }; | |||
| EditUserWrapper.Loading = EditUserLoading; | |||
| @@ -47,12 +47,10 @@ const UserDetail: React.FC<Props> = ({ | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("email")} | |||
| label={t("password")} | |||
| fullWidth | |||
| {...register("email", { | |||
| required: "email required!", | |||
| })} | |||
| error={Boolean(errors.email)} | |||
| {...register("password")} | |||
| error={Boolean(errors.password)} | |||
| /> | |||
| </Grid> | |||
| </Grid> | |||
| @@ -24,11 +24,18 @@ const GenerateMonthlyWorkHoursReport: React.FC<Props> = ({ staffs }) => { | |||
| 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], | |||
| ); | |||
| @@ -38,9 +45,10 @@ return ( | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| 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 response = await fetchMonthlyWorkHoursReport({ id: staffs[index].id, yearMonth: "2023-03" }) | |||
| const response = await fetchMonthlyWorkHoursReport({ id: staffs[index].id, yearMonth: query.date }) | |||
| if (response) { | |||
| 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 { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import { Box } from "@mui/material"; | |||
| import { DateCalendar } from "@mui/x-date-pickers"; | |||
| interface BaseCriterion<T extends string> { | |||
| label: string; | |||
| @@ -43,10 +44,15 @@ interface DateRangeCriterion<T extends string> extends BaseCriterion<T> { | |||
| type: "dateRange"; | |||
| } | |||
| interface MonthYearCriterion<T extends string> extends BaseCriterion<T> { | |||
| type: "monthYear"; | |||
| } | |||
| export type Criterion<T extends string> = | |||
| | TextCriterion<T> | |||
| | SelectCriterion<T> | |||
| | DateRangeCriterion<T>; | |||
| | DateRangeCriterion<T> | |||
| | MonthYearCriterion<T>; | |||
| interface Props<T extends string> { | |||
| criteria: Criterion<T>[]; | |||
| @@ -66,19 +72,19 @@ function SearchBox<T extends string>({ | |||
| (acc, c) => { | |||
| return { ...acc, [c.paramName]: c.type === "select" ? "All" : "" }; | |||
| }, | |||
| {} as Record<T, string>, | |||
| {} as Record<T, string> | |||
| ), | |||
| [criteria], | |||
| [criteria] | |||
| ); | |||
| const [inputs, setInputs] = useState(defaultInputs); | |||
| const makeInputChangeHandler = useCallback( | |||
| (paramName: T): React.ChangeEventHandler<HTMLInputElement> => { | |||
| return (e) => { | |||
| setInputs((i) => ({ ...i, [paramName]: e.target.value })); | |||
| }; | |||
| }, | |||
| [], | |||
| [] | |||
| ); | |||
| 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) => { | |||
| return (e: any) => { | |||
| setInputs((i) => ({ | |||
| @@ -135,7 +148,9 @@ function SearchBox<T extends string>({ | |||
| onChange={makeSelectChangeHandler(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) => ( | |||
| <MenuItem key={`${option}-${index}`} value={option}> | |||
| {t(option)} | |||
| @@ -144,6 +159,26 @@ function SearchBox<T extends string>({ | |||
| </Select> | |||
| </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" && ( | |||
| <LocalizationProvider | |||
| dateAdapter={AdapterDayjs} | |||
| @@ -155,7 +190,11 @@ function SearchBox<T extends string>({ | |||
| <DatePicker | |||
| label={c.label} | |||
| onChange={makeDateChangeHandler(c.paramName)} | |||
| value={inputs[c.paramName] ? dayjs(inputs[c.paramName]) : null} | |||
| value={ | |||
| inputs[c.paramName] | |||
| ? dayjs(inputs[c.paramName]) | |||
| : null | |||
| } | |||
| /> | |||
| </FormControl> | |||
| <Box | |||
| @@ -170,7 +209,11 @@ function SearchBox<T extends string>({ | |||
| <DatePicker | |||
| label={c.label2} | |||
| 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> | |||
| </Box> | |||
| @@ -180,22 +223,22 @@ function SearchBox<T extends string>({ | |||
| ); | |||
| })} | |||
| </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> | |||
| </Card> | |||
| ); | |||