Merge branch 'develop'

This commit is contained in:
Tiago Ribeiro
2024-01-20 15:24:38 +00:00
15 changed files with 131 additions and 34 deletions

View File

@@ -1,5 +1,5 @@
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user"; import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
import {FormEvent, useState} from "react"; import {FormEvent, useEffect, useState} from "react";
import countryCodes from "country-codes-list"; import countryCodes from "country-codes-list";
import {RadioGroup} from "@headlessui/react"; import {RadioGroup} from "@headlessui/react";
import Input from "./Low/Input"; import Input from "./Low/Input";
@@ -12,6 +12,8 @@ import {KeyedMutator} from "swr";
import CountrySelect from "./Low/CountrySelect"; import CountrySelect from "./Low/CountrySelect";
import GenderInput from "@/components/High/GenderInput"; import GenderInput from "@/components/High/GenderInput";
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput"; import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
import TimezoneSelect from "./Low/TImezoneSelect";
import moment from "moment";
interface Props { interface Props {
user: User; user: User;
@@ -25,6 +27,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
const [gender, setGender] = useState<Gender>(); const [gender, setGender] = useState<Gender>();
const [employment, setEmployment] = useState<EmploymentStatus>(); const [employment, setEmployment] = useState<EmploymentStatus>();
const [position, setPosition] = useState<string>(); const [position, setPosition] = useState<string>();
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [companyName, setCompanyName] = useState<string>(); const [companyName, setCompanyName] = useState<string>();
@@ -43,6 +46,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
employment: user.type === "corporate" ? undefined : employment, employment: user.type === "corporate" ? undefined : employment,
position: user.type === "corporate" ? position : undefined, position: user.type === "corporate" ? position : undefined,
passport_id, passport_id,
timezone,
}, },
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined, agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
}) })
@@ -94,6 +98,12 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
required required
/> />
)} )}
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Timezone</label>
<TimezoneSelect value={timezone} onChange={setTimezone} />
</div>
<GenderInput value={gender} onChange={setGender} /> <GenderInput value={gender} onChange={setGender} />
{user.type === "corporate" && ( {user.type === "corporate" && (
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required /> <Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />

View File

@@ -10,7 +10,7 @@ export const PERMISSIONS = {
developer: ["developer"], developer: ["developer"],
}, },
deleteUser: { deleteUser: {
student: ["teacher", "corporate", "developer", "admin"], student: ["corporate", "developer", "admin"],
teacher: ["corporate", "developer", "admin"], teacher: ["corporate", "developer", "admin"],
corporate: ["admin", "developer"], corporate: ["admin", "developer"],
admin: ["developer", "admin"], admin: ["developer", "admin"],

View File

@@ -7,13 +7,22 @@ import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils"; import {dateSorter} from "@/utils";
import moment from "moment"; import moment from "moment";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowLeft, BsBriefcaseFill, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPencilSquare, BsBank, BsCurrencyDollar} from "react-icons/bs"; import {
BsArrowLeft,
BsBriefcaseFill,
BsGlobeCentralSouthAsia,
BsPerson,
BsPersonFill,
BsPencilSquare,
BsBank,
BsCurrencyDollar,
} from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import IconCard from "./IconCard"; import IconCard from "./IconCard";
import useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers'; import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
interface Props { interface Props {
user: User; user: User;
@@ -161,7 +170,9 @@ export default function AdminDashboard({user}: Props) {
<BsArrowLeft className="text-xl" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
<h2 className="text-2xl font-semibold">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2> <h2 className="text-2xl font-semibold">
{paid ? "Payment Done" : "Pending Payment"} ({list.length})
</h2>
</div> </div>
<UserList user={user} filters={[filter]} /> <UserList user={user} filters={[filter]} />
</> </>
@@ -290,13 +301,7 @@ export default function AdminDashboard({user}: Props) {
} }
color="rose" color="rose"
/> />
<IconCard <IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
onClick={() => setPage("paymentdone")}
Icon={BsCurrencyDollar}
label="Payment Done"
value={done.length}
color="purple"
/>
<IconCard <IconCard
onClick={() => setPage("paymentpending")} onClick={() => setPage("paymentpending")}
Icon={BsCurrencyDollar} Icon={BsCurrencyDollar}
@@ -323,7 +328,9 @@ export default function AdminDashboard({user}: Props) {
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter((x) => x.type === "corporate") .filter((x) => x.type === "corporate")
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) .sort((a, b) => {
return dateSorter(a, b, "desc", "registrationDate");
})
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
))} ))}

View File

