@@ -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> | ||||
); | ); | ||||