Browse Source

update user group page

tags/Baseline_30082024_FRONTEND_UAT
MSI\derek 1 year ago
parent
commit
e689ad1083
11 changed files with 790 additions and 15 deletions
  1. +26
    -0
      src/app/(main)/settings/group/edit/page.tsx
  2. +12
    -5
      src/app/api/group/actions.ts
  3. +1
    -4
      src/components/CreateGroup/AuthorityAllocation.tsx
  4. +210
    -0
      src/components/EditUserGroup/AuthorityAllocation.tsx
  5. +165
    -0
      src/components/EditUserGroup/EditUserGroup.tsx
  6. +40
    -0
      src/components/EditUserGroup/EditUserGroupLoading.tsx
  7. +31
    -0
      src/components/EditUserGroup/EditUserGroupWrapper.tsx
  8. +81
    -0
      src/components/EditUserGroup/GroupInfo.tsx
  9. +216
    -0
      src/components/EditUserGroup/UserAllocation.tsx
  10. +1
    -0
      src/components/EditUserGroup/index.ts
  11. +7
    -6
      src/components/UserGroupSearch/UserGroupSearch.tsx

+ 26
- 0
src/app/(main)/settings/group/edit/page.tsx View File

@@ -0,0 +1,26 @@
import EditPosition from "@/components/EditPosition";
import EditUserGroup from "@/components/EditUserGroup";
import { I18nProvider, getServerI18n } from "@/i18n";
import Typography from "@mui/material/Typography";
import { Metadata } from "next";

export const metadata: Metadata = {
title: "Edit User Group",
};

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

// Preload necessary dependencies

return (
<>
{/* <Typography variant="h4">{t("Edit User Group")}</Typography> */}
<I18nProvider namespaces={["group"]}>
<EditUserGroup />
</I18nProvider>
</>
);
};

export default Positions;

+ 12
- 5
src/app/api/group/actions.ts View File

@@ -29,11 +29,11 @@ export interface record {
records: auth[];
}