@@ -34,9 +34,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []); const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize})); const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize}));
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>( const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : new Date());
assignment ? moment(assignment.startDate).toDate() : moment().hours(0).minutes(0).add(1, "day").toDate(),
);
const [endDate, setEndDate] = useState<Date | null>( const [endDate, setEndDate] = useState<Date | null>(
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(), assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
); );
@@ -200,7 +198,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
)} )}
popperClassName="!z-20" popperClassName="!z-20"
filterDate={(date) => moment(date).isAfter(new Date())} filterTime={(date) => moment(date).isSameOrAfter(new Date())}
dateFormat="dd/MM/yyyy HH:mm" dateFormat="dd/MM/yyyy HH:mm"
selected={startDate} selected={startDate}
showTimeSelect showTimeSelect
@@ -216,7 +214,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
)} )}
popperClassName="!z-20" popperClassName="!z-20"
filterDate={(date) => moment(date).isAfter(startDate)} filterTime={(date) => moment(date).isAfter(startDate)}
dateFormat="dd/MM/yyyy HH:mm" dateFormat="dd/MM/yyyy HH:mm"
selected={endDate} selected={endDate}
showTimeSelect showTimeSelect

View File

@@ -2,7 +2,7 @@
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {Group, Stat, User} from "@/interfaces/user"; import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils"; import {dateSorter} from "@/utils";
import moment from "moment"; import moment from "moment";
@@ -20,6 +20,9 @@ import {
BsPersonFillGear, BsPersonFillGear,
BsPersonGear, BsPersonGear,
BsPencilSquare, BsPencilSquare,
BsPersonBadge,
BsPersonCheck,
BsPeople,
} from "react-icons/bs"; } from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
@@ -31,9 +34,10 @@ import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList"; import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import useCodes from "@/hooks/useCodes";
interface Props { interface Props {
user: User; user: CorporateUser;
} }
export default function CorporateDashboard({user}: Props) { export default function CorporateDashboard({user}: Props) {
@@ -43,6 +47,7 @@ export default function CorporateDashboard({user}: Props) {
const {stats} = useStats(); const {stats} = useStats();
const {users, reload} = useUsers(); const {users, reload} = useUsers();
const {codes} = useCodes(user.id);
const {groups} = useGroups(user.id); const {groups} = useGroups(user.id);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
@@ -187,7 +192,13 @@ export default function CorporateDashboard({user}: Props) {
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)} value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
color="purple" color="purple"
/> />
<IconCard onClick={() => setPage("groups")} Icon={BsPersonAdd} label="Groups" value={groups.length} color="purple" /> <IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
<IconCard
Icon={BsPersonCheck}
label="User Balance"
value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
color="purple"
/>
<IconCard <IconCard
Icon={BsClock} Icon={BsClock}
label="Expiration Date" label="Expiration Date"

View File

@@ -6,10 +6,11 @@ interface Props {
label: string; label: string;
value: string | number; value: string | number;
color: "purple" | "rose" | "red"; color: "purple" | "rose" | "red";
tooltip?: string;
onClick?: () => void; onClick?: () => void;
} }
export default function IconCard({Icon, label, value, color, onClick}: Props) { export default function IconCard({Icon, label, value, color, tooltip, onClick}: Props) {
const colorClasses: {[key in typeof color]: string} = { const colorClasses: {[key in typeof color]: string} = {
purple: "text-mti-purple-light", purple: "text-mti-purple-light",
red: "text-mti-red-light", red: "text-mti-red-light",
@@ -19,7 +20,11 @@ export default function IconCard({Icon, label, value, color, onClick}: Props) {
return ( return (
<div <div
onClick={onClick} onClick={onClick}
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"> className={clsx(
"bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300",
tooltip && "tooltip tooltip-bottom",
)}
data-tip={tooltip}>
<Icon className={clsx("text-6xl", colorClasses[color])} /> <Icon className={clsx("text-6xl", colorClasses[color])} />
<span className="flex flex-col gap-1 items-center text-xl"> <span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">{label}</span> <span className="text-lg">{label}</span>

View File

@@ -42,7 +42,7 @@ export default function StudentDashboard({user}: Props) {
const setAssignment = useExamStore((state) => state.setAssignment); const setAssignment = useExamStore((state) => state.setAssignment);
useEffect(() => { useEffect(() => {
getUserCorporate("IXdh9EQziAVXXh0jOiC5cPVlgS82").then(setCorporateUserToShow); getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]); }, [user]);
const startAssignment = (assignment: Assignment) => { const startAssignment = (assignment: Assignment) => {

View File

@@ -19,6 +19,7 @@ import {
BsEnvelopePaper, BsEnvelopePaper,
BsGlobeCentralSouthAsia, BsGlobeCentralSouthAsia,
BsPaperclip, BsPaperclip,
BsPeople,
BsPerson, BsPerson,
BsPersonAdd, BsPersonAdd,
BsPersonFill, BsPersonFill,
@@ -271,7 +272,7 @@ export default function TeacherDashboard({user}: Props) {
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)} value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
color="purple" color="purple"
/> />
<IconCard Icon={BsPersonAdd} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} /> <IconCard Icon={BsPeople} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} />
<div <div
onClick={() => setPage("assignments")} onClick={() => setPage("assignments")}
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"> className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">

21
src/hooks/useCodes.tsx Normal file
View File

@@ -0,0 +1,21 @@
import {Code, Group, User} from "@/interfaces/user";
import axios from "axios";
import {useEffect, useState} from "react";
export default function useCodes(creator?: string) {
const [codes, setCodes] = useState<Code[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Code[]>(`/api/code${creator ? `?creator=${creator}` : ""}`)
.then((response) => setCodes(response.data))
.finally(() => setIsLoading(false));
};
useEffect(getData, [creator]);
return {codes, isLoading, isError, reload: getData};
}

View File

@@ -127,5 +127,16 @@ export interface Group {
disableEditing?: boolean; disableEditing?: boolean;
} }
export interface Code {
code: string;
creator: string;
expiryDate: Date;
type: Type;
userId?: string;
email?: string;
name?: string;
passport_id?: string;
}
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent"; export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent";
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"]; export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"];

View File

@@ -14,6 +14,26 @@ const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res);
return res.status(404).json({ok: false});
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
return;
}
const {creator} = req.query as {creator?: string};
const q = query(collection(db, "codes"), where("creator", "==", creator));
const snapshot = await getDocs(creator ? q : collection(db, "codes"));
res.status(200).json(snapshot.docs.map((doc) => doc.data()));
}
async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"}); res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
return; return;

View File

@@ -69,7 +69,7 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
type: email.endsWith("@ecrop.dev") ? "developer" : codeData ? codeData.type : "student", type: email.endsWith("@ecrop.dev") ? "developer" : codeData ? codeData.type : "student",
subscriptionExpirationDate: codeData ? codeData.expiryDate : moment().subtract(1, "days").toISOString(), subscriptionExpirationDate: codeData ? codeData.expiryDate : moment().subtract(1, "days").toISOString(),
...(passport_id ? {demographicInformation: {passport_id}} : {}), ...(passport_id ? {demographicInformation: {passport_id}} : {}),
registrationDate: new Date(), registrationDate: new Date().toISOString(),
status: code ? "active" : "paymentDue", status: code ? "active" : "paymentDue",
}; };

