Exam generation rework, batch user tables, fastapi endpoint switch
This commit is contained in:
18
src/pages/(admin)/BatchCreateUser/IUserImport.ts
Normal file
18
src/pages/(admin)/BatchCreateUser/IUserImport.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Type as UserType} from "@/interfaces/user";
|
||||
|
||||
export type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate">;
|
||||
|
||||
export interface UserImport {
|
||||
email: string;
|
||||
name: string;
|
||||
passport_id: string;
|
||||
type: Type;
|
||||
groupName: string;
|
||||
corporate: string;
|
||||
studentID: string;
|
||||
demographicInformation: {
|
||||
country: string;
|
||||
passport_id: string;
|
||||
phone: string;
|
||||
};
|
||||
}
|
||||
200
src/pages/(admin)/BatchCreateUser/UserTable.tsx
Normal file
200
src/pages/(admin)/BatchCreateUser/UserTable.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
FilterFn,
|
||||
} from '@tanstack/react-table';
|
||||
import { UserImport } from "./IUserImport";
|
||||
|
||||
const globalFilterFn: FilterFn<any> = (row, columnId, filterValue: string) => {
|
||||
const value = row.getValue(columnId);
|
||||
return String(value).toLowerCase().includes(filterValue.toLowerCase());
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<UserImport>();
|
||||
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor('name', {
|
||||
cell: info => info.getValue(),
|
||||
header: () => 'Name',
|
||||
}),
|
||||
columnHelper.accessor('studentID', {
|
||||
cell: info => info.getValue(),
|
||||
header: () => 'Student ID',
|
||||
}),
|
||||
columnHelper.accessor('demographicInformation.passport_id', {
|
||||
cell: info => info.getValue(),
|
||||
header: () => 'Passport/National ID',
|
||||
}),
|
||||
columnHelper.accessor('email', {
|
||||
cell: info => info.getValue(),
|
||||
header: () => 'Email',
|
||||
}),
|
||||
columnHelper.accessor('demographicInformation.phone', {
|
||||
cell: info => info.getValue(),
|
||||
header: () => 'Phone Number',
|
||||
}),
|
||||
columnHelper.accessor('corporate', {
|
||||
cell: info => info.getValue(),
|
||||
header: () => 'Corporate (e-mail)',
|
||||
}),
|
||||
columnHelper.accessor('groupName', {
|
||||
cell: info => info.getValue(),
|
||||
header: () => 'Group Name',
|
||||
}),
|
||||
columnHelper.accessor('demographicInformation.country', {
|
||||
cell: info => info.getValue(),
|
||||
header: () => 'Country',
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
const UserTable: React.FC<{ users: UserImport[] }> = ({ users }) => {
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
|
||||
const table = useReactTable({
|
||||
data: users,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
globalFilterFn: globalFilterFn,
|
||||
state: {
|
||||
globalFilter,
|
||||
},
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 5,
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<div className="flex flew-row w-full mb-4 justify-between gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={globalFilter ?? ''}
|
||||
onChange={e => setGlobalFilter(e.target.value)}
|
||||
placeholder="Search ..."
|
||||
className="p-2 border rounded flex-grow"
|
||||
/>
|
||||
<select
|
||||
value={table.getState().pagination.pageSize}
|
||||
onChange={e => {
|
||||
table.setPageSize(Number(e.target.value));
|
||||
}}
|
||||
className="p-2 border rounded"
|
||||
>
|
||||
{[5, 10, 15, 20].map(pageSize => (
|
||||
<option key={pageSize} value={pageSize}>
|
||||
Show {pageSize}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map(header => (
|
||||
<th
|
||||
key={header.id}
|
||||
className='bg-mti-purple-ultralight/80 first:rounded-tl-3xl last:rounded-tr-3xl py-4 first:pl-6 text-mti-purple-light cursor-pointer'
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<div className='flex flex-row justify-between'>
|
||||
<span>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</span>
|
||||
<span className='pr-6'>
|
||||
{{
|
||||
asc: ' 🔼',
|
||||
desc: ' 🔽',
|
||||
}[header.column.getIsSorted() as string] ?? null}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row, index, array) => {
|
||||
const isLastRow = index === array.length - 1;
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={
|
||||
isLastRow
|
||||
? `first:rounded-bl-3xl last:rounded-br-3xl py-4 first:pl-6 bg-mti-purple-ultralight/40`
|
||||
: `first:pl-6 py-4 border-b bg-mti-purple-ultralight/40`
|
||||
}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-4 flex items-center gap-4 mx-auto">
|
||||
<button
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
className="px-4 py-2 bg-mti-purple-light text-white rounded disabled:opacity-50"
|
||||
>
|
||||
{'<<'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
className="px-4 py-2 bg-mti-purple-light text-white rounded disabled:opacity-50"
|
||||
>
|
||||
{'<'}
|
||||
</button>
|
||||
<span>
|
||||
Page{' '}
|
||||
<strong>
|
||||
{table.getState().pagination.pageIndex + 1} of{' '}
|
||||
{table.getPageCount()}
|
||||
</strong>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="px-4 py-2 bg-mti-purple-light text-white rounded disabled:opacity-50"
|
||||
>
|
||||
{'>'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="px-4 py-2 bg-mti-purple-light text-white rounded disabled:opacity-50"
|
||||
>
|
||||
{'>>'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserTable;
|
||||
@@ -1,35 +1,33 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Type as UserType, User} from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
import {uniqBy} from "lodash";
|
||||
import {useEffect, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import {useFilePicker} from "use-file-picker";
|
||||
import { uniqBy } from "lodash";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { useFilePicker } from "use-file-picker";
|
||||
import readXlsxFile from "read-excel-file";
|
||||
import Modal from "@/components/Modal";
|
||||
import {BsQuestionCircleFill} from "react-icons/bs";
|
||||
import {PermissionType} from "@/interfaces/permissions";
|
||||
import { BsQuestionCircleFill } from "react-icons/bs";
|
||||
import { PermissionType } from "@/interfaces/permissions";
|
||||
import moment from "moment";
|
||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import clsx from "clsx";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import countryCodes from "country-codes-list";
|
||||
import { User, Type as UserType } from "@/interfaces/user";
|
||||
import { Type, UserImport } from "./IUserImport";
|
||||
import UserTable from "./UserTable";
|
||||
|
||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||
|
||||
type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate">;
|
||||
|
||||
const USER_TYPE_LABELS: {[key in Type]: string} = {
|
||||
const USER_TYPE_LABELS: { [key in Type]: string } = {
|
||||
student: "Student",
|
||||
teacher: "Teacher",
|
||||
corporate: "Corporate",
|
||||
};
|
||||
|
||||
const USER_TYPE_PERMISSIONS: {
|
||||
[key in UserType]: {perm: PermissionType | undefined; list: UserType[]};
|
||||
[key in UserType]: { perm: PermissionType | undefined; list: UserType[] };
|
||||
} = {
|
||||
student: {
|
||||
perm: "createCodeStudent",
|
||||
@@ -63,25 +61,16 @@ const USER_TYPE_PERMISSIONS: {
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
users: User[];
|
||||
permissions: PermissionType[];
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export default function BatchCreateUser({user, users, permissions, onFinish}: Props) {
|
||||
const [infos, setInfos] = useState<
|
||||
{
|
||||
email: string;
|
||||
name: string;
|
||||
passport_id: string;
|
||||
type: Type;
|
||||
demographicInformation: {
|
||||
country: string;
|
||||
passport_id: string;
|
||||
phone: string;
|
||||
};
|
||||
}[]
|
||||
>([]);
|
||||
export default function BatchCreateUser({ user, permissions, onFinish }: Props) {
|
||||
const [infos, setInfos] = useState<UserImport[]>([]);
|
||||
|
||||
const [duplicatedUsers, setDuplicatedUsers] = useState<UserImport[]>([]);
|
||||
const [newUsers, setNewUsers] = useState<UserImport[]>([]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
||||
@@ -90,7 +79,7 @@ export default function BatchCreateUser({user, users, permissions, onFinish}: Pr
|
||||
const [type, setType] = useState<Type>("student");
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
@@ -115,19 +104,19 @@ export default function BatchCreateUser({user, users, permissions, onFinish}: Pr
|
||||
|
||||
return EMAIL_REGEX.test(email.toString().trim())
|
||||
? {
|
||||
email: email.toString().trim().toLowerCase(),
|
||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||
type: type,
|
||||
email: email.toString().trim().toLowerCase(),
|
||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||
type: type,
|
||||
passport_id: passport_id?.toString().trim() || undefined,
|
||||
groupName: group,
|
||||
corporate,
|
||||
studentID,
|
||||
demographicInformation: {
|
||||
country: countryItem?.countryCode,
|
||||
passport_id: passport_id?.toString().trim() || undefined,
|
||||
groupName: group,
|
||||
corporate,
|
||||
studentID,
|
||||
demographicInformation: {
|
||||
country: countryItem?.countryCode,
|
||||
passport_id: passport_id?.toString().trim() || undefined,
|
||||
phone: phone.toString(),
|
||||
},
|
||||
}
|
||||
phone: phone.toString(),
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
})
|
||||
.filter((x) => !!x) as typeof infos,
|
||||
@@ -153,15 +142,41 @@ export default function BatchCreateUser({user, users, permissions, onFinish}: Pr
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent]);
|
||||
|
||||
useEffect(() => {
|
||||
const crossReferenceEmails = async () => {
|
||||
try {
|
||||
const response = await axios.post("/api/users/controller?op=crossRefEmails", {
|
||||
emails: infos.map((x) => x.email)
|
||||
});
|
||||
|
||||
const crossRefEmails = response.data;
|
||||
if (!!crossRefEmails) {
|
||||
const existingEmails = new Set(crossRefEmails.map((x: any)=> x.email));
|
||||
const dupes = infos.filter(info => existingEmails.has(info.email));
|
||||
const newUsersList = infos.filter(info => !existingEmails.has(info.email));
|
||||
setNewUsers(newUsersList);
|
||||
setDuplicatedUsers(dupes);
|
||||
} else {
|
||||
setNewUsers(infos);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
}
|
||||
};
|
||||
|
||||
if (infos.length > 0) {
|
||||
crossReferenceEmails();
|
||||
}
|
||||
}, [infos]);
|
||||
|
||||
const makeUsers = async () => {
|
||||
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
|
||||
if (!confirm(`You are about to add ${newUsers.length}, are you sure you want to continue?`)) return;
|
||||
if (!confirm(`You are about to add ${newUsers.length} user${newUsers.length !== 1 ? 's' : ''}, are you sure you want to continue?`)) return;
|
||||
|
||||
if (newUsers.length > 0) {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await axios.post("/api/batch_users", {users: newUsers.map((user) => ({...user, type, expiryDate}))});
|
||||
//await axios.post("/api/batch_users", {users: newUsers.map((user) => ({...user, type, expiryDate}))});
|
||||
toast.success(`Successfully added ${newUsers.length} user(s)!`);
|
||||
onFinish();
|
||||
} catch {
|
||||
@@ -249,7 +264,7 @@ export default function BatchCreateUser({user, users, permissions, onFinish}: Pr
|
||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
|
||||
{Object.keys(USER_TYPE_LABELS)
|
||||
.filter((x) => {
|
||||
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
|
||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||
// if (x === "corporate") console.log(list, perm, checkAccess(user, list, permissions, perm));
|
||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
||||
})
|
||||
@@ -260,8 +275,20 @@ export default function BatchCreateUser({user, users, permissions, onFinish}: Pr
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button className="my-auto" onClick={makeUsers} disabled={infos.length === 0}>
|
||||
Create
|
||||
{newUsers.length !== 0 && (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<span className="text-mti-gray-dim text-base font-normal">New Users:</span>
|
||||
<UserTable users={newUsers} />
|
||||
</div>
|
||||
)}
|
||||
{duplicatedUsers.length !== 0 && (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<span className="text-mti-gray-dim text-base font-normal">Duplicated Users:</span>
|
||||
<UserTable users={duplicatedUsers} />
|
||||
</div>
|
||||
)}
|
||||
<Button className="my-auto mt-4" onClick={makeUsers} disabled={newUsers.length === 0}>
|
||||
Create {newUsers.length !== 0 ? `${newUsers.length} New Users` : ''}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
Reference in New Issue
Block a user