export const fetchAuth = cache(async () => {
return serverFetchJson<record>(`${BASE_API_URL}/group/auth/combo`, {
next: { tags: ["auth"] },
});
export const fetchAuth = cache(async (id?: number) => {
return serverFetchJson<record>(`${BASE_API_URL}/group/auth/combo/${id ?? 0}`, {
next: { tags: ["auth"] },
});
});
export const saveGroup = async (data: CreateGroupInputs) => {
return serverFetchJson(`${BASE_API_URL}/group/save`, {
@@ -41,4 +41,11 @@ export const saveGroup = async (data: CreateGroupInputs) => {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};
};
export const deleteGroup = async (id: number) => {
return serverFetchWithNoContent(`${BASE_API_URL}/group/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
};

+ 1
- 4
src/components/CreateGroup/AuthorityAllocation.tsx View File

@@ -50,6 +50,7 @@ const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
);
}
);

// Adding / Removing Auth
const addAuth = useCallback((auth: auth) => {
setSelectedAuths((a) => [...a, auth]);
@@ -126,10 +127,6 @@ const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
// );
}, [auth, query]);

useEffect(() => {
// console.log(getValues("addStaffIds"))
}, [initialAuths]);

const resetAuth = React.useCallback(() => {
clearQueryInput();
clearAuth();


+ 210
- 0
src/components/EditUserGroup/AuthorityAllocation.tsx View File

@@ -0,0 +1,210 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
FieldErrors,
FormProvider,
SubmitErrorHandler,
SubmitHandler,
useForm,
useFormContext,
} from "react-hook-form";
import {
Box,
Card,
CardContent,
Grid,
IconButton,
InputAdornment,
Stack,
Tab,
Tabs,
TabsProps,
TextField,
Typography,
} from "@mui/material";
import { differenceBy } from "lodash";
import { CreateGroupInputs, auth } from "@/app/api/group/actions";
import SearchResults, { Column } from "../SearchResults";
import { Add, Clear, Remove, Search } from "@mui/icons-material";

export interface Props {
auth: auth[];
}

const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
const { t } = useTranslation();
const {
setValue,
getValues,
formState: { defaultValues },
reset,
resetField,
} = useFormContext<CreateGroupInputs>();
console.log(auth)
const initialAuths = auth.map((a) => ({ ...a })).sort((a, b) => a.id - b.id);
const [filteredAuths, setFilteredAuths] = useState(initialAuths);
const [selectedAuths, setSelectedAuths] = useState<typeof filteredAuths>(
() => initialAuths.filter((s) => getValues("addAuthIds")?.includes(s.id)))
const [removeAuthIds, setRemoveAuthIds] = useState<number[]>([]);

// Adding / Removing Auth
const addAuth = useCallback((auth: auth) => {
setSelectedAuths((a) => [...a, auth]);
}, []);
const removeAuth = useCallback((auth: auth) => {
setSelectedAuths((a) => a.filter((a) => a.id !== auth.id));
setRemoveAuthIds((prevIds) => [...prevIds, auth.id]);
}, []);

const clearAuth = useCallback(() => {
if (defaultValues !== undefined) {
resetField("addAuthIds");
setSelectedAuths(
initialAuths.filter((auth) => defaultValues.addAuthIds?.includes(auth.id))
);
}
}, [defaultValues]);

// Sync with form
useEffect(() => {
setValue(
"addAuthIds",
selectedAuths.map((a) => a.id)
);
setValue(
"removeAuthIds",
removeAuthIds
);
}, [selectedAuths, removeAuthIds, setValue]);

const AuthPoolColumns = useMemo<Column<auth>[]>(
() => [
{
label: t("Add"),
name: "id",
onClick: addAuth,
buttonIcon: <Add />,
},
{ label: t("authority"), name: "authority" },
{ label: t("Auth Name"), name: "name" },
// { label: t("Current Position"), name: "currentPosition" },
],
[addAuth, t]
);

const allocatedAuthColumns = useMemo<Column<auth>[]>(
() => [
{
label: t("Remove"),
name: "id",
onClick: removeAuth,
buttonIcon: <Remove color="warning"/>,
},
{ label: t("authority"), name: "authority" },
{ label: t("Auth Name"), name: "name" },
],
[removeAuth, selectedAuths, 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))
// })
// );
}, [auth, query]);

const resetAuth = React.useCallback(() => {
clearQueryInput();
clearAuth();
}, [clearQueryInput, clearAuth]);

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("Authority")}
</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 staff ID, name or position.")}
InputProps={{
endAdornment: query && (
<InputAdornment position="end">
<IconButton onClick={clearQueryInput}>
<Clear />
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
</Grid>
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={t("Authority Pool")} />
<Tab
label={`${t("Allocated Authority")} (${selectedAuths.length})`}
/>
</Tabs>
<Box sx={{ marginInline: -3 }}>
{tabIndex === 0 && (
<SearchResults
noWrapper
items={differenceBy(filteredAuths, selectedAuths, "id")}
columns={AuthPoolColumns}
/>
)}
{tabIndex === 1 && (
<SearchResults
noWrapper
items={selectedAuths}
columns={allocatedAuthColumns}
/>
)}
</Box>
</Stack>
</CardContent>
</Card>
</FormProvider>
</>
);
};

export default AuthorityAllocation;

+ 165
- 0
src/components/EditUserGroup/EditUserGroup.tsx View File

@@ -0,0 +1,165 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import SearchResults, { Column } from "../SearchResults";
// import { TeamResult } from "@/app/api/team";
import { useTranslation } from "react-i18next";
import { Button, Stack, Tab, Tabs, TabsProps, Typography } from "@mui/material";
import { CreateTeamInputs, saveTeam } from "@/app/api/team/actions";
import {
FieldErrors,
FormProvider,
SubmitHandler,
useForm,
useFormContext,
} from "react-hook-form";
import { Check, Close, Error } from "@mui/icons-material";
import { StaffResult } from "@/app/api/staff";
import { CreateGroupInputs, auth, fetchAuth, saveGroup } from "@/app/api/group/actions";
import { UserGroupResult } from "@/app/api/group";
import { UserResult } from "@/app/api/user";
import GroupInfo from "./GroupInfo";
import AuthorityAllocation from "./AuthorityAllocation";
import UserAllocation from "./UserAllocation";
interface Props {
groups: UserGroupResult[];
// auths: auth[];
users: UserResult[];
}

const EditUserGroup: React.FC<Props> = ({ groups, users }) => {
// console.log(users)
const { t } = useTranslation();
const [serverError, setServerError] = useState("");
const formProps = useForm<CreateGroupInputs>();
const searchParams = useSearchParams();
const id = parseInt(searchParams.get("id") || "0");
const router = useRouter();
const [tabIndex, setTabIndex] = useState(0);
const [auths, setAuths] = useState<auth[]>();

const errors = formProps.formState.errors;

const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
},
[]
);

const hasErrorsInTab = (
tabIndex: number,
errors: FieldErrors<CreateGroupInputs>
) => {
switch (tabIndex) {
case 0:
return Object.keys(errors).length > 0;
default:
false;
}
};

const onSubmit = useCallback<SubmitHandler<CreateGroupInputs>>(
async (data) => {
try {
console.log(data);
const tempData = {
...data,
removeUserIds: data.removeUserIds ?? [],
removeAuthIds: data.removeAuthIds ?? [],
id: id
}
console.log(tempData)
await saveGroup(tempData);
router.replace("/settings/group");
} catch (e) {
console.log(e);
setServerError(t("An error has occurred. Please try again later."));
}
},
[router]
);
useEffect(() => {
const thisGroup = groups.filter((item) => item.id === id)[0];
const addUserIds = users.filter((item) => item.groupId === id).map((data) => data.id)
let addAuthIds: number[] = []
fetchAuth(id).then((data) => {
setAuths(data.records)
addAuthIds = data.records.filter((data) => data.v === 1).map((data) => data.id).sort((a, b) => a - b);
formProps.reset({
name: thisGroup.name,
description: thisGroup.description,
addAuthIds: addAuthIds,
addUserIds: addUserIds,
});
});
// console.log(auths)
}, [groups, users]);

return (
<>
<FormProvider {...formProps}>
<Stack
spacing={2}
component="form"
onSubmit={formProps.handleSubmit(onSubmit)}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Edit User Group")}
</Typography>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Tabs
value={tabIndex}
onChange={handleTabChange}
variant="scrollable"
>
<Tab
label={t("Group Info")}
icon={
hasErrorsInTab(0, errors) ? (
<Error sx={{ marginInlineEnd: 1 }} color="error" />
) : undefined
}
iconPosition="end"
/>
<Tab label={t("Authority Allocation")} iconPosition="end" />
<Tab label={t("User Allocation")} iconPosition="end" />
</Tabs>
</Stack>
{serverError && (
<Typography variant="body2" color="error" alignSelf="flex-end">
{serverError}
</Typography>
)}
{tabIndex === 0 && <GroupInfo />}
{tabIndex === 1 && <AuthorityAllocation auth={auths!!}/>}
{tabIndex === 2 && <UserAllocation users={users!!} />}
<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 EditUserGroup;

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

+ 31
- 0
src/components/EditUserGroup/EditUserGroupWrapper.tsx View File

@@ -0,0 +1,31 @@
import React from "react";
import EditUserGroup from "./EditUserGroup";
import EditUserGroupLoading from "./EditUserGroupLoading";
import { fetchGroup } from "@/app/api/group";
import { fetchAuth } from "@/app/api/group/actions";
import { fetchUser } from "@/app/api/user";
import { useSearchParams } from "next/navigation";

interface SubComponents {
Loading: typeof EditUserGroupLoading;
}

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

const [
groups,
// auths,
users,
] = await Promise.all([
fetchGroup(),
// fetchAuth(),
fetchUser(),
]);
console.log(users)

return <EditUserGroup groups={groups.records} users={users}/>;
};

EditUserGroupWrapper.Loading = EditUserGroupLoading;

export default EditUserGroupWrapper;

+ 81
- 0
src/components/EditUserGroup/GroupInfo.tsx View File

@@ -0,0 +1,81 @@
"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 { CreateGroupInputs } from "@/app/api/group/actions";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useCallback } from "react";

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

const resetGroup = 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("Group Info")}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={6}>
<TextField
label={t("Group Name")}
fullWidth
{...register("name", {
required: true,
})}
error={Boolean(errors.name)}
helperText={
Boolean(errors.name) &&
(errors.name?.message
? t(errors.name.message)
: t("Please input correct name"))
}
/>
</Grid>
<Grid item xs={12}>
<TextField
label={t("Group Description")}
fullWidth
multiline
rows={4}
{...register("description")}
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 GroupInfo;

+ 216
- 0
src/components/EditUserGroup/UserAllocation.tsx View File

@@ -0,0 +1,216 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
FieldErrors,
FormProvider,
SubmitErrorHandler,
SubmitHandler,
useForm,
useFormContext,
} from "react-hook-form";
import {
Box,
Card,
CardContent,
Grid,
IconButton,
InputAdornment,
Stack,
Tab,
Tabs,
TabsProps,
TextField,
Typography,
} from "@mui/material";
import { differenceBy } from "lodash";
import { CreateGroupInputs, auth } from "@/app/api/group/actions";
import SearchResults, { Column } from "../SearchResults";
import { Add, Clear, Remove, Search } from "@mui/icons-material";
import { UserResult } from "@/app/api/user";

export interface Props {
users: UserResult[];
}

const UserAllocation: React.FC<Props> = ({ users }) => {
const { t } = useTranslation();
const {
setValue,
getValues,
formState: { defaultValues },
reset,
resetField,
} = useFormContext<CreateGroupInputs>();
const initialUsers = users.map((u) => ({ ...u })).sort((a, b) => a.id - b.id);
const [filteredUsers, setFilteredUsers] = useState(initialUsers);
const [selectedUsers, setSelectedUsers] = useState<typeof filteredUsers>(
() => {
return filteredUsers.filter(
(s) => getValues("addUserIds")?.includes(s.id)
);
}
);
const [deletedUserIds, setDeletedUserIds] = useState<number[]>([]);

// Adding / Removing Auth
const addUser = useCallback((users: UserResult) => {
setSelectedUsers((a) => [...a, users]);
}, []);

const removeUser = useCallback((users: UserResult) => {
setSelectedUsers((a) => a.filter((a) => a.id !== users.id));
setDeletedUserIds((prevIds) => [...prevIds, users.id]);
}, []);

const clearUser = useCallback(() => {
if (defaultValues !== undefined) {
resetField("addUserIds");
setSelectedUsers(
initialUsers.filter((s) => defaultValues.addUserIds?.includes(s.id))
);
}
}, [defaultValues]);

// Sync with form
useEffect(() => {
setValue(
"addUserIds",
selectedUsers.map((u) => u.id)
);
setValue(
"removeUserIds",
deletedUserIds
);
}, [selectedUsers, deletedUserIds, setValue]);

const UserPoolColumns = useMemo<Column<UserResult>[]>(
() => [
{
label: t("Add"),
name: "id",
onClick: addUser,
buttonIcon: <Add />,
},
{ label: t("User Name"), name: "username" },
{ label: t("name"), name: "name" },
],
[addUser, t]
);

const allocatedUserColumns = useMemo<Column<UserResult>[]>(
() => [
{
label: t("Remove"),
name: "id",
onClick: removeUser,
buttonIcon: <Remove color="warning" />,
},
{ label: t("User Name"), name: "username" },
{ label: t("name"), name: "name" },
],
[removeUser, selectedUsers, 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))
// })
// );
}, [users, query]);

const resetUser = React.useCallback(() => {
clearQueryInput();
clearUser();
}, [clearQueryInput, clearUser]);

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("User")}
</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 staff ID, name or position.")}
InputProps={{
endAdornment: query && (
<InputAdornment position="end">
<IconButton onClick={clearQueryInput}>
<Clear />
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
</Grid>
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={t("User Pool")} />
<Tab
label={`${t("Allocated Users")} (${selectedUsers.length})`}
/>
</Tabs>
<Box sx={{ marginInline: -3 }}>
{tabIndex === 0 && (
<SearchResults
noWrapper
items={differenceBy(filteredUsers, selectedUsers, "id")}
columns={UserPoolColumns}
/>
)}
{tabIndex === 1 && (
<SearchResults
noWrapper
items={selectedUsers}
columns={allocatedUserColumns}
/>
)}
</Box>
</Stack>
</CardContent>
</Card>
</FormProvider>
</>
);
};

export default UserAllocation;

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

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

+ 7
- 6
src/components/UserGroupSearch/UserGroupSearch.tsx View File

@@ -10,6 +10,7 @@ import { useRouter } from "next/navigation";
import { deleteDialog, successDialog } from "../Swal/CustomAlerts";
import { UserGroupResult } from "@/app/api/group";
import { deleteUser } from "@/app/api/user/actions";
import { deleteGroup } from "@/app/api/group/actions";

interface Props {
users: UserGroupResult[];
@@ -34,20 +35,20 @@ const UserGroupSearch: React.FC<Props> = ({ users }) => {
);

const onUserClick = useCallback(
(users: UserGroupResult) => {
console.log(users);
// router.push(`/settings/user/edit?id=${users.id}`)
(group: UserGroupResult) => {
console.log(group);
router.push(`/settings/group/edit?id=${group.id}`)
},
[router, t]
);

const onDeleteClick = useCallback((users: UserGroupResult) => {
const onDeleteClick = useCallback((group: UserGroupResult) => {
deleteDialog(async () => {
await deleteUser(users.id);
await deleteGroup(group.id);

successDialog(t("Delete Success"), t);

setFilteredUser((prev) => prev.filter((obj) => obj.id !== users.id));
setFilteredUser((prev) => prev.filter((obj) => obj.id !== group.id));
}, t);
}, []);



Loading…
Cancel
Save