View File

@@ -1,6 +1,6 @@
import {PERMISSIONS} from "@/constants/userPermissions"; import {PERMISSIONS} from "@/constants/userPermissions";
import {app, adminApp} from "@/firebase"; import {app, adminApp} from "@/firebase";
import {User} from "@/interfaces/user"; import {Group, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {collection, deleteDoc, doc, getDoc, getDocs, getFirestore, query, setDoc, where} from "firebase/firestore"; import {collection, deleteDoc, doc, getDoc, getDocs, getFirestore, query, setDoc, where} from "firebase/firestore";
import {getAuth} from "firebase-admin/auth"; import {getAuth} from "firebase-admin/auth";
@@ -43,6 +43,19 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const targetUser = {...docTargetUser.data(), id: docTargetUser.id} as User; const targetUser = {...docTargetUser.data(), id: docTargetUser.id} as User;
if (user.type === "corporate" && (targetUser.type === "student" || targetUser.type === "teacher")) {
res.json({ok: true});
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
await Promise.all([
...userParticipantGroup.docs
.filter((x) => (x.data() as Group).admin === user.id)
.map(async (x) => await setDoc(x.ref, {participants: x.data().participants.filter((y: string) => y !== id)}, {merge: true})),
]);
return;
}
const permission = PERMISSIONS.deleteUser[targetUser.type]; const permission = PERMISSIONS.deleteUser[targetUser.type];
if (!permission.includes(user.type)) { if (!permission.includes(user.type)) {
res.status(403).json({ok: false}); res.status(403).json({ok: false});

View File

@@ -82,7 +82,7 @@ function UserProfile({user, mutateUser}: Props) {
const [commercialRegistration, setCommercialRegistration] = useState<string | undefined>( const [commercialRegistration, setCommercialRegistration] = useState<string | undefined>(
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined, user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
); );
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || "UTC"); const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess());
const {groups} = useGroups(); const {groups} = useGroups();
const {users} = useUsers(); const {users} = useUsers();

View File

@@ -1,11 +1,11 @@
import moment from "moment"; import moment from "moment";
export function dateSorter(a: any, b: any, direction: "asc" | "desc", key: string) { export function dateSorter(a: any, b: any, direction: "asc" | "desc", key: string) {
if (!a[key] && b[key]) return direction === "asc" ? -1 : 1;
if (a[key] && !b[key]) return direction === "asc" ? 1 : -1;
if (!a[key] && !b[key]) return 0; if (!a[key] && !b[key]) return 0;
if (moment(a[key]).isAfter(b[key])) return direction === "asc" ? -1 : 1; if (a[key] && !b[key]) return direction === "asc" ? -1 : 1;
if (moment(b[key]).isAfter(a[key])) return direction === "asc" ? 1 : -1; if (!a[key] && b[key]) return direction === "asc" ? 1 : -1;
if (moment(a[key]).isAfter(b[key])) return direction === "asc" ? 1 : -1;
if (moment(b[key]).isAfter(a[key])) return direction === "asc" ? -1 : 1;
return 0; return 0;
} }