import { DocumentData, QueryConstraint, QueryDocumentSnapshot, QueryFieldFilterConstraint, QueryOrderByConstraint, QuerySnapshot, Unsubscribe, arrayRemove, collection, documentId, endAt, endBefore, getDocs, limit, limitToLast, onSnapshot, orderBy, query, startAfter, where } from "firebase/firestore"; import { useCallback, useEffect, useRef, useState } from "react"; import { defaultApplicantWorkflow, defaultInstituteWorkflow, defaultStudentWorkflow } from "./constants"; import { executeCallable, useExecuteCallableJob } from "./firebase/firebase"; import { db, storage } from "./firebase/firebaseConfig"; import FirebaseQuery from "./firebase/firebaseQuery"; import { getFiles, getFormsFromId, getPlacementbyId, getPlacementsWhere, getUserById } from "./firebase/readDatabase"; import { arrayUniqueValues, arraysEqual, convertDate, editNestedObject, getAccess, getDateDiff, getUniqueId, objectsEqual, quoteAlgoliaFilterIfNeeded, validateEmail } from "./firebase/util"; import { addPlacement, editPlacementStage, uploadFiles } from "./firebase/writeDatabase"; import { AlumniConvoUser, ApplicantStage, Application, ArrowObject, CohortData, CustomFormSchema, ExternalEvent, ExternalEventAttendee, FileItem, FlagCodes, InstituteData, OnboardingDocs, OrganisationAddress, PlacementListing, Products, ProviderContactData, ProviderData, QueryObject, QueryObjectConstraint, SchoolData, Sorts, StudentPlacementData, UserData, UserGroupData, WorkflowStage } from "./typeDefinitions"; import algoliasearch from "algoliasearch"; import {getDownloadURL, ref} from "firebase/storage"; import {objectsEqualNew} from "./util"; type StudentPlacementListParams = { user: UserData, student?: UserData, queryConstraint?: QueryConstraint[], ql?: number // query limit } const DEFAULTQUERYLIMIT = 5; export function useStudentPlacementList({user, student, queryConstraint, ql=DEFAULTQUERYLIMIT}:StudentPlacementListParams) { const [loadMoreIcon, setLoadMoreIcon] = useState(true); const [query, setQuery] = useState(""); const [initialQueryLimit, setInitialQueryLimit] = useState(ql); const [queryConstraints, setQueryConstraints] = useState(queryConstraint || []); const [placements, setPlacements] = useState<{[key:string]:StudentPlacementData}>(); const [startPlacementAfter, setStartPlacementAfter] = useState>(); // uid, pId const [studentId, setStudentId] = useState(); const reset = () => { setPlacements(undefined); setStartPlacementAfter(undefined); }; useEffect(() => { if (user.userType === "Students") { setStudentId(user.id); return; } if (user.oId !== student?.oId || user.userType !== "Staff") { setStudentId(undefined); return; } if (user.userGroup === "admin") { setStudentId(student.id); return; } if (!user.viewCohorts || !user.viewStudents || user.viewCohorts === "none") { setStudentId(undefined); return; } if (user.viewCohorts === "all" && user.viewStudents == "all") { setStudentId(student.id); } if (user.viewCohorts === "some" && user.visibleCohorts?.includes(student.cohort || "")) { if (user.viewStudents === "all") { setStudentId(student.id); return; } if (user.studentFilterValues?.includes(student.details[user.studentFilter || ""])) { setStudentId(student.id); return; } setStudentId(undefined); } }, []); const changeQueryConstraints = (e: QueryConstraint[]) => { setQueryConstraints([...(queryConstraint || []), ...e]); }; useEffect(() => { if (!studentId) return; setPlacements(undefined); setStartPlacementAfter(undefined); loadMorePlacements(undefined); }, [studentId, query, queryConstraints]); const loadMorePlacements = async ( fStartPlacementAfter:QueryDocumentSnapshot|undefined=startPlacementAfter, ) => { if (!studentId) return; setLoadMoreIcon(true); // If can view all, query placements directly. Otherwise query students. let fPlacements:{[key:string]: StudentPlacementData} = {}; const queryConstraintOrdered = Boolean(queryConstraints && queryConstraints.find((v) => v.type === "orderBy")); const constraints:QueryConstraint[] = fStartPlacementAfter ? [limit(placements ? DEFAULTQUERYLIMIT : initialQueryLimit), ...(queryConstraintOrdered ? [] : [orderBy(documentId())]), startAfter(fStartPlacementAfter)] : [limit(placements ? DEFAULTQUERYLIMIT : initialQueryLimit), ...(queryConstraintOrdered ? [] : [orderBy(documentId())])]; queryConstraints && constraints.unshift(...queryConstraints); query !== "" && constraints.unshift(where("name", "==", query)); constraints.unshift(where("uid", "==", studentId)); const placementsQuery = await getPlacementsWhere({w: constraints, oId: user?.oId, raw: true}) as QuerySnapshot; const placementsWithStudentData:[string, StudentPlacementData][] = placementsQuery.empty ? [] : placementsQuery.docs.map((placement) => [placement.id, {...placement.data() as StudentPlacementData, id: placement.id}] ); fPlacements = {...fPlacements, ...Object.fromEntries(placementsWithStudentData)}; if (Object.keys(fPlacements).length === (placements ? DEFAULTQUERYLIMIT : initialQueryLimit)) { const lastPlacement = placementsQuery.docs[placementsQuery.docs.length-1]; if (lastPlacement) { setStartPlacementAfter(lastPlacement); } setPlacements((p) => ({...p, ...fPlacements})); return; } setPlacements((p) => ({...p, ...fPlacements})); setLoadMoreIcon(false); return; }; return ({...{placements, loadMoreIcon, loadMorePlacements, setQuery, setInitialQueryLimit, reset, changeQueryConstraints}}); } type InstitutePlacementParams = { id?: string, user: UserData, cohort?: string, queryConstraint?: QueryConstraint[], ql?: number, inProgress?: boolean } type NewInstitutePlacementParams = { id?: string, user: UserData, oId?: string, cohort?: string, queryConstraints?: QueryObjectConstraint[], ql?: number, inProgress?: boolean, view: "list"|"table", filters?: FilterObject, initialSort?: string, initialSearch?: string uid?: string, } export function useOldInstitutePlacementList({id, user, cohort, queryConstraint, ql=DEFAULTQUERYLIMIT, inProgress}:InstitutePlacementParams) { const [loadMoreIcon, setLoadMoreIcon] = useState(true); const [query, setQuery] = useState(); const [initialQueryLimit, setInitialQueryLimit] = useState(ql); const [queryConstraints, setQueryConstraints] = useState(queryConstraint || []); const [oId, setOId] = useState(); const [placements, setPlacements] = useState<{[key:string]:StudentPlacementData & {student: UserData}}>(); const [startPlacementAfter, setStartPlacementAfter] = useState<[QueryDocumentSnapshot|undefined, number]>(); // uid, pId const algoliaClient = algoliasearch(process.env.NODE_ENV === "development" ? "A0ZB50I7VS" : "A0ZB50I7VS", user.algoliaKey); const placementsIndex = algoliaClient.initIndex("placements"); useEffect(() => { if (user.product !== "institutes" || user.userType !== "Staff") { setOId(undefined); } setOId(user.oId); }, [user]); useEffect(() => { if (query === undefined) return; if (!query) { reset(); loadMorePlacements([undefined, 0]); } const searchPlacements = async () => { let placementsFound:{[key: string]: StudentPlacementData& { student: UserData; }} = {} let placementSearchString = `oId:${user.oId} AND ` + (inProgress !== undefined ? (inProgress ? "inProgress:true" : "completed:true" ) : "") if (cohort) { placementSearchString = placementSearchString + ` AND cohort:${cohort}`; } const searchPlacementHits = await placementsIndex.search(query, { filters: placementSearchString }); const i = (searchPlacementHits ? (await Promise.all(searchPlacementHits.hits.map(async (hit) => { const student = hit.uid === user.id ? user : (await getUserById(hit.uid) .catch(() => false)) as UserData; if (!student) return; console.log("STUDENNT", hit.objectID, student) const finalData = {...(hit as StudentPlacementData), student: student}; return [hit.objectID, finalData]; }))) : []).filter((e) => e !== undefined) as [string, StudentPlacementData&{student: UserData}][]; console.log("FOUND", i.length, "placement"); placementsFound = {...Object.fromEntries(i), ...placementsFound} console.log("found", placementsFound); setPlacements(placementsFound); setLoadMoreIcon(false); } searchPlacements() }, [query]); const reset = () => { setPlacements(undefined); setStartPlacementAfter([undefined, 0]); }; const changeQueryConstraints = (e: QueryConstraint[]) => { setQueryConstraints([...(queryConstraint || []), ...e]); }; useEffect(() => { setPlacements(undefined); setStartPlacementAfter([undefined, 0]); loadMorePlacements([undefined, 0]); }, [queryConstraints]); const loadMorePlacements = async ( fStartPlacementAfter:[QueryDocumentSnapshot|undefined, number]|undefined=startPlacementAfter, ) => { if (query || user.viewCohorts === "none" || !oId) { setLoadMoreIcon(false); return; } setLoadMoreIcon(true); let fPlacements:{[key:string]: StudentPlacementData&{student:UserData}} = {}; if (((user.userGroup === "admin" || (cohort && user.viewCohorts === "some" && user?.visibleCohorts?.includes(cohort as string)) || (user.viewCohorts === "all" && user.viewStudents === "all")))) { const queryConstraintOrdered = Boolean(queryConstraints && queryConstraints.find((v) => v.type === "orderBy")); const constraints:QueryConstraint[] = fStartPlacementAfter?.length === 2 && fStartPlacementAfter[0] ? [limit(placements ? DEFAULTQUERYLIMIT : initialQueryLimit), ...(queryConstraintOrdered ? [] : [orderBy(documentId())]), startAfter(fStartPlacementAfter[0])] : [limit(placements ? DEFAULTQUERYLIMIT : initialQueryLimit), ...(queryConstraintOrdered ? [] : [orderBy(documentId())])]; queryConstraints && constraints.unshift(...queryConstraints); // query && constraints.unshift(where("name", "==", query)); cohort && constraints.unshift(where("cohort", "==", cohort)); const placementsQuery = await getPlacementsWhere({w: constraints, oId: oId, raw: true}) as QuerySnapshot; console.log("PLACEMENTS RETRIEVED", placementsQuery.size); const placementsWithStudentData:([string, StudentPlacementData&{student:UserData}]|false)[] = placementsQuery.empty ? [] : await Promise.all(placementsQuery.docs.map(async (placement) => { const pData = placement.data() as StudentPlacementData; const student = pData.uid === user.id ? user : (await getUserById(pData.uid).catch(() => false)) as UserData; console.log("STUDENT", student, "PLACEMENT", id); // if (!student) return false; if (user.viewStudents === "some") { if (!(user.studentFilter && user.studentFilterValues)) return false; if (!user.studentFilterValues.includes(student.details[user.studentFilter])) { return false; } } return [placement.id, {...pData, student: student, id: placement.id}]; })); const filtered = placementsWithStudentData.filter((el) => el) as [string, StudentPlacementData & {student: UserData}][]; fPlacements = {...fPlacements, ...Object.fromEntries(filtered)}; if (Object.keys(fPlacements).length === (placements ? DEFAULTQUERYLIMIT : initialQueryLimit)) { const lastPlacement = placementsQuery.docs[placementsQuery.docs.length-1]; if (lastPlacement) { setStartPlacementAfter(() => ([lastPlacement, 0])); } setPlacements((p) => ({...p, ...fPlacements})); return; } if (!query) { setPlacements((p) => ({...p, ...fPlacements})); setLoadMoreIcon(false); return; } } }; return ({...{placements, loadMoreIcon, loadMorePlacements, setQuery, setInitialQueryLimit, reset, changeQueryConstraints}}); } export function useNewInstitutePlacementList({id, user, oId, uid, filters, initialSort, initialSearch, view, cohort, queryConstraints, ql=DEFAULTQUERYLIMIT, inProgress}:NewInstitutePlacementParams) { const [query, setQuery] = useState(); const sorts:Sorts = { ["Student Forename - Asc"]: { value: "studentForename", direction: "asc", }, ["Student Forename - Desc"]: { value: "studentForename", direction: "desc", }, ["Student Surname - Asc"]: { value: "studentSurname", direction: "asc", }, ["Student Surname - Desc"]: { value: "studentSurname", direction: "desc", }, ["Student Email - Asc"]: { value: "studentEmail", direction: "asc", }, ["Student Email - Desc"]: { value: "studentEmail", direction: "desc", }, ["Provider email - Asc"]: { value: "providerEmail", direction: "asc", }, ["Provider email - Desc"]: { value: "providerEmail", direction: "desc", } } const additionalProcessing = async (k: string, placement:StudentPlacementData) : Promise<[string, unknown]|undefined> => { if (user.userGroup !== "admin" && user.viewStudents === "some") { if (!(user.studentFilter && user.studentFilterValues)) return undefined; const student = await getUserById(placement.uid).catch(() => false) as UserData|false; if (!student) { console.log("No student"); return }; if (!user.studentFilterValues.includes(student.details[user.studentFilter])) { console.log("filter not included. Filteres: ", user.studentFilterValues, "value", user.studentFilter, "-", student.details[user.studentFilter]); return; } } return [k, {...placement, id: k}]; } const {tableData, pageUp, pageDown, setFilters, page, setView, loading, updateSearch, updateSort, sort} = useDataViewerPaginator({view, filters, sorts, queryLimit: ql, initialSort, initialSearch, data: query, additionalEntryProcessing: additionalProcessing, onSearch: async (s, sort, page, filters, limit) => await algoliaPlacementSearch(query || [], user, s, sort, page, filters, limit, cohort, inProgress)}) useEffect(() => { console.log("SET QUERY", user, queryConstraints, cohort); // Sets the query of for the DataViewerPaginator if(user.product !== "institutes" || user.userType !== "Staff") { setQuery(undefined); return; } if (((user.userGroup === "admin" || (cohort && user.viewCohorts === "some" && user?.visibleCohorts?.includes(cohort as string)) || (user.viewCohorts === "all" && user.viewStudents !== "none")))) { const constraints:QueryObjectConstraint[] = [["oId", "==", oId || user.oId], ["draft", "==", false]]; cohort && constraints.push(["cohort", "==", cohort]); uid && constraints.push(["uid", "==", uid]); queryConstraints && constraints.unshift(...queryConstraints); inProgress !== undefined && constraints.push(["inProgress", "==", inProgress]); console.log("PLACEMENT CONSTRAINTS", constraints); setQuery([{ path: ["placements"], where: constraints }]) return; } setQuery(undefined) }, [user, queryConstraints, cohort]) return {tableData, page, loading, updateSearch, setFilters, setView, pageUp, pageDown, sorts, updateSort, sort} } type AlumniPaginatorParams = { user?: UserData, alumniConvoUser?: AlumniConvoUser, school?: string, queryConstraints?: QueryObjectConstraint[], ql?: number, view: "list"|"table", filters?: FilterObject, } export function useAlumniPaginator({user, alumniConvoUser, filters, view, school, queryConstraints, ql=DEFAULTQUERYLIMIT}:AlumniPaginatorParams) { const [query, setQuery] = useState(); const sorts:Sorts = {} const {tableData, pageUp, pageDown, setFilters, page, setView, loading} = useDataViewerPaginator({view, filters, sorts, queryLimit: ql, data: query}) const firebaseQuery = new FirebaseQuery(); useEffect(() => { const createQuery = async () => { // Sets the query of for the DataViewerPaginator const getQueryAccess = async () => { const constraints:QueryObjectConstraint[] = []; if (user) { constraints.push(["oId", "==", user.oId]); if (user.userGroup === "admin" && user.userType === "Staff") return constraints; if (user.userType === "Staff") { if (user.viewSchools === "all") return constraints; if (user.viewSchools === "none") return false; if (user.viewSchools === "some") { if (!school) return false; if (user?.visibleSchools?.includes(school as string)) return constraints; } return false; } return false; } if (alumniConvoUser) { constraints.push(["oId", "==", alumniConvoUser.oId]); console.log("ALUMNI CONVO USER"); if ((school || alumniConvoUser.schoolId) && school === alumniConvoUser.schoolId) return constraints; console.log("ALUMNI ACCESS TRUE"); if (alumniConvoUser.schoolId) { const school = await firebaseQuery.getDocData(["schools", alumniConvoUser.schoolId]) as SchoolData; console.log("SCHOOL ACCESS", school, school.alumniConversations); return school.alumniConversations ? constraints : false; } else { const institute = await firebaseQuery.getDocData(["institutes", alumniConvoUser.oId]) as InstituteData; return institute.alumniConversations ? constraints : false; } } return false; } const constraints = await getQueryAccess(); console.log("CONSTRAINTS", constraints); if (!constraints) return; school && constraints.push(["schoolId", "==", school]); queryConstraints && constraints.unshift(...queryConstraints); return constraints; } console.log("Creating query"); createQuery().then((constraints) => { setQuery([{ path: ["alumni"], where: constraints }]) }) }, [user, queryConstraints, school]) useEffect(() => { console.log("Alumni data", query, tableData); }, [tableData]); return {tableData, page, loading, setFilters, setView, pageUp, pageDown, sorts} } const algoliaPlacementSearch = async (data: QueryObject[], user: UserData, query?: string, sort?: [string, {value: string, direction: "asc"|"desc"}], page?: number, filters?: FilterObject, limit?: number, cohort?: string, inProgress?: boolean) => { const algoliaClient = algoliasearch(process.env.NODE_ENV === "development" ? "A0ZB50I7VS" : "A0ZB50I7VS", user.algoliaKey); const placementsIndex = algoliaClient.initIndex(sort ? Object.values(sort[1]).join("_") : "placements"); // const usersIndex = algoliaClient.initIndex("users"); // let userSearchString = `userType:Students AND status:active AND oId:${user.oId} AND product:${user.product}` // if (cohort) { // userSearchString = userSearchString + ` AND cohort:${cohort}`; // } // if (user.product === "institutes" && user.userType === "Staff") { // const searchStudentHits = await usersIndex.search(query, { // filters: userSearchString, // hitsPerPage: limit, // page: page // }); // if (searchStudentHits) { // console.log("FOUND", searchStudentHits.hits.length, "students"); // await Promise.all(searchStudentHits.hits.map(async (hit) => { // console.log("STUDENT", hit.objectID); // const constraints = [...(cohort ? [where("cohort", "==", cohort)] : [])] // if (inProgress !== undefined) { // constraints.push(inProgress ? where("inProgress", "==", true) : where("completed", "==", true)); // } // const fPlacements = await getPlacementsWhere({w: constraints, uid: hit.objectID, oId: hit.oId}) as {[key: string]: StudentPlacementData}; // console.log("PLACEMENTS", fPlacements) // Object.entries(fPlacements).forEach(([k, v]) => { // placementsFound[k] = {...v, student: hit}; // }) // })) // } // } if (data.length === 0) return {}; const constraints = data[0].where; if (!constraints?.length) return {}; let placementSearchString = constraints.map(([k, e, v]) => { if (e === "==") return `${k}:${quoteAlgoliaFilterIfNeeded(v)}`; // Equality check if (e === "!=") return `${k}:-${quoteAlgoliaFilterIfNeeded(v)}`; // Not equal check if (e === "<") return `${k}:<${quoteAlgoliaFilterIfNeeded(v)}`; // Less than check if (e === "<=") return `${k}:<=${quoteAlgoliaFilterIfNeeded(v)}`; // Less than or equal check if (e === ">") return `${k}:>${quoteAlgoliaFilterIfNeeded(v)}`; // Greater than check if (e === ">=") return `${k}:>=${quoteAlgoliaFilterIfNeeded(v)}`; // Greater than or equal check if (e === "array-contains") return `${k}:${quoteAlgoliaFilterIfNeeded(v)}`; // Array contains check (string format) if (e === "array-contains-any") return `${k}:"${(v as string[]).join('","')}"`; // Array contains any of the values (string format) if (e === "in") return `(${(v as string[]).map((value) => `${k}:${quoteAlgoliaFilterIfNeeded(value)}`).join(' OR ')})`; // In check if (e === "not-in") return (v as string[]).map((value) => `${k}:-${quoteAlgoliaFilterIfNeeded(value)}`).join(' AND '); // In check return; }).join(" AND "); filters && Object.entries(filters).filter(([, filter]) => filter.value).map(([id, filter]) => { placementSearchString = placementSearchString + ` AND ${id}:${quoteAlgoliaFilterIfNeeded(filter.value)}`; }); const options = { filters: placementSearchString, hitsPerPage: limit, page: page ? page - 1 : undefined, } const searchPlacementHits = await placementsIndex.search(query || "", options); console.log(searchPlacementHits.hits); const i = (searchPlacementHits ? (await Promise.all(searchPlacementHits.hits.map(async (hit) => { return [hit.objectID, hit]; }))) : []).filter((e) => e !== undefined) as [string, StudentPlacementData][]; return Object.fromEntries(i) } export function useVeryOldInstitutePlacementList({user, cohort, queryConstraint, ql=DEFAULTQUERYLIMIT}:InstitutePlacementParams) { const [loadMoreIcon, setLoadMoreIcon] = useState(true); const [query, setQuery] = useState(""); const [initialQueryLimit, setInitialQueryLimit] = useState(ql); const [queryConstraints, setQueryConstraints] = useState(queryConstraint || []); const [oId, setOId] = useState(); const firebaseQuery = new FirebaseQuery(); const [placements, setPlacements] = useState<{[key:string]:StudentPlacementData & {student: UserData}}>(); const [startPlacementAfter, setStartPlacementAfter] = useState<["placement"|"student"|undefined, string|undefined, QueryDocumentSnapshot|undefined, number]>(); // uid, pId const [loadedStudents, setLoadedStudents] = useState([]); useEffect(() => { if (user.product !== "institutes" || user.userType !== "Staff") { setOId(undefined); } setOId(user.oId); }, [user]); const reset = () => { setPlacements(undefined); setLoadedStudents([]); setStartPlacementAfter(["placement", undefined, undefined, 0]); }; const changeQueryConstraints = (e: QueryConstraint[]) => { setQueryConstraints([...(queryConstraint || []), ...e]); }; useEffect(() => { setPlacements(undefined); setLoadedStudents([]); setStartPlacementAfter(["placement", undefined, undefined, 0]); loadMorePlacements(["placement", undefined, undefined, 0], []); }, [query, queryConstraints]); const loadMorePlacements = async ( fStartPlacementAfter:["placement"|"student"|undefined, string|undefined, QueryDocumentSnapshot|undefined, number]|undefined=startPlacementAfter, fLoadedStudents:string[]=loadedStudents ) => { // Only false when at end of results // console.log("Load placements"); if (user.viewCohorts === "none" || !oId) { setLoadMoreIcon(false); return; } setLoadMoreIcon(true); // If can view all, query placements directly. Otherwise query students. let fPlacements:{[key:string]: StudentPlacementData&{student:UserData}} = {}; if (((user.userGroup === "admin" || (cohort && user.viewCohorts === "some" && user?.visibleCohorts?.includes(cohort as string)) || (user.viewCohorts === "all" && user.viewStudents === "all") || query) && (fStartPlacementAfter && fStartPlacementAfter[0] === "placement"))) { const queryConstraintOrdered = Boolean(queryConstraints && queryConstraints.find((v) => v.type === "orderBy")); const constraints:QueryConstraint[] = fStartPlacementAfter?.length === 4 && fStartPlacementAfter[1] ? [limit(placements ? DEFAULTQUERYLIMIT : initialQueryLimit), ...(queryConstraintOrdered ? [] : [orderBy(documentId())]), startAfter(fStartPlacementAfter[2])] : [limit(placements ? DEFAULTQUERYLIMIT : initialQueryLimit), ...(queryConstraintOrdered ? [] : [orderBy(documentId())])]; queryConstraints && constraints.unshift(...queryConstraints); query !== "" && constraints.unshift(where("name", "==", query)); cohort && constraints.unshift(where("cohort", "==", cohort)); const placementsQuery = await getPlacementsWhere({w: constraints, oId: oId, raw: true}) as QuerySnapshot; console.log("placementsQuery", placementsQuery); const placementsWithStudentData:([string, StudentPlacementData&{student:UserData}]|false)[] = placementsQuery.empty ? [] : await Promise.all(placementsQuery.docs.map(async (placement) => { const pData = placement.data() as StudentPlacementData; const student = pData.uid === user.id ? user : (await getUserById(pData.uid).catch(() => false)) as UserData; if (user.viewStudents === "some") { if (!(user.studentFilter && user.studentFilterValues)) return false; if (!user.studentFilterValues.includes(student.details[user.studentFilter])) { return false; } } return [placement.id, {...pData, student: student, id: placement.id}]; })); console.log("placementsWithStudentData", placementsWithStudentData); const filtered = placementsWithStudentData.filter((el) => el) as [string, StudentPlacementData & {student: UserData}][]; fPlacements = {...fPlacements, ...Object.fromEntries(filtered)}; if (Object.keys(fPlacements).length === (placements ? DEFAULTQUERYLIMIT : initialQueryLimit)) { console.log("More to come, setting"); const lastPlacement = placementsQuery.docs[placementsQuery.docs.length-1]; if (lastPlacement) { setStartPlacementAfter(() => (["placement", lastPlacement.data().uid, lastPlacement, 0])); } setPlacements((p) => ({...p, ...fPlacements})); return; } if (!query) { console.log("no query, finishing") setPlacements((p) => ({...p, ...fPlacements})); setLoadMoreIcon(false); return; } } if (!(user.studentFilterValues && user.studentFilter && user.viewStudents === "some")) { console.log("fenaibn"); return; }; const getStudentPlacements = async (ffStartPlacementAfter=fStartPlacementAfter, ffPlacements=fPlacements) : Promise<{sLoadedStudents:string[], sPlacements: { [k: string]: StudentPlacementData&{student: UserData}}, sStartPlacementAfter: ["placement"|"student"|undefined, string | undefined, QueryDocumentSnapshot | undefined, number]|undefined}> => { if (ffStartPlacementAfter && ffStartPlacementAfter[0] === "placement") { ffStartPlacementAfter = [undefined, undefined, undefined, 0]; } // console.log("ff", ffStartPlacementAfter); const constraints:QueryConstraint[] = ffStartPlacementAfter && ffStartPlacementAfter[1] ? [limit(1), orderBy(documentId()), startAfter(ffStartPlacementAfter[1])] : [limit(1), orderBy(documentId())]; constraints.push( where("oId", "==", oId), where("userType", "==", "Students") ); if (ffStartPlacementAfter && ffStartPlacementAfter[3]) { const currentFilter = user?.studentFilterValues?.[ffStartPlacementAfter[3]]; if (currentFilter && user.studentFilter) { constraints.push(where(user.studentFilter, "==", currentFilter)); } } // console.log("CONSTERAINTS: ", constraints); const student:[string, UserData] = Object.entries((await firebaseQuery.getDocsWhere("users", constraints)) as {[key:string]: UserData})[0]; // console.log("student", student); // If there is no students but more filters if (!student && ffStartPlacementAfter && ffStartPlacementAfter[3] + 1 < (user?.studentFilterValues || []).length) { // console.log("No more students. Calling recursion"); return await getStudentPlacements(["student", undefined, undefined, ffStartPlacementAfter[3]+1], ffPlacements); } if (!student) { // console.log("No more students or queries. Returning."); setLoadMoreIcon(false); return {sLoadedStudents: fLoadedStudents, sPlacements: ffPlacements, sStartPlacementAfter: ffStartPlacementAfter}; } if (fLoadedStudents.includes(student[0]) && ffStartPlacementAfter) { return await getStudentPlacements(["student", student[0], undefined, ffStartPlacementAfter[3]], ffPlacements); } fLoadedStudents.push(student[0]); const studentConstraints:QueryConstraint[] = ffStartPlacementAfter && ffStartPlacementAfter[2] ? [ limit(placements ? DEFAULTQUERYLIMIT : initialQueryLimit), orderBy(documentId()), where("uid", "==", student[0]), startAfter(ffStartPlacementAfter[2])] : [limit(placements ? DEFAULTQUERYLIMIT : initialQueryLimit), orderBy(documentId()), where("uid", "==", student[0])]; // console.log("QCCCC", queryConstraints); queryConstraints && studentConstraints.unshift(...queryConstraints); const studentPlacements = await getPlacementsWhere({w: studentConstraints, oId: oId, raw: true}) as QuerySnapshot; // console.log('SP', studentPlacements) const placementsWithStudentData:[string, StudentPlacementData&{student:UserData}][] = (studentPlacements.docs.map((placement) => [placement.id, {...placement.data() as StudentPlacementData, student: student[1], id: placement.id}] )); ffPlacements = {...ffPlacements, ...Object.fromEntries(placementsWithStudentData)}; if (Object.keys(ffPlacements).length < (placements ? DEFAULTQUERYLIMIT : initialQueryLimit)) { // console.log("Below query length, calling recursion") return await getStudentPlacements(["student", student[0], undefined, ffStartPlacementAfter ? ffStartPlacementAfter[3] : 0], ffPlacements); } const lastItem = studentPlacements.docs[studentPlacements.docs.length-1]; return {sLoadedStudents: fLoadedStudents, sPlacements: ffPlacements, sStartPlacementAfter: ["student", ffStartPlacementAfter ? ffStartPlacementAfter[1] : undefined, lastItem, ffStartPlacementAfter ? ffStartPlacementAfter[3] : 0]}; // Get placements from user after current user. // If number of placements matches query, return new placements and setStartPlacement as no update and current user. // If number of placements below query, recursively call function with setStartPlacement as new and no placement one }; // console.log("Call student placements"); const {sPlacements, sStartPlacementAfter, sLoadedStudents} = await getStudentPlacements(); setLoadedStudents((s) => s ? s.concat(sLoadedStudents.filter((item:string) => s.indexOf(item) < 0)) : sLoadedStudents); setPlacements((p) => ({...p, ...fPlacements, ...sPlacements})); setStartPlacementAfter(sStartPlacementAfter); return; /** * if(!loadingIcon) return * * If user can view all, get placements matching title, limit 5. If 5, return and set as last placement. * On scroll bottom, call searchPlacements again, but this time there will be a startAfter. * Query on startAfter, limit 5. If there are 5, return. * * If less than 5, continue. * * If no query and view all, set loading icon to false, end of results. * * There may be a query, or the user can view only staff roles. * At this point, query users based on the staff roles and forename and surname with query. * Limit students results to 5. * For each student, get 5 placements and add to total placements. * When adding placements, make sure to remove any duplicates from the existing ones. * At end of each student, check if >= 5 placements. * If true, set role query at currentPos, student startAt as current student and placement startAfter as prev placement. Return * * Otherwise, it will keep looping. * * When scroll bottom this time, only get placements if studentPos is null. If it is a value, we must be * querying based on student. All individual placements are in the list. * Lots of returning going on so if gets to end of function, setLoadingIcon to false, end of results. When scroll bottom, loadingIcon is false, so return * * * How to restart search? on query or onLoad, reset all queryCursors and placements and call load. * This can be one useEffect listening to query. Will reset on start (but no need to) and whenever query changes. // Search users, limit to 5. Go through each until end, then extend limit by 5 each time. // For each user, query 5 placements at a time. When at end, increment user position by 1. Continue to append. // Implement this all into the filters, so one search tool. // When search changes, reset placements. // console.log("s", students); // Search students. For each student, get placements. Limit and note student id. Start after on scroll. Should be easy. */ }; return ({...{placements, loadMoreIcon, loadMorePlacements, setQuery, setInitialQueryLimit, reset, changeQueryConstraints}}); } export function useFilterTablePaginator({data}:{data:{[key:string]:{[key:string]: unknown}}|QueryObject[]}) { const [tableData, setTableData] = useState<{[key:string]:{[key:string]: unknown}}>(Array.isArray(data) ? Object.fromEntries(Object.entries(data).slice(0, 10)) : {}); const [page, setPage] = useState([1, 0]); const [filters, setFilters] = useState<{[key:string]: unknown}>(); const [queryAnchor, setQueryAnchor] = useState<{startKey: string, endKey: string, startQueryPos: number, endQueryPos: number}>({startKey: "", endKey: "", startQueryPos: 0, endQueryPos: 0}); const [prevEntryIds, setPrevEntryIds] = useState<{[key:string]:number}>({}); const [dataListenerUnsubscribe, setDataListenerUnsubscribe] = useState(); const getDataFromQuery = async ( itemList: {[key: string]: any} = {}, currentQueryAnchor=queryAnchor, cursorDirection?:"increase"|"decrease"|undefined, prevEntries=prevEntryIds, loadMoreFromQuery=false):Promise => { if (!Array.isArray(data)) { setTableData(Object.fromEntries(Object.entries(data).slice((page[0] - 1)*10, page[0]*10))); return; } let cursorPos:number; if (page[0] > page[1]) { cursorPos = currentQueryAnchor.endQueryPos; } else { cursorPos = currentQueryAnchor.startQueryPos; } const querySchema:QueryObject = data[cursorPos]; const createQuery = (queryData:QueryObject) => { const constraints:any[] = []; queryData.where && queryData.where.forEach((w) => { constraints.push(where(...w)); }); filters && Object.entries(filters).forEach(([key, value]) => { constraints.push(where(key, "==", value)); }); constraints.push(orderBy(queryData.orderBy ? queryData.orderBy === "documentId" ? documentId() : queryData.orderBy : documentId())); if (page[0] > page[1] && !cursorDirection) { // Going up currentQueryAnchor.endKey && constraints.push(startAfter(currentQueryAnchor.endKey)); constraints.push(limit(10)); if (!loadMoreFromQuery) { currentQueryAnchor = {...currentQueryAnchor, startQueryPos: currentQueryAnchor.endQueryPos}; } } else if (page[0] < page[1] && !cursorDirection) { // Going down if (!loadMoreFromQuery) { currentQueryAnchor = {...currentQueryAnchor, endQueryPos: currentQueryAnchor.startQueryPos}; } constraints.push(limitToLast(10)); if (currentQueryAnchor.startKey) { currentQueryAnchor.startKey && constraints.push(endBefore(currentQueryAnchor.startKey)); } else { currentQueryAnchor.startKey && constraints.push(endAt(currentQueryAnchor.startKey)); } } else { if (cursorDirection === "decrease") { constraints.push(limitToLast(10)); } else { constraints.push(limit(10)); } } return constraints; }; const constraints = createQuery(querySchema); // console.log(queryId, "constraints", constraints) const q = query(collection(db, ...(querySchema.path as [any])), ...(constraints)); const queryResults:{[key:string]: unknown} = {}; const queryData = await getDocs(q); // console.log("queryData.size", queryData.size) const reverseIfBack = (docs:QueryDocumentSnapshot[]) => { if (page[0] < page[1]) { return docs.reverse(); } return docs; }; let index = 0; reverseIfBack(queryData.docs).forEach((doc: QueryDocumentSnapshot) => { if (Object.keys(queryResults).length + Object.keys(itemList).length === 10) { return; } let position = Object.keys(itemList).length+(page[0]-1)*10+index+1; if (page[0] < page[1]) { position = (page[0])*10-index-Object.keys(itemList).length; } // console.log(index, "doc.id", doc.id, position, "E", prevEntries[doc.id]) if (itemList[doc.id] || (prevEntries[doc.id] && prevEntries[doc.id] !== position)) { console.log("Removing ", doc.id, ": E=", prevEntries[doc.id], ", G=", position); return; } const item = doc.data(); item.id = doc.id; queryResults[doc.id] = item; index = index+1; if (prevEntries[doc.id]) return; prevEntries[doc.id] = position; }); if (cursorDirection === "decrease" || page[0] < page[1]) { itemList = {...Object.fromEntries(Object.entries(queryResults).reverse()), ...itemList}; } else { itemList = {...itemList, ...queryResults}; } setPrevEntryIds(prevEntries); if (queryData.size < 10 && Object.keys(itemList).length < 10) { if (page[0] > page[1] && cursorPos+1 < data.length) { console.log("Increase query index"); return getDataFromQuery(itemList, {...currentQueryAnchor, endQueryPos: currentQueryAnchor.endQueryPos+1}, "increase", prevEntries); } else if (page[0] < page[1] && cursorPos > 0) { console.log("Decrease query index"); return getDataFromQuery(itemList, {...currentQueryAnchor, startQueryPos: currentQueryAnchor.startQueryPos-1}, "decrease", prevEntries); } } if (Object.keys(itemList).length < 10 && queryData.size === 10) { console.log("Shorter than ten"); // if(loadMoreFromQuery){return} return getDataFromQuery(itemList, {...currentQueryAnchor, startKey: Object.keys(itemList)[0], endKey: Object.keys(itemList).slice(-1)[0]}, undefined, prevEntries, true); } if (queryData.size === 0 && Object.keys(itemList).length === 0 && currentQueryAnchor.endQueryPos+1 === data.length && page[0] > 1) { setTableData({}); setQueryAnchor((a) => ({...a, startKey: ""})); return; } const listenForUpdates = () => { if (!Object.keys(itemList).length) return; console.log("Fetching filter table updates") const itemListUpdateQuery = query(collection(db, ...(querySchema.path as [any])), where(documentId(), "in", Object.keys(itemList))); const itemUpdateSnapshot = onSnapshot(itemListUpdateQuery, (querySnapshot) => { querySnapshot.docs.forEach((doc) => { setTableData((data) => { const newData = {...data, [doc.id]: {...doc.data(), id: doc.id}}; return newData; }); }); }); setDataListenerUnsubscribe(() => itemUpdateSnapshot); }; listenForUpdates(); setQueryAnchor({...currentQueryAnchor, startKey: Object.keys(itemList)[0], endKey: Object.keys(itemList).slice(-1)[0]}); setTableData(itemList); }; useEffect(() => { if (!filters) return; setPage([1, 0]); setTableData({}); setQueryAnchor({startKey: "", endKey: "", startQueryPos: 0, endQueryPos: 0}); setPrevEntryIds({}); dataListenerUnsubscribe && dataListenerUnsubscribe(); }, [filters]); // Fetch new data when queries or page change useEffect(() => { console.log("SETTING TABLE DATA") getDataFromQuery(); dataListenerUnsubscribe && dataListenerUnsubscribe(); }, [page]); return ({...{tableData, setPage, setFilters, page}}); } export function useProviderContactPaginator({data, institute, user, event, view, eventId, filters}:{data:QueryObject[], institute: InstituteData, event?: Partial, eventId?: string, user: UserData, view: "list"|"table", filters?: FilterObject}) { const [query, setQuery] = useState(); const firebaseQuery = new FirebaseQuery(); const getAdditionalData = async (k: string, v: ProviderContactData|ExternalEventAttendee):Promise<[string, any]|undefined> => { if (eventId) { const providerData = await firebaseQuery.getDocData(["providerContacts", (v as ExternalEventAttendee).providerContactId]).catch(() => undefined) as ProviderContactData|undefined; if (!providerData) return; const attendeeData = v as ExternalEventAttendee; return [k, { contact: `${providerData.contactForename} ${providerData.contactSurname}`, email: providerData.providerEmail, employer: providerData.name, capacity: attendeeData.capacity, remainingSpaces: (attendeeData.capacity || 0) - (attendeeData.students || [])?.length - Object.values(attendeeData.schools || {}).reduce((partialSum, a) => partialSum + a, 0) }] } if ((v as ProviderContactData).savedBy?.[user.oId]?.activities?.includes("workExperience")) { const placementsCount = await firebaseQuery.getCount("placementListings", [where(`savedBy.${user.oId}.exists`, "==", true), where("providerContactId", "==", k)]); return [k, {...v, placements: placementsCount}]; } return [k, v]; } const {tableData, pageUp, pageDown, setFilters, page, setView, loading} = useDataViewerPaginator({view, filters, queryLimit: 10, data: query, additionalEntryProcessing: getAdditionalData}) useEffect(() => { console.log("Event ID OID", eventId, event?.oId); if (eventId && event?.oId) { console.log("Setting Query"); setQuery([{ path: ["externalEventAttendees"], where: [ ["oId", "==", event.oId], ["eventId", "==", eventId], ["status", "==", "providerConfirmed"], ] }]); return; } if (user.viewSchools === "none" && user.userType === "Staff" && user.userGroup !== "admin") { setQuery(undefined); return; } const statuses = ["providerReviewed", "uploaded", "approved"]; // if (user.viewSchools === "some") { // if (!user.visibleSchools || user.visibleSchools.length === 0) { // setQuery(undefined); // return; // } // const constraints:QueryObjectConstraint = [ // [`savedBy.${user.oId}.exists`, "==", true], // [`savedBy.${user.oId}.MATschools`, "array-contains-any", user.visibleSchools] // ]; // setQuery(statuses.map((status) => ({ // path: ["providerContacts"], // where: [...constraints, [`savedBy.${user.oId}.status`, "==", status]], // }) as QueryObject)) // return; // } const constraints:QueryObjectConstraint[] = [ [`savedBy.${user.oId}.exists`, "==", true], ] // if (event && event.activityId) { // constraints.push([`savedBy.${user.oId}.activities`, "array-contains", event.activityId]); // } if (user.userType === "Students") { constraints.push([`savedBy.${user.oId}.cohorts.${user.cohort}.listed`, "==", true]); } const queries = statuses.map((status) => ({ path: ["providerContacts"], where: [...constraints, [`savedBy.${user.oId}.status`, "==", status]], }) as QueryObject) const links = Object.entries(institute.trustLinks || {}); if (links.length > 0 && user.userType === "Staff") { queries.push(...links.filter(([, link]) => link.status === "approved").map(([linkOId]) => ({ path: ["providerContacts"], where: [[`savedBy.${linkOId}.exists`, "==", true], [`savedBy.${linkOId}.status`, "==", "approved"], ((event && event.activityId) ? [`savedBy.${linkOId}.activities`, "array-contains", event.activityId] : undefined)] .filter((i) => i) } as QueryObject))) } setQuery(queries); }, [user]); return ({...{tableData, pageUp, pageDown, setFilters, page, setView, loading}}); } type UserPaginatorParams = { user: UserData, cohort?: string, data: QueryObject[], search?: string, userType: "Staff"|"Students", sort?: string } const algoliaUsersSearch = async (data: QueryObject[], user: UserData, query?: string, sort?: [string, {value: string, direction: "asc"|"desc"}], page?: number, filters?: FilterObject, limit?: number, cohort?: string, userType?: string) => { const algoliaClient = algoliasearch("A0ZB50I7VS", user.algoliaKey); const userIndex = algoliaClient.initIndex(sort ? `users_${Object.values(sort[1]).join("_")}` : "users"); const items = (await Promise.all(data.map(async (queryObj) => { if (!queryObj.where) return; let userSearchString = queryObj.where.map(([k, e, v]) => { if (e === "==") return `${k}:${quoteAlgoliaFilterIfNeeded(v)}`; // Equality check if (e === "!=") return `${k}:-${quoteAlgoliaFilterIfNeeded(v)}`; // Not equal check if (e === "<") return `${k}:<${quoteAlgoliaFilterIfNeeded(v)}`; // Less than check if (e === "<=") return `${k}:<=${quoteAlgoliaFilterIfNeeded(v)}`; // Less than or equal check if (e === ">") return `${k}:>${quoteAlgoliaFilterIfNeeded(v)}`; // Greater than check if (e === ">=") return `${k}:>=${quoteAlgoliaFilterIfNeeded(v)}`; // Greater than or equal check if (e === "array-contains") return `${k}:${quoteAlgoliaFilterIfNeeded(v)}`; // Array contains check (string format) if (e === "array-contains-any") return `${k}:"${(v as string[]).join('","')}"`; // Array contains any of the values (string format) if (e === "in") return `(${(v as string[]).map((value) => `${k}:${quoteAlgoliaFilterIfNeeded(value)}`).join(' OR ')})`; // In check if (e === "not-in") return (v as string[]).map((value) => `${k}:-${quoteAlgoliaFilterIfNeeded(value)}`).join(' AND '); // In check return; }).join(" AND "); filters && Object.entries(filters).filter(([, filter]) => filter.value).map(([id, filter]) => { userSearchString = userSearchString + ` AND ${id}:${quoteAlgoliaFilterIfNeeded(filter.value)}`; }); const options:{[key: string]: any} = { filters: userSearchString, } if (data.length === 1) { options.hitsPerPage = limit; options.page = page ? page - 1 : undefined; } console.log("Search", options); const searchUsersHits = await userIndex.search(query || "", options); console.log(searchUsersHits.hits); const i = (searchUsersHits ? (await Promise.all(searchUsersHits.hits.map(async (hit) => { return [hit.objectID, hit]; }))) : []).filter((e) => e !== undefined) as [string, UserData][]; return i; }))).filter((i) => i) as [string, UserData][][]; return Object.fromEntries(items.flat()); } type NewUserPaginatorParams = { user: UserData, cohort?: string, oId?: string, queryConstraints?: QueryObjectConstraint[], ql?: number, view: "list"|"table", filters?: FilterObject, institute: InstituteData, userType: "Staff"|"Students", initialSort?: string trustSchoolsIDs?: string[] } export function useNewCohortUserPaginator({user, oId, trustSchoolsIDs, institute, initialSort, filters, view, cohort, queryConstraints, ql=DEFAULTQUERYLIMIT, userType}:NewUserPaginatorParams) { const [query, setQuery] = useState(); const firebaseQuery = new FirebaseQuery(); const sorts:Sorts = { [`${userType} Forename - Asc`]: { value: "details.forename", direction: "asc", }, [`${userType} Forename - Desc`]: { value: "details.forename", direction: "desc", }, [`${userType} Surname - Asc`]: { value: "details.surname", direction: "asc", }, [`${userType} Surname - Desc`]: { value: "details.surname", direction: "desc", }, [`${userType} Email - Asc`]: { value: "email", direction: "asc", }, [`${userType} Email - Desc`]: { value: "email", direction: "desc", } } useEffect(() => { const getAccess = async () => { // Sets the query of for the DataViewerPaginator if(user.product !== "institutes" || user.userType !== "Staff") { setQuery(undefined); return; } // Set the query based on defined groups to make it easier. if (institute.package === "careersHub") { const qConstraints:QueryObjectConstraint[] = [["oId", "==", user.oId], ["userType", "==", userType]]; queryConstraints && qConstraints.unshift(...queryConstraints); setQuery([{ path: ["users"], where: qConstraints, }]); return; } if (institute.package === "institutes-one") { const constraints:QueryObjectConstraint[] = [["oId", "==", user.oId], ["userType", "==", userType]]; cohort && constraints.push(["cohort", "==", cohort]); queryConstraints && constraints.unshift(...queryConstraints); if (user.userGroup === "admin" || (user.viewCohorts === "all" && user.viewStudents === "all")) { setQuery([{ path: ["users"], where: constraints, }]); return; }; if (!user.viewCohorts || user.viewCohorts === "none" || !user.viewStudents || user.viewStudents === "none" || (user.viewCohorts === "some" && !user.visibleCohorts?.length) || (user.viewStudents === "some" && !user.studentFilterValues?.length)) return setQuery(undefined); if (user.viewStudents === "some") { constraints.push([`details.${user.studentFilter}`, "in", user.studentFilterValues as string[]]); } if (cohort) { const canViewCohort = user.viewCohorts === "all" || (user.viewCohorts === "some" && user.visibleCohorts?.includes("cohort")); setQuery(canViewCohort ? [{ path: ["users"], where: constraints, }] : undefined); return; } else { // No cohort. if (user.viewCohorts === "all") { setQuery([{ path: ["users"], where: constraints, }]); return; }; if (user.viewCohorts === "some" && user.visibleCohorts) { constraints.push(["cohort", "in", user.visibleCohorts]); setQuery([{ path: ["users"], where: constraints, }]); return; } return setQuery(undefined); } } if (institute.package === "institutes-two") { const buildQuery = (additionalFilters?: QueryObjectConstraint[]) => { // If no oId or cohort, make it for any. let constraints:QueryObjectConstraint[] = []; if (trustSchoolsIDs && !(cohort || oId)) { setQuery([user.oId, ...trustSchoolsIDs].map((orgId) => ({ path: ["users"], where: [...(queryConstraints || []), ...(additionalFilters || []), ["oId", "==", orgId], ["userType", "==", userType]], }))) return; } cohort && constraints.push(["cohort", "==", cohort]); oId && constraints.push(["oId", "==", oId]); userType && constraints.push(["userType", "==", userType]); queryConstraints && constraints.unshift(...queryConstraints); additionalFilters && constraints.unshift(...additionalFilters); setQuery([{ path: ["users"], where: constraints, }]); } if (user.userGroup === "admin" || (user.viewSchools === "all" && user.viewCohorts === "all" && user.viewStudents === "all")) return buildQuery(); if (!user.viewCohorts || !user.viewSchools || user.viewSchools === "none" || user.viewCohorts === "none") return setQuery(undefined); if (!user.viewStudents || user.viewStudents === "none" || (user.viewStudents === "some" && !user.studentFilterValues?.length) || (user.viewCohorts === "some" && !user.visibleCohorts?.length) || (user.viewSchools === "some" && !user.visibleSchools?.length)) return setQuery(undefined); if (user.viewStudents === "some") { buildQuery([[`details.${user.studentFilter}`, "in", user.studentFilterValues as string[]]]); } if (cohort) { if (user.viewCohorts === "some" && !user.visibleCohorts?.includes(cohort)) return setQuery(undefined); if (user.viewSchools === "some") { const cohortData = await firebaseQuery.getDocData(["cohorts", cohort]) as CohortData; if (!cohortData.oId || !user.visibleSchools?.includes(cohortData.oId)) return setQuery(undefined); } } else { if (user.viewCohorts === "some" && user.visibleCohorts) { buildQuery([["cohort", "in", user.visibleCohorts]]); } else if (user.viewSchools === "some" && user.visibleSchools) { const cohorts = await firebaseQuery.getDocsWhere("cohorts", [where("oId", "in", user.visibleSchools)]); buildQuery([["cohort", "in", Object.keys(cohorts || {})]]); } } } setQuery(undefined); } getAccess(); }, [user, queryConstraints, cohort]); console.log("Query", query); const {tableData, pageUp, pageDown, setFilters, page, setView, loading, updateSearch, updateSort, search, sort} = useDataViewerPaginator({view, filters, initialSort, sorts, queryLimit: ql, data: query, onSearch: async (s, sort, page, filters, limit) => await algoliaUsersSearch(query || [], user, s, sort, page, filters, limit, cohort, userType)}) return {tableData, page, loading, updateSearch, setFilters, setView, pageUp, pageDown, sorts, updateSort, sort, search} } export function useCohortUserPaginator({user, cohort, data, search, userType, sort}:UserPaginatorParams) { const [tableData, setTableData] = useState<{[key:string]:{[key:string]: unknown}}>({}); const [queryAnchor, setQueryAnchor] = useState<{startDoc?: QueryDocumentSnapshot, endDoc?: QueryDocumentSnapshot, startQueryPos: number, endQueryPos: number}>({startDoc: undefined, endDoc: undefined, startQueryPos: 0, endQueryPos: 0}); const [filters, setFilters] = useState<{[key:string]: unknown}>(); const [sortResultsBy, setSortResultsBy] = useState(sort); const [prevEntryIds, setPrevEntryIds] = useState<{[key:string]:number}>({}); const [page, setPage] = useState([1, 0]); const [dataListenerUnsubscribe, setDataListenerUnsubscribe] = useState(); const [queries, setQueries] = useState(); const [prevSearch, setPrevSearch] = useState(); const algoliaClient = algoliasearch(process.env.NODE_ENV === "development" ? "A0ZB50I7VS" : "A0ZB50I7VS", user.algoliaKey); const usersIndex = algoliaClient.initIndex("users"); const sortOptions = { ["Forename - Asc"]: ["details.forename", "asc"], ["Forename - Desc"]: ["details.forename", "desc"], ["Surname - Asc"]: ["details.surname", "desc"], ["Surname - Desc"]: ["details.surname", "asc"], ["Email - Asc"]: ["email", "asc"], ["Email - Desc"]: ["email", "desc"], } useEffect(() => { if (user.userType !== "Staff") { console.log("Not a staff member", user) setQueries(undefined); return; } if ( (!user.viewCohorts && user.userGroup !== "admin") || (user.viewCohorts === "none" && user.userGroup !== "admin") || (user.viewCohorts === "some" && user.userGroup !== "admin" && cohort !== "all" && !user.visibleCohorts?.includes(cohort || ""))) { setQueries(undefined); return; } setQueries(() => { const finalConstraints:QueryConstraint[][] = []; console.log("data", data, data.length) for (var i = 0; i < data.length; i++) { const item = data[i] console.log("item", item) const constraints:QueryConstraint[] = []; item.where && item.where.forEach((w) => { constraints.push(where(...w)); }); constraints.push(where("oId", "==", user.oId)); cohort && cohort !== "all" && constraints.push(where("cohort", "==", cohort)); if (user.userGroup === "admin" || user.viewStudents === "all") { finalConstraints.push(constraints); continue } if (!user.studentFilter || !user.studentFilterValues) continue; user.studentFilterValues.forEach((filterValue) => { user.studentFilter && finalConstraints.push([...constraints, where("details."+user.studentFilter, "==", filterValue)]) }) }; console.log("final", finalConstraints) return finalConstraints; }); }, []); const getDataFromQuery = async ( itemList: {[key: string]: QueryDocumentSnapshot} = {}, currentQueryAnchor=queryAnchor, cursorDirection?:"increase"|"decrease"|undefined, prevEntries=prevEntryIds, loadMoreFromQuery=false):Promise => { if (!queries?.length) { setTableData({}); return; } console.log("q", queries) let cursorPos:number; if (page[0] > page[1]) { cursorPos = currentQueryAnchor.endQueryPos; } else { cursorPos = currentQueryAnchor.startQueryPos; } const querySchema = queries[cursorPos]; console.log("schema", querySchema) const createQuery = (mConstraints:QueryConstraint[]) => { console.log("mConstraints", mConstraints); const fConstraints = [...mConstraints]; let addOrderBy = true; filters && Object.entries(filters).forEach(([key, value]) => { if (Array.isArray(value)) { value.forEach((v) => { if (typeof v === "object") { if (v instanceof QueryOrderByConstraint) { addOrderBy = false; } fConstraints.push(v as QueryFieldFilterConstraint|QueryOrderByConstraint); } else { fConstraints.push(where(key, "==", v)); } }) } else if (typeof value === "object") { if (value instanceof QueryOrderByConstraint) { addOrderBy = false; } fConstraints.push(value as QueryFieldFilterConstraint|QueryOrderByConstraint); } else { fConstraints.push(where(key, "==", value)); } }); console.log("Add order by", addOrderBy); if (addOrderBy) { if (sortResultsBy) { fConstraints.push(orderBy(sortOptions[sortResultsBy][0], sortOptions[sortResultsBy][1])); } else { fConstraints.push(orderBy(documentId())); } } if (page[0] > page[1] && !cursorDirection) { // Going up currentQueryAnchor.endDoc && fConstraints.push(startAfter(currentQueryAnchor.endDoc)); fConstraints.push(limit(10)); if (!loadMoreFromQuery) { currentQueryAnchor = {...currentQueryAnchor, startQueryPos: currentQueryAnchor.endQueryPos}; } } else if (page[0] < page[1] && !cursorDirection) { // Going down if (!loadMoreFromQuery) { currentQueryAnchor = {...currentQueryAnchor, endQueryPos: currentQueryAnchor.startQueryPos}; } fConstraints.push(limitToLast(10)); if (currentQueryAnchor.startDoc) { currentQueryAnchor.startDoc && fConstraints.push(endBefore(currentQueryAnchor.startDoc)); } else { currentQueryAnchor.startDoc && fConstraints.push(endAt(currentQueryAnchor.startDoc)); } } else { if (cursorDirection === "decrease") { fConstraints.push(limitToLast(10)); } else { fConstraints.push(limit(10)); } } return fConstraints; }; const constraints = createQuery(querySchema); // console.log(queryId, "constraints", constraints) const q = query(collection(db, "users"), ...(constraints)); const queryResults:{[key:string]: QueryDocumentSnapshot} = {}; const queryData = await getDocs(q); // console.log("queryData.size", queryData.size) const reverseIfBack = (docs:QueryDocumentSnapshot[]) => { if (page[0] < page[1]) { return docs.reverse(); } return docs; }; let index = 0; reverseIfBack(queryData.docs).forEach((doc: QueryDocumentSnapshot) => { if (Object.keys(queryResults).length + Object.keys(itemList).length === 10) { return; } let position = Object.keys(itemList).length+(page[0]-1)*10+index+1; if (page[0] < page[1]) { position = (page[0])*10-index-Object.keys(itemList).length; } // console.log(index, "doc.id", doc.id, position, "E", prevEntries[doc.id]) if (itemList[doc.id] || (prevEntries[doc.id] && prevEntries[doc.id] !== position)) { console.log("Removing ", doc.id, ": E=", prevEntries[doc.id], ", G=", position); return; } queryResults[doc.id] = doc; index = index+1; if (prevEntries[doc.id]) return; prevEntries[doc.id] = position; }); if (cursorDirection === "decrease" || page[0] < page[1]) { itemList = {...Object.fromEntries(Object.entries(queryResults).reverse()), ...itemList}; } else { itemList = {...itemList, ...queryResults}; } setPrevEntryIds(prevEntries); if (queryData.size < 10 && Object.keys(itemList).length < 10) { if (page[0] > page[1] && cursorPos+1 < queries.length) { return getDataFromQuery(itemList, {...currentQueryAnchor, endQueryPos: currentQueryAnchor.endQueryPos+1}, "increase", prevEntries); } else if (page[0] < page[1] && cursorPos > 0) { return getDataFromQuery(itemList, {...currentQueryAnchor, startQueryPos: currentQueryAnchor.startQueryPos-1}, "decrease", prevEntries); } } if (Object.keys(itemList).length < 10 && queryData.size === 10) { return getDataFromQuery(itemList, {...currentQueryAnchor, startDoc: Object.values(itemList)[0], endDoc: Object.values(itemList).slice(-1)[0]}, undefined, prevEntries, true); } if (queryData.size === 0 && Object.keys(itemList).length === 0 && currentQueryAnchor.endQueryPos+1 === queries.length && page[0] > 1) { setTableData({}); setQueryAnchor((a) => ({...a, startKey: ""})); return; } const listenForUpdates = () => { if (!Object.keys(itemList).length) return; console.log("Fetching cohort user data") const itemListUpdateQuery = query(collection(db, "users"), where(documentId(), "in", Object.keys(itemList))); const itemUpdateSnapshot = onSnapshot(itemListUpdateQuery, (querySnapshot) => { querySnapshot.docs.forEach((doc) => { setTableData((data) => { const newData = {...data, [doc.id]: {...doc.data(), id: doc.id}}; return newData; }); }); }); setDataListenerUnsubscribe(() => itemUpdateSnapshot); }; listenForUpdates(); setQueryAnchor({...currentQueryAnchor, startDoc: Object.values(itemList)[0], endDoc: Object.values(itemList).slice(-1)[0]}); setTableData(Object.fromEntries(Object.entries(itemList).map(([k, v]) => [k, {id: k, ...v.data()}]))); }; const searchUsers = async () => { if (!search) return; let userSearchString = `userType:${userType} AND oId:${user.oId} AND product:${user.product}` if (cohort && cohort !== "all") { userSearchString = userSearchString + ` AND cohort:${cohort}`; } Object.entries(filters || {}).forEach(([field, value]) => { userSearchString = userSearchString + ` AND ${field}:${value}`; }) console.log("Going", page[0] > page[1] ? "up" : "down"); console.log("Start at", page[0] > page[1] ? page[1] : page[1] - 1) const searchStudentHits = await usersIndex.search(search, { filters: userSearchString, length: 10, offset: 10 * (page[0] > page[1] ? page[1] : (page[0] - 1)) }); const results = Object.fromEntries(await Promise.all(searchStudentHits.hits.map(async (hit) => { return [hit.objectID, hit as UserData] }))) setPrevSearch(search); setTableData(results); } useEffect(() => { if (!filters && !prevSearch && !search) return; if (search && prevSearch) { console.log("New search") setPrevSearch(search); return; } console.log("Setting page"); setPage([1, 0]); console.log("Set page"); setTableData({}); setQueryAnchor({startQueryPos: 0, endQueryPos: 0}); setPrevEntryIds({}); dataListenerUnsubscribe && dataListenerUnsubscribe(); }, [filters, search, sortResultsBy]); // Fetch new data when queries or page change useEffect(() => { if (search) { console.log("PAGE", page); searchUsers(); return; } getDataFromQuery(); dataListenerUnsubscribe && dataListenerUnsubscribe(); }, [page, queries, prevSearch]); const setSort = (s: string) => setSortResultsBy(s); return ({...{tableData, setPage, page, setFilters, setSort, sortOptions: Object.keys(sortOptions), sortBy: sortResultsBy}}); } export function useAdmissionsPaginator({data}:{data:{[key:string]:{[key:string]: unknown}}}) { const [tableData, setTableData] = useState<{[key:string]:{[key:string]: unknown}}>({}); const [page, setPage] = useState([1, 0]); const [filters, setFilters] = useState<{[key:string]: unknown}>(); useEffect(() => { // Get slice of results and get data from those. setTableData(Object.fromEntries(Object.entries(data).slice((page[0] - 10)*10, page[0]*10))); }, [page]); useEffect(() => { // Filter then get first 10 results. }, [filters]); return ({...{tableData, setPage, setFilters}}); } type LazyLoadQueryParams = { path: string|string[], constraints?: QueryConstraint|QueryConstraint[], number: number, endResultsText?: string, noResultsText?: string, onItemFetched?: (item: unknown) => Promise<{[key: string]: unknown}> } export function useLazyLoadQueryList({path, constraints, number, onItemFetched}:LazyLoadQueryParams) { const [items, setItems] = useState<{[key:string]:{[key:string]:unknown}}>({}); const [loadMoreIcon, setLoadMoreIcon] = useState(false); const [lastItem, setLastItem] = useState>(); const firebaseQuery = new FirebaseQuery(); const reset = () => { setItems({}); setLastItem(undefined); }; const loadMore = async () => { setLoadMoreIcon(true); let formattedConstraints:QueryConstraint[] = []; if (constraints) { formattedConstraints = (Array.isArray(constraints) ? [...constraints, limit(number)] : [constraints, limit(number)]); } if (lastItem) { formattedConstraints.push(startAfter(lastItem)); } const documents = await firebaseQuery.getDocsWhere(path, formattedConstraints, true) as QuerySnapshot; console.log("docs", documents); if (documents?.empty) { setLoadMoreIcon(false); return; } setLastItem(documents?.docs[documents?.docs?.length-1]); const processedItems = Object.fromEntries(await Promise.all(Object.values(documents?.docs || {}).map(async (doc) => { const itemObj = {...doc.data(), id: doc.id, docPath: doc.ref}; return [doc.id, onItemFetched ? await onItemFetched(itemObj) : itemObj]; }))) setItems((i) => ({...i, ...processedItems})); setLoadMoreIcon(false); }; useEffect(() => { loadMore(); }, []); return ({...{items, loadMore, loadMoreIcon, reset}}); } // type PublicPlacementListingLoaderParams = { // providerId?: string, // number: number, // } // export function usePublicPlacementListingLoader({providerId, number=5}:PublicPlacementListingLoaderParams) { // const [items, setItems] = useState<{[key:string]:{[key:string]:unknown}}>({}); // const [loadMoreIcon, setLoadMoreIcon] = useState(false); // const [lastItem, setLastItem] = useState>(); // const firebaseQuery = new FirebaseQuery(); // const reset = () => { // setItems({}); // setLastItem(undefined); // }; // const loadMore = async () => { // setLoadMoreIcon(true); // let formattedConstraints:QueryConstraint[] = [ // where("status", "==", "listed"), // limit(number) // ]; // if (providerId) { // formattedConstraints.push(where("providerId", "==", providerId)); // } // if (lastItem) { // formattedConstraints.push(startAfter(lastItem)); // } // const documents = await firebaseQuery.getDocsWhere("placementListings", formattedConstraints, true) as QuerySnapshot; // console.log("docs", documents.docs); // setLastItem(documents.docs[documents.docs.length-1]); // const processedItems = Object.fromEntries(await Promise.all(Object.values(documents.docs).map(async (doc) => { // let itemObj = {...doc.data(), id: doc.id} as PlacementListing; // if (itemObj.addressId) { // const address = await firebaseQuery.getDocData(["addresses", itemObj.addressId]) as Address; // delete address.id; // itemObj = {...address, ...itemObj}; // } // if (itemObj.applicantWorkflowId) { // const applicantWorkflow = (await firebaseQuery.getDocData(["applicantWorkflows", itemObj.applicantWorkflowId]) as ApplicantWorkflow).workflow.filter((i) => i.id === 1)[0]; // const applicantFiles = applicantWorkflow.files ? Object.fromEntries(await Promise.all(applicantWorkflow.files?.map(async (fileId) => { // const file = await firebaseQuery.getDocData(["files", fileId]); // file.url = await getDownloadURL(ref(storage, `providers/${itemObj.providerId}/${file.fileName}`)); // return [fileId, file]; // }))) : []; // const applicantForms = applicantWorkflow.forms ? Object.fromEntries(await Promise.all(applicantWorkflow.forms?.map(async (formId) => { // return [formId, await firebaseQuery.getDocData(["forms", formId])]; // }))) : []; // applicantWorkflow.viewableFiles = applicantFiles; // applicantWorkflow.formDetails = applicantForms; // itemObj = {...itemObj, applicantWorkflow: [applicantWorkflow]}; // } // return [doc.id, itemObj]; // }))) // setItems((i) => ({...i, ...processedItems})); // setLoadMoreIcon(false); // }; // useEffect(() => { // loadMore(); // }, []); // return ({...{items, loadMore, loadMoreIcon, reset}}); // } // export type ApplicationHookParams = { // successText: { // submitted: { // title: string; // desc: string; // }; // draftSaved: { // title: string; // desc: string; // }; // stageComplete: { // title: string; // desc: string; // }; // outcome: { // title: string; // desc: string; // }; // }; // setSuccessPopup: import("react").Dispatch>; // openSuccessPopup: (type: "submitted" | "draftSaved" | "stageComplete" | "outcome") => void; // setFApplication: import("react").Dispatch>>; // fApplication: Partial; // draftSaved: boolean; // profileUrl: string | undefined; // successPopup: "submitted" | "draftSaved" | "stageComplete" | "outcome" | undefined; // fApplicationId: string | undefined; // fListing: PlacementListing | false | undefined; // student: UserData | undefined; // fProvider: { // details?: ProviderData; // profile?: string; // id?: string; // } | undefined; // onFApply: (draft?: boolean) => Promise; // setFormComplete: (formId: string, e: { // [key: string]: unknown; // }) => void; // viewFile: (file: string, onOpen: (url: string) => void) => void // addFile: (files: string[], fileId: number) => void; // uploadedFiles?: { // [key: string]: FileItem; // }; // getCurrentStage: (stage: number) => Promise<{ // stage: ApplicantStage; // completedSections: { // submitted?: string | undefined; // filesViewed?: string[] | undefined; // formsCompleted?: { // [key: string]: unknown; // } | undefined; // filesUploaded?: { // [key: number]: string[]; // } | undefined; // }; // }>; // currentStageComplete?: boolean; // progressStage: (type: number | "accept" | "reject", e?: { // feedback?: string; // }) => Promise; // }; // export function useCreateApplicationRenderer({user, listingId, listing, provider, application, applicationId, orgContext}: // {user:UserData, listingId:string, applicationId?: string, listing?: PlacementListing, application?: Partial, // orgContext?: {details: ProviderData, addresses: {[key: string]: OrganisationAddress}, applicantWorkflows: {[key: string]: ApplicantWorkflow}}, // provider?: {details?: ProviderData, profile?: string, id?: string}}) { // const firebaseQuery = new FirebaseQuery(); // let applicationWithoutAdditionalData = {...(application || {})} as any; // delete applicationWithoutAdditionalData.listing; // delete applicationWithoutAdditionalData.address; // delete applicationWithoutAdditionalData.provider; // const [fApplication, setFApplication] = useState>(application ? applicationWithoutAdditionalData : { // uid: user.userType === "Students" ? user.id : undefined, // listingId: listingId, // addressId: listing?.addressId, // stage: 1, // reqUserType: "Students", // status: "draft"}); // const [fApplicationId, setFApplicationId] = useState(applicationId); // const [draftSaved, setDraftSaved] = useState(false); // const [fProvider, setFProvider] = useState<{details?: ProviderData, profile?: string, id?: string}|undefined>(provider); // const [fListing, setFListing] = useState(Object.keys(listing || {}).length > 5 ? listing : undefined); // const [student, setStudent] = useState(user.userType === "Students" ? user : undefined); // const [profileUrl, setProfileUrl] = useState(); // const [successPopup, setSuccessPopup] = useState<"submitted"|"draftSaved"|"stageComplete"|"outcome">(); // const [uploadedFiles, setUploadedFiles] = useState<{[key: string]: FileItem}>(); // const [currentStageComplete, setCurrentStageComplete] = useState(); // useEffect(() => { // const getListing = async () => { // console.log("Checking ID") // if (!listingId) return; // console.log("Getting listing") // console.log("LISTING PARAM", listing, Object.keys(listing || {}).length > 5); // const listingData = (Object.keys(listing || {}).length > 5) ? listing : (await firebaseQuery.getDocData(["placementListings", listingId]).catch(() => false) as PlacementListing|false); // console.log("LISTINGDATA", listingData, Object.keys(listing || {}).length > 5 ? {a: "string"} : "AAA"); // const address = listingData ? (user.product === "providers" && orgContext) ? orgContext.addresses[listingData.addressId || ""] : await firebaseQuery.getDocData(["addresses", listingData.addressId as string]) as Address : undefined; // const workflow = listingData ? (user.product === "providers" && orgContext) ? orgContext.applicantWorkflows[listingData.applicantWorkflowId || ""] as ApplicantWorkflow : await firebaseQuery.getDocData(["applicantWorkflows", listingData.applicantWorkflowId as string]) as ApplicantWorkflow : undefined; // if (workflow && listingData) { // workflow.workflow = await Promise.all(workflow.workflow.map(async (s) => { // const applicantFiles = s.files ? Object.fromEntries(await Promise.all(s.files?.map(async (fileId: string) => { // const file = await firebaseQuery.getDocData(["files", fileId]); // file.url = await getDownloadURL(ref(storage, `providers/${listingData?.providerId}/${file.fileName}`)); // return [fileId, file]; // }))) : []; // const applicantForms = s.forms ? Object.fromEntries(await Promise.all(s.forms?.map(async (formId: string) => { // return [formId, await firebaseQuery.getDocData(["forms", formId])]; // }))) : []; // return {...s, viewableFiles: applicantFiles, formDetails: applicantForms}; // })); // delete workflow.id; // } // if (address) { // delete address.id; // } // console.log("Setting listing") // setFListing((listingData && workflow) ? {...listingData, ...address, applicantWorkflow: workflow.workflow} as PlacementListing : false); // if ((fProvider?.id === application?.providerId) && user.product === "providers") { // setFProvider({details: orgContext?.details, id: user.oId}); // } else if (listingData && listingData?.providerId) { // console.log("Getting provider from DB"); // const provider = await firebaseQuery.getDocData(["providers", listingData.providerId]) as ProviderData; // console.log("Provider", provider); // setFProvider({details: provider, id: listingData.providerId}); // } // } // getListing(); // }, [listingId, listing]); // useEffect(() => { // if (student?.id !== application?.uid) { // if (user.userType === "Students") { // setStudent(user); // } else if (user.product === "providers" && application?.uid) { // firebaseQuery.getDocData(["users", application.uid]).then((s) => setStudent(s as UserData)); // } // } // }, []); // useEffect(() => { // setFListing(Object.keys(listing || {}).length > 5 ? listing : undefined); // }, [listing]); // useEffect(() => { // if (provider?.profile) return; // if (!provider?.id) return; // getDownloadURL(ref(storage, `providers/${provider?.id}/profilePic.png`)).then(setProfileUrl).catch(() => null); // }, [provider]); // useEffect(() => { // console.log("APPLICATIONID", applicationId); // if (!applicationId) { // if (!listingId) return; // firebaseQuery.getDocsWhere("applications", [where("status", "not-in", ["approved", "declined"]), where("uid", "==", user.id), where("listingId", "==", listingId)]).then((existingApplication) => { // console.log("EXISTING", existingApplication); // // get uploaded files // if (existingApplication && Object.keys(existingApplication).length) { // setFApplication(Object.values(existingApplication)[0]); // setFApplicationId(Object.keys(existingApplication)[0]); // } // }); // return; // } // if (applicationId) { // setFApplicationId(applicationId); // if (application) { // let applicationWithoutAdditionalData = {...(application || {})} as any; // delete applicationWithoutAdditionalData.listing; // delete applicationWithoutAdditionalData.address; // delete applicationWithoutAdditionalData.provider; // setFApplication(applicationWithoutAdditionalData); // } else { // firebaseQuery.getDocData(["applications", applicationId]).then(setFApplication); // } // } // }, [application, applicationId]); // const getCurrentStage = async (stage: number): Promise<{ // stage: ApplicantStage; completedSections: { // submitted?: string | undefined; // filesViewed?: string[] | undefined; // formsCompleted?: { // [key: string]: unknown; // } | undefined; // filesUploaded?: { // [key: number]: string[]; // } | undefined; // }; // }> => { // console.log("fLSITING CURRENT STAGE", fListing); // if (!fListing) throw new Error("Listing deleted"); // if (!fListing?.applicantWorkflowId) throw new Error("No workflow stage"); // const mApplicantWorkflow = (await firebaseQuery.getDocData(["applicantWorkflows", fListing.applicantWorkflowId]) as ApplicantWorkflow); // const stageObj = mApplicantWorkflow?.workflow?.find((s) => s.id === stage); // if (!stageObj) throw new Error("Can't find stage."); // const applicantFiles = stageObj.files ? Object.fromEntries(await Promise.all(stageObj.files?.map(async (fileId) => { // const file = await firebaseQuery.getDocData(["files", fileId]); // file.url = await getDownloadURL(ref(storage, `providers/${mApplicantWorkflow?.oId}/${file.fileName}`)); // return [fileId, file]; // }))) : []; // const applicantForms = stageObj.forms ? Object.fromEntries(await Promise.all(stageObj.forms?.map(async (formId) => { // return [formId, await firebaseQuery.getDocData(["forms", formId])]; // }))) : []; // stageObj.viewableFiles = applicantFiles; // stageObj.formDetails = applicantForms; // return {stage: stageObj, completedSections: fApplication?.completedSections?.[stage] || {}}; // }; // useEffect(() => { // const getUploadedFiles = async () => { // if (!fApplication.completedSections) { // setUploadedFiles({}); // return // }; // const fileIds = Object.values(fApplication.completedSections) // .flatMap(stageData => // Object.values(stageData.filesUploaded || {}).flatMap(fileIds => fileIds) // ); // const fileDataPromises = fileIds.map(async (fileId) => { // const fileData = await firebaseQuery.getDocData(["files", fileId]) as FileItem; // fileData.url = await getDownloadURL(ref(storage, `userFiles/${fileData.fileName}`)); // return [fileId, fileData] as [string, FileItem] // }); // const fileDataArray = await Promise.all(fileDataPromises); // const fileDataObj = Object.fromEntries(fileDataArray) // setUploadedFiles(fileDataObj) // } // const addApplication = async () => { // console.log("ADDING APPLICATION"); // if (!fListing || !fListing?.id || !fProvider?.id || !student?.id) return; // const applicationData = { // uid: student.id, // listingId: fListing?.id, // addressId: fListing.addressId, // applicantWorkflowId: fListing?.applicantWorkflowId, // providerId: fProvider.id, // stage: 1, // status: "draft", // created: (new Date()).toISOString(), // ...fApplication, // } as Partial; // const mApplicationId = (await firebaseQuery.add(["applications"], applicationData)).id; // console.log("APPLICATION ADDED"); // setFApplicationId(mApplicationId); // setFApplication(applicationData); // setDraftSaved(true); // return; // }; // getUploadedFiles(); // console.log("Checking IDs"); // if (!fListing || !fListing?.id || !fProvider?.id || !student?.id) return; // if (user.product === "providers") return; // console.log("Checking dates and sections"); // if (!fApplication.completedSections && !fApplication.startDate && !fApplication.endDate) return; // console.log("Application updated"); // if (!fApplicationId && !applicationId) { // // save new // console.log("Add application") // addApplication(); // return; // } // // update // firebaseQuery.update(["applications", (fApplicationId || applicationId) as string], fApplication); // setDraftSaved(true); // }, [fApplication]); // const openSuccessPopup = (type: "submitted"|"draftSaved"|"stageComplete"|"outcome") => { // setSuccessPopup(type); // setTimeout(() => { // // onClose(); // }, 1500); // }; // useEffect(() => { // const areStagesCompleted = async ():Promise => { // if (!fListing) return; // if (fApplication.stage === undefined) return undefined; // const currentStage = await getCurrentStage(fApplication.stage); // console.log("Checking current stage is complete"); // for (const fileViewed of currentStage.stage?.files || []) { // console.log("Checking file viewed", fileViewed, "in", currentStage?.completedSections?.filesViewed); // if (!currentStage?.completedSections?.filesViewed?.includes(fileViewed)) { // console.log("File not viewed"); // return false; // } else { // console.log("File viewed"); // } // } // for (const formCompleted of currentStage?.stage?.forms || []) { // console.log("Checking form completed", formCompleted, "in", currentStage?.completedSections?.formsCompleted); // if (!Object.keys(currentStage?.completedSections?.formsCompleted || {}).includes(formCompleted)) { // console.log("Form not completed"); // return false; // } else { // console.log("Form completed") // } // } // for (let i = 0; i++; i < (currentStage?.stage?.requiredFiles || []).length) { // if (!Object.keys(currentStage?.completedSections?.filesUploaded || {}).includes(i.toString())) { // console.log("Form not uploaded"); // return false; // } else { // console.log("Form uploaded"); // } // } // return true; // }; // areStagesCompleted().then(setCurrentStageComplete); // }, [fApplication, fListing]) // const viewFile = (file: string, onOpen: (url: string) => void) => { // if (fApplication.reqUserType !== user.userType) return; // if (fApplication.stage === undefined) throw new Error("Missing applciation stage.") // if (!fListing) throw new Error("No associated listing."); // const viewableFiles = fListing?.applicantWorkflow?.find((stage) => stage.id === fApplication.stage)?.viewableFiles; // setFApplication((a) => { // const oldA = {...a}; // const viewedFiles = a.completedSections?.[1]?.filesViewed || []; // if (viewedFiles?.includes(file)) return a; // viewableFiles?.[file].url && onOpen(viewableFiles?.[file].url); // viewedFiles?.push(file); // const newA = editNestedObject(["completedSections", 1, "filesViewed"], oldA, viewedFiles) as Partial; // return newA; // }); // }; // const addFile = (files: string[], fileId: number) => { // if (fApplication.reqUserType !== user.userType) throw new Error(`Incorrect user type. Expected ${fApplication.reqUserType}, got: ${user.userType}`); // if (fApplication.stage === undefined) throw new Error("Missing applciation stage.") // if (!files.length) throw new Error("No files to upload"); // setFApplication((a) => editNestedObject(["completedSections", fApplication.stage as number, "filesUploaded", fileId], a, files) as Application); // }; // const setFormComplete = (formId: string, e: {[key: string]: unknown}) => { // if (fApplication.reqUserType !== user.userType) throw new Error(`Incorrect user type. Expected ${fApplication.reqUserType}, got: ${user.userType}`); // if (fApplication.stage === undefined) throw new Error("Missing applciation stage.") // setFApplication((a) => editNestedObject(["completedSections", fApplication.stage as number, "formsCompleted", formId], a, e) as Application); // }; // const successText = { // submitted: { // title: "Application submitted", // desc: "We've sent your application to the placement provider. You will hear what the next steps are soon.", // }, // draftSaved: { // title: "Draft saved", // desc: "Your draft has been saved. Go to the 'Applications' section from your home screen to edit.", // }, // stageComplete: { // title: "Stage complete", // desc: "We have sent this to the next stage in the application process. We will update you with any progress.", // }, // outcome: { // title: "Outcome submitted", // desc: "We have sent the student an email with the outcome of their application.", // }, // }; // const onFApply = async (draft?: boolean) => { // if (draft) { // openSuccessPopup("draftSaved") // return; // } // if (!fApplicationId) return; // // Check all items have been filled in. // if (!fApplication.startDate || !fApplication.endDate) throw new Error("Please select dates for your placement."); // await executeCallable("applications-submit", {applicationId: fApplicationId}); // const newApplication = await firebaseQuery.getDocData(["applications", fApplicationId]) as Application; // setFApplication(newApplication); // openSuccessPopup("submitted") // }; // const progressStage = async (type: number|"accept"|"reject", e?: {feedback?: string}) => { // // Check all stages completed. // if (!fApplicationId) return; // if (!currentStageComplete) throw new Error("Complete all forms before submitting."); // if (fApplication.reqUserType !== user.userType) throw new Error(`Incorrect user type. Expected ${fApplication.reqUserType}, got: ${user.userType}`); // if (fApplication.stage === undefined) throw new Error("Missing applciation stage.") // await executeCallable("applications-changeStage", {applicationId: fApplicationId, type: type, feedback: e?.feedback}); // const newApplication = await firebaseQuery.getDocData(["applications", fApplicationId]) as Application; // setFApplication(newApplication); // }; // return {successText, onFApply, progressStage, currentStageComplete, uploadedFiles, getCurrentStage, setFormComplete, viewFile, addFile, setSuccessPopup, openSuccessPopup, setFApplication, fApplication, draftSaved, profileUrl, successPopup, fApplicationId, fListing, student, fProvider} // } export function useProposePlacementRenderer({user, orgContext, placement}: {user:UserData, orgContext?: {details: InstituteData, userGroups: {[key:string]: UserGroupData}, placements: {[key: string]: StudentPlacementData}, forms: {[key:string]: unknown}, cohorts: {[key:string]: CohortData}}, placement?:StudentPlacementData}) { const [businessSectionComplete, setBusinessSectionComplete] = useState(false); const [addressSectionComplete, setAddressSectionComplete] = useState(false); const [placementSectionComplete, setPlacementSectionComplete] = useState(false); const [formData, setFormData] = useState(); const [disabled, setDisabled] = useState(false); const [student, setStudent] = useState((user && (user?.userType === "Students")) ? user : undefined); const [cohort, setCohort] = useState(); const [complete, setComplete] = useState(false); const [stage, setStage] = useState<"provider"|"address"|"dates"|"review">("dates"); const sections:Array<"dates"|"provider"|"address"|"review"> = ["dates", "provider", "address", "review"]; const firebaseQuery = new FirebaseQuery(); useEffect(() => { if (user.userType === "Students") { setStudent(user); } if (placement) { console.log("p", placement); setFormData(placement); if (placement.placementId) { setStage("dates"); } else { setStage("review"); } } }, []); useEffect(() => { if (!student) { return; } if (user.userType === "Staff" && orgContext) { setCohort(orgContext.cohorts[student?.cohort as string]); return; } setCohort(user.cohortData as CohortData); }, [student]); useEffect(() => { if (!cohort) { return; } if (user.userType === "Students" && cohort.startSubmission && cohort.endSubmission) { const today = new Date(); const submissionStart = new Date(cohort.startSubmission); const submissionEnd = new Date(cohort.endSubmission); setDisabled(today <= submissionStart || today >= submissionEnd); } }, [cohort]); const submitSection = (type:"dates"|"provider"|"address", data:{[key:string]:unknown}) => { setFormData((f) => { return { ...f, ...data } as StudentPlacementData; }); if (placement?.placementId && type === "dates") { setStage("review"); return; } setStage(sections[sections.indexOf(type)+1]); }; const resetFormData = () => { setFormData(undefined); }; const proposePlacement = async (draft = false) => { // const getPlacementStatus = (startDate: Date, endDate: Date) => { // const today = new Date() // if (startDate <= today && endDate >= today) return { active: !draft ? true : false, inProgress: true, completed: false}; // else if (endDate <= today) return { completed: true, inProgress: false, active: false}; // return { completed: false, inProgress: true, active: false }; // } if (!formData || !student) { throw new Error("Cannot find placement details."); } try { console.log("formData", formData); if (formData.id && formData.uid && draft === formData.draft) await firebaseQuery.update(["placements", formData.id], formData); const response = await addPlacement(formData, student.id, draft) setComplete(true); return response } catch (error) { console.log("Error:", error); throw error; } /* console.log("STUDENTID", student.id); return await addPlacement(formData, student.id, draft).catch((e) => { console.log("error"); console.log(e); throw e; }).then((e) => { setComplete(true); return e; }); */ }; const deletePlacement = async (id:string) => { return await firebaseQuery.delete(["placements", id]); }; return ({...{student, disabled, stage, formData, complete, cohort, businessSectionComplete, addressSectionComplete, placementSectionComplete, setBusinessSectionComplete, setAddressSectionComplete, setPlacementSectionComplete, proposePlacement, deletePlacement, setStudent, submitSection, setFormData, resetFormData, setStage, setComplete}}); } export const useRefDimensions = () => { const [dimensions, setDimensions] = useState({width: 1, height: 2}); const refCallback = useCallback((node: HTMLDivElement) => { if (!node) return; const resizeObserver = new ResizeObserver(() => { const boundingRect = node.getBoundingClientRect(); const {width, height} = boundingRect; setDimensions({width: Math.round(width), height: Math.round(height)}); }); resizeObserver.observe(node); }, []); return {dimensions, refCallback}; }; type CreateCohortRendererParams = { oId: string, product: Products, initialData ?: CohortData } export type CreateCohortStages = "info"|"name"|"placementType"|"database"|"review"; const cohortStages = ["info", "name", "placementType", "review", "created"]; const defaultCohortData:CohortData = { name: "", stage: "info", startSubmission: "", endSubmission: "", startPlacements: "", endPlacements: "", placementType: "defined", workflow: [], oId: "", product: "providers", feedback: { students: "defaultStudentFeedackForm", provider: "defaultEmployerFeedackForm", parent: "defaultParentFeedackForm", } }; export function useCreateCohortRenderer({oId, product, initialData=defaultCohortData}:CreateCohortRendererParams) { const [cohortData, setCohortData] = useState(initialData); const [cohortId, setCohortId] = useState(initialData.id); const firebaseQuery = new FirebaseQuery(); const submitSection = (e?: {[key:string]: unknown}) => { console.log("Next stage", cohortData); if (cohortData?.stage === "students") { console.log("Next stage"); setCohortData((p) => ({...p, stage: cohortStages[cohortStages.indexOf(cohortData.stage)+1] as CreateCohortStages})); } else { setCohortData((p) => ({...p, ...e, stage: cohortStages[cohortStages.indexOf(cohortData.stage)+1] as CreateCohortStages})); } }; useEffect(() => { if (!Object.keys(cohortData).length || cohortData.stage === "name" || objectsEqual(initialData, cohortData)) return; if (cohortId) { console.log("update", cohortId, cohortData); firebaseQuery.update(["cohorts", cohortId], cohortData); return; } firebaseQuery.add(["cohorts"], {...cohortData, product: product, oId: oId}).then((doc) => { setCohortId(doc.id); }); }, [cohortData]); const submitCohort = () => { if (cohortId) { firebaseQuery.update(["cohorts", cohortId], {stage: "created"}); return; } }; const deleteCohort = async () => { if (cohortId) { await firebaseQuery.delete(["cohorts", cohortId]); } }; const back = () => { setCohortData((p) => ({...p, stage: cohortStages[cohortStages.indexOf(cohortData?.stage)-1] as CreateCohortStages})); }; return ({...{submitCohort, submitSection, back, setCohortData, deleteCohort, cohortData, cohortId}}); } type UserUploadParams = { userType: "Staff"|"Students"|"eventStudents", user: UserData, onComplete?: (e: string) => void, cohortId?: string, userGroupId?: string, shareNameWithReferralLeaderboardConsent?: string, product: Products, oId: string, event?: Partial, eventId?: string } export function useUserUploadHandler({product, oId, event, eventId, userType, user, onComplete, userGroupId, cohortId}: UserUploadParams) { const [emptyCellsWarning, setEmptyCellsWarning] = useState(false); const [alert, setAlert] = useState<{severity: "warning"|"error"|"success"|"info", msg: string}>(); const {execute} = useExecuteCallableJob({user: user}); const requiredFields = ["forename", "surname", "email"]; const checkData = (userData: {email?: string, parentEmail?: string, year?: number, [key:string]: unknown}[]) => { setAlert(undefined); userData = userData.filter((u) => Object.entries(u).some(([, v]) => v)); if (!Object.entries(userData)) { return []; } const emailDuplicateLookup = userData.reduce((acc, e, index) => { if (!e.email) return acc; acc[e.email] = [...(acc[e.email] || []), index]; return acc; }, {} as {[email:string]: number[]}); const emailDuplicates = Object.entries(emailDuplicateLookup).filter(([_, ids]) => ids.length > 1).map(([email]) => email) if (userData.filter((u) => u.email).length < userData.length) { setAlert({msg: "Your data contains missing email addresses.", severity: "error"}); return false; } console.log("emailDuplicates", emailDuplicates); if (emailDuplicates.length) { setAlert({msg: `Your data contains multiple rows with the same email address. Please remove these and reupload.\n\n Duplicate emails: ${emailDuplicates.join(", ")}`, severity: "error"}); return false; } let emptyOptionalCell = false; let emptyRequiredCell = false; for (const user of userData ) { if (!checkEmailValidity(user)) { return false; } if (Object.keys(user).includes("")) { setAlert({msg: "Data cannot be uploaded in unnamed columns.", severity: "error"}); return false; } for (const field in user) { if (!user[field]) { if (requiredFields.includes(field)) { setAlert({msg: "All users must contain "+requiredFields.join(", "), severity: "error"}); emptyRequiredCell = true; } emptyOptionalCell = true; } } // Add more test cases } if (emptyRequiredCell) { return false; } if (emptyOptionalCell && !emptyCellsWarning) { setAlert({msg: "You have empty cell(s). Continue to submit or upload an amended file.", severity: "warning"}); setEmptyCellsWarning(true); return false; } else { setEmptyCellsWarning(false); } return userData; }; const checkEmailValidity = (user: {[key:string]: unknown}) => { if (!validateEmail(user.email as string)) { setAlert({msg: `Error in email formatting: ${user.email}. Amend errors and reupload.`, severity: "error"}); return false; } if (user.parentEmail && !validateEmail(user.parentEmail as string)) { setAlert({msg: `Error in parent email formatting: ${user.parentEmail}. Amend errors and reupload.`, severity: "error"}); return false; } return true; }; const uploadUsers = async (users: {email?: string, parentEmail?: string, year: number, [key:string]: unknown}[]) => { let fUsers:{email?: string, parentEmail?: string, [key:string]: unknown}[] = []; const cleanUpload = checkData(users); if (!cleanUpload) { return; } fUsers = cleanUpload; if (userType === "Students" && !cohortId) { setAlert({msg: "No cohort associated with user uploads. Contact admin@careerthread.co.uk to address this error", severity: "error"}); } setAlert(undefined); if (fUsers.length) { console.log("fUsers", fUsers); const jobId = await execute(userType === "eventStudents" ? "events-addStudents" : "userManagement-addUsers", {product: product, oId: oId, event: event, eventId: eventId, users: fUsers, userType: userType, userGroupId: userGroupId, cohortId: cohortId}); onComplete && onComplete(jobId.jobId); } console.log("Complete", onComplete); console.log("Finished"); }; const onChange = () => { setAlert(undefined); setEmptyCellsWarning(false); }; return ({...{uploadUsers, alert, onChange}}); } type WorkflowEditorParams = { user: UserData, initialData?: WorkflowStage[]|ApplicantStage[] onSubmit: (e:{workflow: WorkflowStage[], includedFiles: string[], includedForms: string[]}) => void, cohortId?: string, product?: Products, oId?: string, } export function useWorkflowEditor({user, initialData, onSubmit, cohortId}:WorkflowEditorParams) { const firebaseQuery = new FirebaseQuery(); const {filePopupActive, files, includedFiles, includedForms, uploadFile, fAddNode, fOnDelete, arrows, onChange, error, onMoveEnd, addEdgePoint, fWorkflowNodes, newEdgePoint, setTutorialActive, setFilePopupActive, fSubmitWorkflow, openPopup, setSnackbar, snackbar, setMousePosFunc, mousePos, tutorialActive, onDeleteArrow, containerRef, setError, setArrows, setFWorkflowNodes} = useGenericWorkflowEditor({...{user, initialData}, defaultData: defaultInstituteWorkflow, onSubmit: onSubmit}); const addNode = ({userType, nextStage, prevStage}:{userType?: "Students" | "Staff" | "Provider" | "Parent", nextStage?: number, prevStage?: number} = {}) => { return fAddNode({userType: userType}, `${userType} review`, nextStage, prevStage); }; const onDelete = useCallback(async (id?: number) => { if (!id) { return; } return fOnDelete(id, onDeleteValidator) }, []); const onDeleteValidator = async (id: number): Promise<{res: boolean, reason?: string}> => { const placementsOnStage = await firebaseQuery.getCount("placements", [where("oId", "==", user.oId), where("cohort", "==", cohortId), where("status", "==", id)]); if (placementsOnStage > 0) { return {res: false, reason: "Cannot delete a stage with current active placements."} } if ([1, 6, 8, 11].includes(id)) { return {res: false, reason: "Cannot delete core workflow component."} } return {res: true}; } const submitWorkflow = async (workflow?: WorkflowStage[]|ApplicantStage[], callOnComplete=true) => { const validatorTypeConversion = (newWorkflow: GenericWorkflowStage[], oldWorkflow?: GenericWorkflowStage[]) => { return validateWorkflow(newWorkflow as WorkflowStage[], oldWorkflow as WorkflowStage[]) } return fSubmitWorkflow(validatorTypeConversion, workflow, callOnComplete) }; const validateWorkflow = async (newWorkflow: WorkflowStage[], oldWorkflow?: WorkflowStage[]) => { const getStageById = (id: number, checkInitialData ?: boolean) => { let node:WorkflowStage|undefined; if (checkInitialData) { if (!oldWorkflow?.length) { throw new Error("Check initial data but no oldWorkflow"); } node = (oldWorkflow).find((x) => x.id === id); } else { node = (newWorkflow).find((x) => x.id === id); } if (!node) { throw new Error("Error in configuration. Cannot find stage: "+id); } return node; }; console.log("workflows, new, old", newWorkflow, oldWorkflow); // Steps: // Check if any deleted stages that have placements assigned to them const missingNodes = oldWorkflow && oldWorkflow.length ? oldWorkflow.reduce((acc, oldNode) => { const existsInNewWorkflow = Boolean(newWorkflow.find((newNode) => newNode.id === oldNode.id)) if (!existsInNewWorkflow) { acc.push(oldNode.id); } return acc; }, [] as number[]) : []; await Promise.all(missingNodes.map(async (nodeId) => { const placementsOnStage = await firebaseQuery.getCount("placements", [where("oId", "==", user.oId), where("cohort", "==", cohortId), where("status", "==", nodeId)]); if (placementsOnStage > 0) { throw new Error("Cannot delete a stage with current active placements.") } })); // After verifying, we have a list of stages that // Create paths with new workflow // Check cyclic // Check early termination // Check missed items // Create paths from old workflow // Check cyclic // Check early termination // Check missed items const paths:(WorkflowStage[])[] = []; const createPaths = (stage: WorkflowStage, path:WorkflowStage[], checkingInitialData?: boolean) => { if (!stage) return; const currentPath = [...(path || [])]; // Check if infinite cycles if (currentPath.find((s) => s.id === stage.id)) { // ID must be already in it, so a loop has been formed. // Get just the elements in that cycle, such as [1, 2, 3, 4, 1] const cyclicPath = currentPath.slice(currentPath.findIndex((s) => s.id === stage.id)); // If we have a cycle [1, 2, 3, 4, 1], check if any of them have a way out. const escapableCycle = cyclicPath.some((cycleStage, idx) => { let cyclicStageButtons = cycleStage?.buttons || []; // Keep only the arrows that don't go to the next one already documented, or the ID being checked. cyclicStageButtons = cyclicStageButtons.filter((button) => ![cyclicPath[idx+1], stage.id].includes(button.id)); return cyclicStageButtons.length > 0; }); if (!escapableCycle) { throw new Error(`Inescapable cycle detected at ${stage.name}. Amend and resubmit.`) } return; } currentPath.push(stage); if (!stage?.buttons?.length) { console.log("Final path", currentPath, checkingInitialData); // Check if in order and ends in 11 if (stage.id !== 11) { throw new Error("All paths must end with the 'Workflow end' stage. Amend and reupload."); } //Check if a valid path. const validEssentialRoute = [1, "Provider", 6, 8, 11]; const currentEssentialRoute = currentPath.reduce((acc, node) => { if ([1, 6, 8, 11].includes(node.id)) { acc.push(node.id); return acc; } if (node?.userType !== "Provider") return acc; if (!acc.includes(6) && !acc.includes("Provider")) { acc.push("Provider"); } return acc; }, [] as Array); const valid = arraysEqual(currentEssentialRoute, validEssentialRoute); if (!valid) { console.log("Error in", currentEssentialRoute); throw new Error(checkingInitialData ? "Cannot submit. Changing workflow will cause current placements to skip core workflow components." : "Missing core workflow components. Ensure all placements have a provider review, start, end and workflow end in that order.") } paths.push(currentPath); return; } if (checkingInitialData) { stage.buttons.forEach((button) => { createPaths(getStageById(button.id, true), currentPath, checkingInitialData); }); } const newWorkflowStage = newWorkflow.find((newStage) => newStage.id === stage.id); if (!newWorkflowStage) { if (checkingInitialData) { return; } throw new Error("Cannot find node: "+stage.id); } newWorkflowStage.buttons?.forEach((button) => { console.log("stage", stage, checkingInitialData); createPaths(getStageById(button.id), currentPath); }); }; createPaths(getStageById(1), []); if (oldWorkflow && oldWorkflow.length) { createPaths(getStageById(1, true), [], true); } return paths.every((x) => x); }; const workflowNodes = fWorkflowNodes as WorkflowStage[]; const setWorkflowNodes = setFWorkflowNodes as React.Dispatch> return ({...{filePopupActive, files, uploadFile, addNode, onDelete, arrows, onChange, error, includedFiles, onMoveEnd, addEdgePoint, newEdgePoint, setTutorialActive, setFilePopupActive, includedForms, submitWorkflow, openPopup, snackbar, setSnackbar, setMousePosFunc, mousePos, tutorialActive, onDeleteArrow, workflowNodes, containerRef, setError, setArrows, setWorkflowNodes}}); } type ApplicantWorkflowEditorParams = { user: UserData, initialData?: ApplicantStage[] onSubmit: (e:{workflow: ApplicantStage[], includedFiles: string[], includedForms: string[]}) => void, workflowId?: string, product?: Products, oId?: string, } export function useApplicantWorkflowEditor({user, initialData, onSubmit, workflowId}:ApplicantWorkflowEditorParams) { const firebaseQuery = new FirebaseQuery(); const {filePopupActive, includedFiles, includedForms, uploadFile, fAddNode, fOnDelete, arrows, onChange, error, onMoveEnd, addEdgePoint, fWorkflowNodes, newEdgePoint, setTutorialActive, setFilePopupActive, fSubmitWorkflow, openPopup, setSnackbar, snackbar, setMousePosFunc, mousePos, tutorialActive, onDeleteArrow, containerRef, setError, setArrows, setFWorkflowNodes} = useGenericWorkflowEditor({...{user, initialData}, defaultData: workflowId ? [] : defaultApplicantWorkflow, onSubmit: onSubmit}); useEffect(() => { if (!workflowId) return; firebaseQuery.getDocData(["applicantWorkflows", workflowId]).then((e) => setFWorkflowNodes(e.workflow)) }, [workflowId]); const addNode = ({userType, nextStage, prevStage}:{userType?: "Students" | "Staff", nextStage?: number, prevStage?: number} = {}) => { return fAddNode({userType: userType, buttons: [{name: "Reject", id: 12, required: true}]}, "", nextStage, prevStage); }; const onDelete = useCallback(async (id?: number) => { if (!id) { return; } return fOnDelete(id, onDeleteValidator) }, []); const onDeleteValidator = async (id: number): Promise<{res: boolean, reason?: string}> => { const placementsOnStage = await firebaseQuery.getCount("placement", [where("providerId", "==", user.oId), where("applicantWorkflowId", "==", workflowId), where("applicantStage", "==", id)]); if (placementsOnStage > 0) { return {res: false, reason: "Cannot delete a stage with current applicants."} } if ([1, 11, 12].includes(id)) { return {res: false, reason: "Cannot delete core workflow component."} } return {res: true}; } const submitWorkflow = async (workflow?: ApplicantStage[], callOnComplete=true) => { const validatorTypeConversion = (newWorkflow: GenericWorkflowStage[], oldWorkflow?: GenericWorkflowStage[]) => { return validateWorkflow(newWorkflow as ApplicantStage[], oldWorkflow as ApplicantStage[]) } return fSubmitWorkflow(validatorTypeConversion, workflow, callOnComplete) }; // Change validator const validateWorkflow = async (newWorkflow: ApplicantStage[], oldWorkflow?: ApplicantStage[]) => { const getStageById = (id: number, checkInitialData ?: boolean) => { let node:ApplicantStage|undefined; if (checkInitialData) { if (!oldWorkflow?.length) { throw new Error("Check initial data but no oldWorkflow"); } node = (oldWorkflow).find((x) => x.id === id); } else { node = (newWorkflow).find((x) => x.id === id); } if (!node) { throw new Error("Error in configuration. Cannot find stage: "+id); } return node; }; console.log("workflows, new, old", newWorkflow, oldWorkflow); // Steps: // Check if any deleted stages that have placements assigned to them const missingNodes = oldWorkflow && oldWorkflow.length ? oldWorkflow.reduce((acc, oldNode) => { const existsInNewWorkflow = Boolean(newWorkflow.find((newNode) => newNode.id === oldNode.id)) if (!existsInNewWorkflow) { acc.push(oldNode.id); } return acc; }, [] as number[]) : []; await Promise.all(missingNodes.map(async (nodeId) => { const placementsOnStage = await firebaseQuery.getCount("placement", [where("providerId", "==", user.oId), where("applicantWorkflow", "==", workflowId), where("applicantStage", "==", nodeId)]); if (placementsOnStage > 0) { throw new Error("Cannot delete a stage with current active placements.") } })); // After verifying, we have a list of stages that // Create paths with new workflow // Check cyclic // Check early termination // Check missed items // Create paths from old workflow // Check cyclic // Check early termination // Check missed items const paths:(ApplicantStage[])[] = []; const createPaths = (stage: ApplicantStage, path:ApplicantStage[], checkingInitialData?: boolean) => { if (!stage) return; const currentPath = [...(path || [])]; // Check if infinite cycles if (currentPath.find((s) => s.id === stage.id)) { // ID must be already in it, so a loop has been formed. // Get just the elements in that cycle, such as [1, 2, 3, 4, 1] const cyclicPath = currentPath.slice(currentPath.findIndex((s) => s.id === stage.id)); // If we have a cycle [1, 2, 3, 4, 1], check if any of them have a way out. const escapableCycle = cyclicPath.some((cycleStage, idx) => { let cyclicStageButtons = cycleStage?.buttons || []; // Keep only the arrows that don't go to the next one already documented, or the ID being checked. cyclicStageButtons = cyclicStageButtons.filter((button) => ![cyclicPath[idx+1], stage.id].includes(button.id)); return cyclicStageButtons.length > 0; }); if (!escapableCycle) { throw new Error(`Inescapable cycle detected at ${stage.name}. Amend and resubmit.`) } return; } currentPath.push(stage); if (!stage?.buttons?.length) { console.log("Final path", currentPath, checkingInitialData); // Check if in order and ends in 11 if (![11, 12].includes(stage.id)) { throw new Error("All paths must end with the 'Success' or 'Reject' stage. Amend and reupload."); } paths.push(currentPath); return; } if (checkingInitialData) { stage.buttons.forEach((button) => { createPaths(getStageById(button.id, true), currentPath, checkingInitialData); }); } const newWorkflowStage = newWorkflow.find((newStage) => newStage.id === stage.id); if (!newWorkflowStage) { if (checkingInitialData) { return; } throw new Error("Cannot find node: "+stage.id); } newWorkflowStage.buttons?.forEach((button) => { createPaths(getStageById(button.id), currentPath); }); }; createPaths(getStageById(1), []); if (oldWorkflow && oldWorkflow.length) { createPaths(getStageById(1, true), [], true); } return paths.every((x) => x); }; const workflowNodes = fWorkflowNodes as ApplicantStage[]; const setWorkflowNodes = setFWorkflowNodes as React.Dispatch> return ({...{filePopupActive, uploadFile, addNode, onDelete, arrows, onChange, error, includedFiles, onMoveEnd, addEdgePoint, newEdgePoint, setTutorialActive, setFilePopupActive, includedForms, submitWorkflow, openPopup, snackbar, setSnackbar, setMousePosFunc, mousePos, tutorialActive, onDeleteArrow, workflowNodes, containerRef, setError, setArrows, setWorkflowNodes}}); } type GenericWorkflowEditorParams = { user: UserData, initialData?: GenericWorkflowStage[], defaultData: GenericWorkflowStage[], onSubmit: (e: { workflow: GenericWorkflowStage[]; includedFiles: string[]; includedForms: string[]; }) => void } type GenericWorkflowStage = { id: number, checkpoint?: boolean, permanent?: boolean, description?: string, name: string, buttons?: {id: number, name: string|false, description?: string, required: boolean}[], pos?: { x: number, y: number }, forms?: string[], files?: string[], [key: string]: unknown }; function useGenericWorkflowEditor({user, initialData, defaultData, onSubmit}: GenericWorkflowEditorParams) { const [filePopupActive, setFilePopupActive] = useState(false); const [files, setFiles] = useState<{name: string, url: string}[]>([]); const [fWorkflowNodes, setFWorkflowNodes] = useState([]); // Workflow data const [arrows, setArrows] = useState([]); const [includedForms, setIncludedForms] = useState([]); const [includedFiles, setIncludedFiles] = useState([]); const [error, setError] = useState(); const [snackbar, setSnackbar] = useState(); const [newEdgePoint, setNewEdgePoint] = useState(); const [mousePos, setMousePos] = useState<{x: number, y: number}>(); const [tutorialActive, setTutorialActive] = useState(false); const containerRef = useRef(null); let ticking = false; useEffect(() => { let newData = initialData; if (initialData === undefined || initialData.length === 0) { newData = defaultData; setTimeout(() => { setTutorialActive(false); }, 1000); } getFiles(`${user.product}/${user.oId}/files`).then(setFiles); setFWorkflowNodes(newData || []); }, [initialData]); useEffect(() => { // New edge points if (!newEdgePoint) { return; } const removeListener = (e: MouseEvent) => { setNewEdgePoint(undefined); e.preventDefault(); }; window.addEventListener("contextmenu", removeListener); return () => { setMousePos(undefined); window.removeEventListener("contextmenu", removeListener); }; }, [newEdgePoint]); const setMousePosFunc = (e: React.MouseEvent) => { if (!newEdgePoint) { return; } if (!ticking) { requestAnimationFrame(() => { ticking = false; setMousePos({x: e.pageX, y: e.pageY}); }); } ticking = true; }; const uploadFile = async (files: any[]) => { const res = await uploadFiles(files, `${user.product}/${user.oId}/files/`); if (res===1) { // change this setFilePopupActive(false); getFiles(`${user.product}/${user.oId}/files`).then(setFiles); } }; const fAddNode = (additionalData?: {buttons?: {required: boolean, name: string, id: number}[], [key: string]: unknown}, name?: string, nextStage?: number, prevStage?: number) => { let nodeId = getUniqueId(fWorkflowNodes); // Add user type and name for institute placement workflow! setFWorkflowNodes((p) => { let newWorkflowNodes = ([...p, {id: nodeId, pos: {x: 10, y: 10}, ...additionalData, name: name || `Stage ${nodeId}`, buttons: nextStage ? [...(additionalData?.buttons || []), {required: true, name: "Accept", id: nextStage}] : []}]); if (prevStage) { const prevStageIndex = p.findIndex((node) => node.id === prevStage); newWorkflowNodes[prevStageIndex] = {...newWorkflowNodes[prevStageIndex], buttons: [{required: true, name: "Accept", id: nodeId}]} } return newWorkflowNodes; }); return nodeId; }; const fOnDelete = useCallback(async (id?: number, validation?: (id: number) => Promise<{res: boolean, reason?: string}>) => { if (!id) { return; } if (validation) { // Add placementsOnStage to validation const validationResult = await validation(id); if (!validationResult.res) { setError(validationResult.reason); return; } } setFWorkflowNodes((prev) => { let oldMutable = [...prev]; // Buttons going from deleted nodes to new node const deletedNodeButtons = oldMutable.find((node) => node.id === id)?.buttons; oldMutable = oldMutable.filter((e) => e.id.toString() !== id.toString()); const arrowsToDeletedNode = oldMutable.map((e, i) => { const node = {...e}; // Remove any arrows to this node const nodeFeedsToDeleted = Boolean(node.buttons?.find((destination) => destination.id === id)) if (nodeFeedsToDeleted) { console.log("Delete line from node", node.id); } if (nodeFeedsToDeleted) { const newNodeButtons = node.buttons ? node.buttons.filter((button) => id.toString() !== button.id.toString()) : []; node.buttons = newNodeButtons; console.log("New node buttons", newNodeButtons, node.buttons); } oldMutable[i] = node; return nodeFeedsToDeleted ? node : undefined; }).filter((i) => i) as { id: number; name: string | false; required: boolean; }[]; console.log("arrows to and destinations", arrowsToDeletedNode, deletedNodeButtons); if (deletedNodeButtons?.length && arrowsToDeletedNode.length) { const newNodeDestination = deletedNodeButtons[0].id; const newNodeOrigin = arrowsToDeletedNode[0].id; const indexOfOrigin = oldMutable.findIndex((node) => node.id === newNodeOrigin); oldMutable[indexOfOrigin] = {...oldMutable[indexOfOrigin], buttons: [...(oldMutable[indexOfOrigin].buttons || []), {id: newNodeDestination, required: true, name: "Submit"}]} } return oldMutable; }); }, []); useEffect(() => { error && setError(undefined); if (!fWorkflowNodes) { return; } setArrows(fWorkflowNodes.reduce((acc, node) => { if (node.buttons) { node.buttons.forEach((buttonData) => { acc.push({ start: node.id, end: buttonData.id, name: buttonData.name, required: Boolean(buttonData.required), }); }); } return acc; }, [] as ArrowObject[])); const mIncludedFiles = fWorkflowNodes.reduce((acc, node) => { if (node.files) { acc.push(...node.files.map((f) => { try { return JSON.parse(f).name; } catch { return f; } })); } return arrayUniqueValues(acc) as string[]; }, [] as string[]); const mIncludedForms = fWorkflowNodes.reduce((acc, node) => { if (node.forms) { acc.push(...node.forms); } return arrayUniqueValues(acc) as string[]; }, [] as string[]) setIncludedFiles(mIncludedFiles); setIncludedForms(mIncludedForms); }, [fWorkflowNodes]); const onChange = useCallback((path: (string|number)[], value: unknown) => { setFWorkflowNodes((prev) => editNestedObject(path, prev, value, true) as GenericWorkflowStage[]); }, []); const fSubmitWorkflow = async (validation: (newWorkflow: GenericWorkflowStage[], oldWorkflow?: GenericWorkflowStage[]) => Promise, workflow?: GenericWorkflowStage[], callOnComplete=true) => { setError(undefined); const fWorkflow = workflow || fWorkflowNodes; const mIncludedFiles = fWorkflow.reduce((acc, node) => { if (node.files) { acc.push(...node.files.map((f) => { try { return JSON.parse(f).name; } catch { return f; } })); } return arrayUniqueValues(acc) as string[]; }, [] as string[]); const mIncludedForms = fWorkflow.reduce((acc, node) => { if (node.forms) { acc.push(...node.forms); } return arrayUniqueValues(acc) as string[]; }, [] as string[]) setIncludedFiles(mIncludedFiles); setIncludedForms(mIncludedForms); if (initialData === fWorkflow) { return callOnComplete && onSubmit({workflow: fWorkflow, includedFiles: mIncludedFiles, includedForms: mIncludedForms}); } const validatedWorkflow = await validation(fWorkflow, initialData).catch((e: Error) => { setError(e.message); throw e; }) if (!validatedWorkflow) { return; } return callOnComplete && onSubmit({workflow: fWorkflow, includedFiles: includedFiles, includedForms: includedForms}); }; const deleteEdge = useCallback((originId: number|string, nodeId: number|string) => { setFWorkflowNodes((prev) => editNestedObject([originId, "buttons", nodeId], prev, undefined, true) as GenericWorkflowStage[]); }, []); const onDeleteArrow = useCallback((data: ArrowObject) => { deleteEdge(data.start, data.end); }, [deleteEdge]); const onMoveEnd = useCallback((id: number, currentRef?: HTMLDivElement) => { if (!containerRef.current || !currentRef) return; const clientRect = currentRef.getBoundingClientRect(); const {left, top} = containerRef.current.getBoundingClientRect(); const x = containerRef.current.scrollLeft + clientRect.x-left; const y = containerRef.current.scrollTop + clientRect.y-top-26; // Make sure x and y are not negative. Contrains canvas by top and left const pos = {x: x >= 0 ? x : 0, y: y >= 0 ? y : 0}; setFWorkflowNodes((p) => (editNestedObject([id, "pos"], p, pos) as GenericWorkflowStage[])); }, []); const addEdgePoint = useCallback((id: number, existingEdgePoint: number) => { if (!existingEdgePoint) { if (id === 11) { setSnackbar("Cannot connect stages after workflow end."); return; } const buttons = fWorkflowNodes.find((node) => node.id === id)?.buttons; if ([1, 11].includes(id) && buttons && buttons.length > 0) { setSnackbar("Stage can only have one destination."); return; } setNewEdgePoint(id); return; } // Prevent making arrow to own stage if (existingEdgePoint === id) { setNewEdgePoint(undefined); return; } const originPos = fWorkflowNodes.findIndex((x) => x.id.toString() === existingEdgePoint.toString()); const buttons: {id: number, name: string|false, required: boolean}[] = [...(fWorkflowNodes[originPos].buttons || [])]; if (!buttons.some((x) => x.id.toString() === id.toString())) { buttons.push({ id: id, required: false, name: [1, 6, 8].includes(existingEdgePoint) ? false : "", }); } setFWorkflowNodes((p) => editNestedObject([existingEdgePoint, "buttons"], p, buttons, true) as WorkflowStage[]); setNewEdgePoint(undefined); }, [fWorkflowNodes]); const openPopup = useCallback(() => { setFilePopupActive(true); }, []); return ({...{filePopupActive, files, uploadFile, fAddNode, fOnDelete, arrows, onChange, error, includedFiles, onMoveEnd, addEdgePoint, newEdgePoint, setTutorialActive, setFilePopupActive, includedForms, fSubmitWorkflow, openPopup, snackbar, setSnackbar, setMousePosFunc, mousePos, tutorialActive, onDeleteArrow, fWorkflowNodes, containerRef, setError, setArrows, setFWorkflowNodes}}); } type InstituteProviderContactUploadParams = { user: UserData, addToEvent?: boolean onComplete?: () => void } export type InstituteProviderContactUpload = { business: string; forename?: string; surname?: string; email: string; phone?: string; addressOne?: string; addressTwo?: string; city?: string; postcode?: string; country?: string; }; export function useInstituteProviderContactsHandler({user, addToEvent}: InstituteProviderContactUploadParams) { const [emptyCellsWarning, setEmptyCellsWarning] = useState(false); const [alert, setAlert] = useState<{severity: "warning"|"error"|"success"|"info", msg: string}>(); const requiredFields = ["business", "email"]; const {execute} = useExecuteCallableJob({user: user}); const checkData = (providerContacts: InstituteProviderContactUpload[]) => { setAlert(undefined); providerContacts = providerContacts.filter((u) => Object.entries(u).some(([, v]) => v)); if (!Object.entries(providerContacts)) { return []; } console.log("P", providerContacts); if (providerContacts.filter((u) => u.email).length < providerContacts.length) { setAlert({msg: "Your data contains missing email addresses.", severity: "error"}); return false; } let emptyOptionalCell = false; let emptyRequiredCell = false; for (const providerContact of providerContacts ) { if (!checkEmailValidity(providerContact)) { return false; } if (Object.keys(providerContact).includes("")) { setAlert({msg: "Data cannot be uploaded in unnamed columns.", severity: "error"}); return false; } for (const field in providerContact) { if (!providerContact[field]) { if (requiredFields.includes(field)) { setAlert({msg: "All users must contain "+requiredFields.join(", "), severity: "error"}); emptyRequiredCell = true; } emptyOptionalCell = true; } } } if (emptyRequiredCell) { return false; } if (emptyOptionalCell && !emptyCellsWarning) { setAlert({msg: "You have empty cell(s). Continue to submit or upload an amended file.", severity: "warning"}); setEmptyCellsWarning(true); return false; } else { setEmptyCellsWarning(false); } return providerContacts; }; const checkEmailValidity = (providerContact: InstituteProviderContactUpload) => { if (!validateEmail(providerContact.email as string)) { setAlert({msg: `Error in email formatting: ${providerContact.email}. Amend errors and reupload.`, severity: "error"}); return false; } return true; }; const uploadProviderContacts = async (providers: InstituteProviderContactUpload[], schoolId?: string) => { let fProviders:InstituteProviderContactUpload[] = []; console.log("PP", providers); const cleanUpload = checkData(providers); if (!cleanUpload) { return false; } fProviders = cleanUpload; setAlert(undefined); if (fProviders.length) { if (addToEvent) { const addedContacts = await executeCallable("providerContacts-add", {providerContacts: fProviders, instituteId: user.product === "admin" ? "admin" : user.oId, schoolId: schoolId}) return addedContacts.data as string[]; } execute("providerContacts-add", {providerContacts: fProviders, instituteId: user.product === "admin" ? "admin" : user.oId, schoolId: schoolId}); } return true; }; const onChange = () => { setAlert(undefined); setEmptyCellsWarning(false); }; return ({...{uploadProviderContacts, alert, onChange}}); } export async function getIndividualPlacementForTaskList({type, placement}:{placement: StudentPlacementData, type: "insurance"|"dbsCheck"|"riskAssessment"}) { const newPlacementData:StudentPlacementData&{ eliData?: string, riskAssessmentURL?: string, dbsCheckURL?: string, } = {...placement}; if (type === "riskAssessment") { if (placement.riskAssessmentType === "file") { console.log("Risk assessment"); const storageRef = ref(storage, `riskAssessments/${placement.placementId || placement.id}.pdf`); const file = await getDownloadURL(storageRef).catch(async () => await getDownloadURL(ref(storage, `riskAssessments/${placement.id}.pdf`)).catch(() => undefined)); newPlacementData.riskAssessmentURL = file; } } if (type === "insurance") { const storageRef = ref(storage, `insurance/${placement.providerContactId}.pdf`); const file = await getDownloadURL(storageRef).catch(() => placement.insuranceSkippedReason); newPlacementData.eliData = file; } if (type === "dbsCheck") { if (placement.dbsCheckType === "file") { const storageRef = ref(storage, `dbsChecks/${placement.placementId || placement.id}.pdf`); const file = await getDownloadURL(storageRef); newPlacementData.dbsCheckURL = file; } } return newPlacementData; } export function useGetIndividualPlacementForPlacementPage({user, placementId, organisation}:{user: UserData, placementId: string, organisation?: {details: InstituteData|ProviderData, cohorts: {[key: string]: CohortData}}}) { const [placement, setPlacement] = useState(); const [institute, setInstitute] = useState(user.product === "institutes" ? organisation?.details as InstituteData : undefined); const [workflow, setWorkflow] = useState(); const [cohort, setCohort] = useState(); const [student, setStudent] = useState(); const [wStage, setWStage] = useState(); const [snackbar, setSnackbar] = useState<{ open: boolean; message?: string, color?: string, button?: {title: string, onClick: () => any}}>({open: false}); const [disableEmail, setDisableEmail] = useState({parent: false, provider: false}); const [rejectELIPopup, setRejectELIPopup] = useState(false); const [eliPopupOpen, setEliPopupOpen] = useState(false); const [eliData, setELIData] = useState(); const [rejectExternalDocPopup, setRejectExternalDocPopup] = useState<"riskAssessment"|"dbsCheck"|false>(false); const [externalDocPopupOpen, setExternalDocPopupOpen] = useState<"riskAssessment"|"dbsCheck"|false>(false); const [riskAssessmentURL, setRiskAssessmentURL] = useState(""); const [dbsCheckURL, setDbsCheckURL] = useState(""); const [uploadProviderDocPopup, setUploadProviderDocPopup] = useState<"insurance"|"riskAssessment"|"DbsCheck"|undefined>(); const [skipStagePopup, setSkipStagePopup] = useState(false); const [viewExternalLinkPopup, setViewExternalLinkPopup] = useState(false); const [externalLinkCopied, setExternalLinkCopied] = useState(false); const [uploadInsurance, setUploadInsurance] = useState(false); const [uploadRA, setUploadRA] = useState(false); const [uploadDBS, setUploadDBS] = useState(false); const [onboardingPopup, setOnboardingPopup] = useState(false); const [dismissOnboardingPopup, setDismissOnboardingPopup] = useState(false); const [addOnboardingDocsPopup, setAddOnboardingDocsPopup] = useState(false); const [shareStudentRequestPopup, setShareStudentRequestPopup] = useState(false); const [editable, setEditable] = useState(false); const [withdrawFromPlacementPopup, setWithdrawFromPlacementPopup] = useState(false); const firebaseQuery = new FirebaseQuery(); useEffect(() => { if (!placementId) return; getPlacementbyId(placementId, setPlacement); }, [placementId]); useEffect(() => { console.log("p", placement); if (!placement) { return; } setEditable(user.userType === "Staff" || !((placement.providerCompleted && placement.providerCompleted.includes("details")) || placement.completed)); if (placement.oId) { if (user.product === "institutes") { setInstitute(organisation?.details as InstituteData); } else if (user.product === "providers") { firebaseQuery.getDocData(["institutes", placement.oId]).then((i) => setInstitute(i as InstituteData)); } } if (placement.cohort) { if (user.product === "institutes" && user.oId === placement.oId) { setCohort(organisation?.cohorts[placement.cohort]); setWorkflow(organisation?.cohorts[placement.cohort]?.workflow); } else { firebaseQuery.getDocData(["cohorts", placement.cohort]).then((w) => { setWorkflow(w.workflow); setCohort(w as CohortData); }); } } else { setWorkflow(defaultStudentWorkflow); } if (user.userType === "Students") { setStudent(user); } else { if (placement.uid) { getUserById(placement.uid, undefined, false).then(setStudent); } else { setStudent({ details: { forename: placement.studentForename || "", surname: placement.studentSurname || "", }, email: placement.studentEmail || "" } as UserData) } } }, [placement]); useEffect(() => { if (!workflow || !placement) return; const getAdditionalStageData = async ():Promise => { const currentWorkflowStage = {...workflow.find((obj) => obj.id === placement.status) as WorkflowStage&{files: {[fileId: string]: {name: string, url: string}}}}; // console.log("currentWorkflowStage", currentWorkflowStage) currentWorkflowStage.id = placement.status; // Get form data for current stage if (currentWorkflowStage.forms) { getFormsFromId(["forms"], currentWorkflowStage.forms).then((details) => { currentWorkflowStage.formDetails = details as [{name:string}]; }); } if (currentWorkflowStage.files) { currentWorkflowStage.files = Object.fromEntries(await Promise.all(currentWorkflowStage.files.map(async (file) => { const fileItem = await firebaseQuery.getDocData(["files", file]) as FileItem; const url = await getDownloadURL(ref(storage, `institutes/${fileItem.oId}/${fileItem.fileName}`)) return [file, {name: fileItem.name, url: url}]; }))); } return currentWorkflowStage; } getAdditionalStageData().then(setWStage); }, [placement, workflow]); const editStage = async (nextStageId:number) => { if (!placementId || !wStage) return; await editPlacementStage(placementId, wStage.id, nextStageId); setSnackbar({open: true, message: "Stage updated."}); }; const sendEmail = (type:"provider"|"parent", forceSend?: boolean) => { if (!placement || !student) return undefined; const sendRequest = async () => { await executeCallable("placement-sendExternalEmail", {pId: placementId, userType: type}); setSnackbar({open: true, message: "Email sent."}); return; }; if ((type === "provider" && !placement.providerEmail) || (type === "parent" && !student.details.parentEmail)) { setSnackbar({open: true, message: `Please add a ${type} email.`}); return; } const insuranceRequired = Boolean(workflow?.find((i) => i.eli)) ? placement.insurance : true; const raRequired = Boolean(workflow?.find((i) => i.riskAssessment)) ? placement.riskAssessment : true; const dbsRequired = Boolean(workflow?.find((i) => i.dbsCheck)) ? placement.dbsCheck : true; const completedAllDocs = insuranceRequired && raRequired && dbsRequired; if (!forceSend && (type === "provider" && wStage?.userType !== "Provider" && completedAllDocs) || (type === "parent" && wStage?.userType !== "Parent")) { setSnackbar({open: true, message: "We don't currently need any information from this user.", color: "primary", button: { title: "Send Anyway", onClick: () => sendEmail(type, true) }}); return; } if (!placement[`${type}Emailed`]) { setDisableEmail((x) => ({...x, [type]: true})); sendRequest().then(() => setDisableEmail((x) => ({...x, [type.toLowerCase()]: false}))); return; } const previousEmailTime = new Date(placement[`${type}Emailed`].seconds * 1000); const today = new Date(); const timeSinceEmail = getDateDiff(previousEmailTime, today); if (timeSinceEmail === 0) { setSnackbar({open: true, message: "Emails can only be sent after 1 day."}); return; } setDisableEmail((x) => ({...x, [type.toLowerCase()]: true})); sendRequest().then(() => setDisableEmail((x) => ({...x, [type.toLowerCase()]: false}))); }; const onboardingStatus:"Add onboarding documents"|"Onboarding sent"|"Onboarding docs completed"|"Onboarding docs approved"|"Complete onboarding" = placement?.onboarding ? placement.onboarding.completed?.submitted ? placement.onboarding.completed.accepted ? "Onboarding docs approved" : "Onboarding docs completed" : (user.userType === "Staff" ? "Onboarding sent" : "Complete onboarding") : "Add onboarding documents"; const signOffPlacements = getAccess(user, "signOffPlacements"); let canEdit = false; if ((wStage?.userType === "Staff" && user.userType === "Staff" && user.product === "institutes") || (user.product === "providers" && wStage?.userType === "Provider") || user.userType === "Students" && wStage?.userType === "Students") { console.log("ALMOST CAN EDIT"); if (user.userType === "Staff" && !signOffPlacements) { canEdit = false; } else { canEdit = true; } } const setFeedbackComplete = async (e: {instituteName: string}) => { if (!placement) throw new Error("No placement loaded."); await executeCallable("placement-submitExternalForm", {uid: placement.uid, pId: placementId, key: "students", formData: e, feedback: "students"}); }; const onFlagClick = async (e:FlagCodes, onClose?: boolean) => { if (!placement) return; if (e === "completeOnboarding" || e === "reviewOnboarding") { setOnboardingPopup(true); } if (e === "studentNotAccepted") { setShareStudentRequestPopup(true); } if (e === "providerEmailFailed") { firebaseQuery.update(["placements", placement.id], {flags: arrayRemove("providerEmailFailed")}) } if (e === "parentEmailFailed") { firebaseQuery.update(["placements", placement.id], {flags: arrayRemove("parentEmailFailed")}) } if (e === "noInsurance") { if (!eliData) { const storageRef = ref(storage, `insurance/${placement.providerContactId}.pdf`); const file = await getDownloadURL(storageRef).catch(() => placement.insuranceSkippedReason); setELIData(file); } setEliPopupOpen(true); } if (e === "noRiskAssessment") { if (!riskAssessmentURL && placement.riskAssessmentType === "file") { console.log("Risk assessment"); const storageRef = ref(storage, `riskAssessments/${placement.placementId || placement.id}.pdf`); const file = await getDownloadURL(storageRef).catch(async () => await getDownloadURL(ref(storage, `riskAssessments/${placement.id}.pdf`))); setRiskAssessmentURL(file); } setExternalDocPopupOpen("riskAssessment"); } if (e === "noDbsCheck") { if (!dbsCheckURL && placement.dbsCheckType === "file") { const storageRef = ref(storage, `dbsChecks/${placement.placementId || placement.id}.pdf`); const file = await getDownloadURL(storageRef); setDbsCheckURL(file); } setExternalDocPopupOpen("dbsCheck"); } if (e === "addOnboarding") { if (onClose) { setDismissOnboardingPopup(true); } else { setAddOnboardingDocsPopup(true); } } }; const approveELI = async () => { if (!placement) return; await executeCallable("insurance-approve", {oId: user.oId, providerContactId: placement.providerContactId}); setEliPopupOpen(false); }; const rejectELI = async ({reason}:{reason: string}) => { if (!placement || !institute) return; console.log("Reject", {reason: reason, placement: placement, instituteName: institute.name}); await executeCallable("insurance-reject", {reason: reason, placementId: placementId, instituteName: institute.name}); setRejectELIPopup(false); setEliPopupOpen(false); }; const approveProviderDoc = async (type: "riskAssessment"|"dbsCheck") => { await executeCallable(`${type}-approve`, {oId: user.oId, placementId: placementId}); setExternalDocPopupOpen(false); }; const rejectProviderDoc = async ({reason}:{reason: string}, type: "riskAssessment"|"dbsCheck") => { if (!placement || !institute) return; await executeCallable(`${type}-reject`, {reason: reason, placementId: placementId, instituteName: institute.name, staffEmail: user.email}); setRejectExternalDocPopup(false); setExternalDocPopupOpen(false); }; const manuallyConfigureProvider = async () => { if (!placement) return; if (!placement.providerId) { const res = await executeCallable("providerContacts-uploadProviderDetails", { placement: { id: placementId, data: Object.fromEntries(Object.entries(placement).filter(([k]) => k !== "contactId")), stage: wStage, }, skipSearch: true}).catch((e) => { throw e; }); console.log("RETURN", res.data); setPlacement((p) => ({...p, ...res.data as any})); if (uploadProviderDocPopup === "insurance") { setUploadProviderDocPopup(undefined); setUploadInsurance(true); } else if (uploadProviderDocPopup === "riskAssessment") { setUploadProviderDocPopup(undefined); setUploadRA(true); } else if (uploadProviderDocPopup === "DbsCheck") { setUploadProviderDocPopup(undefined); setUploadDBS(true); } } }; const withdrawFromPlacement = async () => { if (user.userType !== "Students") throw new Error("Must be a student to withdraw."); await executeCallable("placement-withdraw", {placementId: placementId}); } return {placement, wStage, student, workflow, editable, withdrawFromPlacementPopup, addOnboardingDocsPopup, setFeedbackComplete, setAddOnboardingDocsPopup, dismissOnboardingPopup, setDismissOnboardingPopup, setWithdrawFromPlacementPopup, withdrawFromPlacement, onFlagClick, setUploadInsurance, setUploadProviderDocPopup, setUploadRA, setUploadDBS, onboardingStatus, setSkipStagePopup, onboardingPopup, setViewExternalLinkPopup, setOnboardingPopup, setRejectELIPopup, eliData, riskAssessmentURL, dbsCheckURL, setExternalLinkCopied, skipStagePopup, snackbar, setSnackbar, cohort, disableEmail, rejectELIPopup, eliPopupOpen, rejectExternalDocPopup, externalDocPopupOpen, viewExternalLinkPopup, externalLinkCopied, uploadInsurance, uploadRA, uploadDBS, editStage, sendEmail, canEdit, approveELI, setEliPopupOpen, uploadProviderDocPopup, rejectELI, setRejectExternalDocPopup, setExternalDocPopupOpen, approveProviderDoc, rejectProviderDoc, manuallyConfigureProvider, institute, shareStudentRequestPopup, setShareStudentRequestPopup} } export function useOnboardingPopup({onboarding, providerId, placementId, user, onClose}:{onboarding: ( OnboardingDocs&{ completed: { submitted: boolean, submittedDate?: string, accepted?: boolean, filesViewed?: string[], formsCompleted?: {[key: string]: unknown}, filesUploaded?: {[key: number]: string[]}, }} ), placementId: string, user: UserData, providerId: string, onClose: () => void}) { const [fileUploadPopup, setFileUploadPopup] = useState(false); const [form, setForm] = useState<{id: string, name: string, [key:string]: unknown}>(); const [rejectPopup, setRejectPopup] = useState(false); const [mOnboarding, setMOnboarding] = useState(onboarding); const [completedSections, setCompletedSections] = useState<{ submitted: boolean, submittedDate?: string, filesViewed?: string[] | undefined; formsCompleted?: { [key: string]: unknown; } | undefined; filesUploaded?: { [key: number]: string[]; } | undefined; }>(); const [uploadedFiles, setUploadedFiles] = useState<{[key: string]: FileItem}>({}); const [viewableFiles, setViewableFiles] = useState<{[key: string]: FileItem}>(); const [formDetails, setFormDetails] = useState<{ [key: string]: { name: string; id: string; description?: string; product: Products; oId: string; form: CustomFormSchema; }; }>({}); const firebaseQuery = new FirebaseQuery(); const addFile = (files: string[]) => { if (!files.length || fileUploadPopup === false) return; setMOnboarding((a) => editNestedObject(["completed", "filesUploaded", fileUploadPopup], a, files) as any); setFileUploadPopup(false); }; const viewFile = (file: string, onOpen: (url: string) => void) => { setMOnboarding((a) => { const oldA = {...a}; const viewedFiles = a.completed ? a.completed?.filesViewed || [] : []; if (viewedFiles?.includes(file)) return a; viewableFiles?.[file].url && onOpen(viewableFiles?.[file].url); viewedFiles?.push(file); const newA = editNestedObject(["completed", "filesViewed"], oldA, viewedFiles) as any; return newA; }); }; const setFormComplete = (e: {[key: string]: unknown}, formId?: string) => { const mFormId = formId || form?.id; if (!mFormId) return; setMOnboarding((a) => editNestedObject(["completed", "formsCompleted", mFormId], a, e) as any); setFileUploadPopup(false); }; useEffect(() => { if (!onboarding) return; const getOnboardingData = async () => { const onboardingNew = {...onboarding}; const onboardingFiles = onboarding.files ? Object.fromEntries(await Promise.all(onboarding.files?.map(async (fileId) => { const file = await firebaseQuery.getDocData(["files", fileId]); file.url = await getDownloadURL(ref(storage, `providers/${providerId}/${file.fileName}`)); return [fileId, file]; }))) : []; const onboardingForms = onboarding.forms ? Object.fromEntries(await Promise.all(onboarding.forms?.map(async (formId) => { return [formId, await firebaseQuery.getDocData(["forms", formId])]; }))) : []; setViewableFiles(onboardingFiles); setFormDetails(onboardingForms); return onboardingNew; }; getOnboardingData().then(setMOnboarding); }, [onboarding]); useEffect(() => { console.log("onboarding change", mOnboarding, !objectsEqual(mOnboarding, onboarding)); if (!objectsEqual(mOnboarding, onboarding) && placementId) { firebaseQuery.update(["placements", placementId], {onboarding: mOnboarding} as Partial); } }, [mOnboarding]); const stagesCompleted = ():boolean => { console.log("CompletedSections", completedSections); for (const fileViewed of mOnboarding?.files || []) { if (!completedSections?.filesViewed?.includes(fileViewed)) { console.log("Checking file", fileViewed); console.log("File not completed"); return false; } } for (const formCompleted of mOnboarding?.forms || []) { if (!Object.keys(completedSections?.formsCompleted || {}).includes(formCompleted)) { console.log("Form not completed"); return false; } } for (var i; i++; i < (mOnboarding?.requiredFiles?.length || 1)) { if (!Object.keys(completedSections?.filesUploaded || {}).includes((i))) { return false; } } return true; }; const rejectOnboarding = async ({reason}:{reason: string}) => { if (user.product !== "providers") return; return await executeCallable("placement-rejectOnboarding", {reason: reason, placementId: placementId}).then(()=> { setRejectPopup(false); onClose(); }).catch(() => { throw new Error("Error"); }); }; const acceptOnboarding = async () => { if (user.product !== "providers" || !placementId) return; await firebaseQuery.update(["placements", placementId], {"onboarding.completed.accepted": true}); }; const submit = async () => { // Check all stages completed. if (!placementId) return; if (!stagesCompleted()) throw new Error("Complete all forms before submitting."); await firebaseQuery.update(["placements", placementId], {["onboarding.completed.accepted"]: false, ["onboarding.completed.submitted"]: true, ["onboarding.completed.submittedDate"]: convertDate(new Date(), "dbstring") as string}); //executeCallable("sendOnboardingSubmittedEmail", {}); }; useEffect(() => { const getUploadedFiles = async () => { if (!mOnboarding.completed) { setUploadedFiles({}); return }; const fileIds = Object.values(mOnboarding.completed.filesUploaded || {}) .flatMap(fileIds => fileIds); const fileDataPromises = fileIds.map(async (fileId) => { const fileData = await firebaseQuery.getDocData(["files", fileId]) as FileItem; fileData.url = await getDownloadURL(ref(storage, `userFiles/${fileData.fileName}`)); return [fileId, fileData] as [string, FileItem] }); const fileDataArray = await Promise.all(fileDataPromises); const fileDataObj = Object.fromEntries(fileDataArray) setUploadedFiles(fileDataObj) } const addCompletedSectionURLs = async () => { const completedSectionsWithURLs = {...mOnboarding?.completed}; completedSectionsWithURLs.filesUploaded = Object.fromEntries(await Promise.all(Object.entries(completedSectionsWithURLs.filesUploaded || {}).map(async ([fileId, items]) => { const filesWithObjects = await Promise.all(items.map(async (itemId) => { const file = await firebaseQuery.getDocData(["files", itemId]); const fileUrl = await getDownloadURL(ref(storage, `userFiles/${file.fileName}`)); console.log("|FILEURL", fileUrl); return fileUrl; })); return [fileId, filesWithObjects]; }))); setCompletedSections(completedSectionsWithURLs); }; getUploadedFiles(); addCompletedSectionURLs(); }, [mOnboarding]); return {addFile, viewFile, uploadedFiles, setFormComplete, setRejectPopup, stagesCompleted, setForm, mOnboarding, form, submit, acceptOnboarding, rejectOnboarding, rejectPopup, completedSections, fileUploadPopup, setFileUploadPopup, viewableFiles, formDetails}; } export function useLoadAddresses(user: UserData, limitItems?: number, queryConstraint?: QueryConstraint[], request?: boolean) { const [addresses, setAddresses] = useState<{[key: string]: OrganisationAddress&{listings: number}}>({}); const [lastDoc, setLastDoc] = useState(null); const [loading, setLoading] = useState(false); const firebaseQuery = new FirebaseQuery(); const [queryConstraints, setQueryConstraints] = useState(queryConstraint || []); const changeQueryConstraints = (e: QueryConstraint[]) => { setQueryConstraints([...(queryConstraint || []), ...e]); }; const loadAddresses = () => { const constraints:QueryConstraint[] = [where("oId", "==", user.oId), where("product", "==", user.product), orderBy(documentId())] if (limitItems) { constraints.push(limit(limitItems)); } if (user.viewAddresses === "all" || user.userGroup === "admin" || request) { if (lastDoc?.id) { constraints.push(startAfter(lastDoc?.id)); } } else if (user.viewAddresses === "request") { if (!user.visibleAddresses?.length) return; constraints.push(where(documentId(), "in", user.visibleAddresses)) if (lastDoc?.id) { constraints.push(startAfter(lastDoc?.id)); } } else { setLoading(false); return; // viewAddresses === "none", no need to load anything } queryConstraints && constraints.unshift(...queryConstraints); return firebaseQuery.collectionSnapshot((async (snapshot: QuerySnapshot) => { const deletedAddresses = snapshot.docChanges().map((change) => { if (change.type === "removed") { return change.doc.id; } return; }) setAddresses((prev) => Object.fromEntries(Object.entries(prev).filter(([k]) => !deletedAddresses.includes(k)))) if (!snapshot.empty) { const newAddresses:[string, OrganisationAddress][] = snapshot.docs.map(doc => ([doc.id, {id: doc.id, ...doc.data() as OrganisationAddress}])); const withListings:{[key: string]: OrganisationAddress&{listings: number}} = Object.fromEntries(await Promise.all(newAddresses.map(async ([k, address]) => { const listings = await firebaseQuery.getCount("placementListings", [where("providerId", "==", user.oId), where("addressId", "==", k)]); return [k, {...address, listings: listings}]; }))); setAddresses(prev => ({...prev, ...withListings})); setLastDoc(snapshot.docs[snapshot.docs.length - 1]); } setLoading(false); }), "addresses", constraints, undefined, true); }; useEffect(() => { const unsubscribe = loadAddresses(); return () => { if (unsubscribe) { unsubscribe(); // Unsubscribe from the snapshot listener when the component unmounts } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [queryConstraints]); const onScrollBottom = () => { if (!limitItems) return; if (!loading) { setLoading(true); loadAddresses(); } }; return { addresses, onScrollBottom, loading, changeQueryConstraints }; } export function useLoadListings(user: UserData, queryConstraint?: QueryConstraint[], request?: boolean) { const [listings, setListings] = useState<[string, PlacementListing&{applicants?: number, scheduled?: number, active?: number}][]>([]); const [lastDoc, setLastDoc] = useState(null); const [loading, setLoading] = useState(false); const firebaseQuery = new FirebaseQuery(); const [queryConstraints, setQueryConstraints] = useState(queryConstraint || []); const changeQueryConstraints = (e: QueryConstraint[]) => { setQueryConstraints([...(queryConstraint || []), ...e]); }; const loadListings = () => { const constraints:QueryConstraint[] = [where("providerId", "==", user.oId), limit(10), orderBy(documentId())] if (user.viewPlacementListings === "all" || user.userGroup === "admin" || request) { if (lastDoc?.id) { constraints.push(startAfter(lastDoc)); } } else { if (!user.visibleListings?.length) return; constraints.push(where(documentId(), 'in', user.visibleListings)); if (lastDoc?.id) { constraints.push(startAfter(lastDoc)); } } if (user.viewAddresses !== "all" && user.viewPlacementListings === "all" && user.userGroup !== "admin") { if (!user.visibleAddresses?.length) return; constraints.push(where('addressId', 'in', user.visibleAddresses)); } queryConstraints && constraints.unshift(...queryConstraints); return firebaseQuery.collectionSnapshot((async (snapshot: QuerySnapshot) => { const deletedListings = snapshot.docChanges().map((change) => { if (change.type === "removed") { return change.doc.id; } return; }) setListings((prev) => prev.filter(([k]) => !deletedListings.includes(k))) if (!snapshot.empty) { const newListings:[string, PlacementListing][] = snapshot.docs.map(doc => ([doc.id, {...doc.data() as PlacementListing, id: doc.id}])); const listingsWithAdditionalData:[string, PlacementListing&{applicants?: number, scheduled?: number, active?: number}][] = await Promise.all(newListings.map(async ([id, listing]) => { const listingWithAdditionalData = {...listing} as PlacementListing&{applicants?: number, scheduled?: number, active?: number}; if (listingWithAdditionalData.applicants !== undefined) return [id, listingWithAdditionalData]; listingWithAdditionalData.applicants = await firebaseQuery.getCount("applications", [where("providerId", "==", user.oId), where("placementId", "==", id), where("status", "==", "submitted")]); listingWithAdditionalData.scheduled = await firebaseQuery.getCount("placements", [where("providerId", "==", user.oId), where("placementId", "==", id), where("inProgress", "==", true), where("startDate", ">", convertDate(new Date(), "dbstring"))]); listingWithAdditionalData.active = await firebaseQuery.getCount("placements", [where("providerId", "==", user.oId), where("placementId", "==", id), where("active", "==", true)]); return [id, listingWithAdditionalData]; })); setListings(prev => (Object.entries({...Object.fromEntries(prev), ...Object.fromEntries(listingsWithAdditionalData)}))); setLastDoc(snapshot.docs[snapshot.docs.length - 1]); } setLoading(false); }), "placementListings", constraints, undefined, true); }; useEffect(() => { loadListings(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [queryConstraints]); const onScrollBottom = () => { if (!loading) { setLoading(true); loadListings(); } }; return { listings, onScrollBottom, loading, changeQueryConstraints }; } export function useLoadProviderPlacements(user: UserData, queryConstraint?: QueryConstraint[], placementId?: string) { const [placements, setPlacements] = useState<[string, StudentPlacementData&{applicants?: number, scheduled?: number, active?: number}][]>([]); const [lastDoc, setLastDoc] = useState(null); const [loading, setLoading] = useState(false); const firebaseQuery = new FirebaseQuery(); const [queryConstraints, setQueryConstraints] = useState(queryConstraint || []); if (user.product !== "providers") throw new Error("Only providers can use this hook."); const changeQueryConstraints = (e: QueryConstraint[]) => { setQueryConstraints([...(queryConstraint || []), ...e]); }; const loadListings = () => { const constraints:QueryConstraint[] = [where("providerId", "==", user.oId), limit(10), orderBy(documentId())] if (placementId) { constraints.push(where("placementId", "==", placementId)); } if (user.viewPlacementListings === "all" || user.userGroup === "admin") { if (lastDoc?.id) { constraints.push(startAfter(lastDoc)); } } else { if (!user.visibleListings?.length) return; constraints.push(where("placementId", 'in', user.visibleListings)); if (lastDoc?.id) { constraints.push(startAfter(lastDoc)); } } if (user.viewAddresses !== "all" && user.viewPlacementListings === "all" && user.userGroup !== "admin") { if (!user.visibleAddresses?.length) return; constraints.push(where('addressId', 'in', user.visibleAddresses)); } queryConstraints && constraints.unshift(...queryConstraints); return firebaseQuery.collectionSnapshot((async (snapshot: QuerySnapshot) => { const deletedListings = snapshot.docChanges().map((change) => { if (change.type === "removed") { return change.doc.id; } return; }) setPlacements((prev) => prev.filter(([k]) => !deletedListings.includes(k))) if (!snapshot.empty) { const newPlacements:[string, StudentPlacementData][] = snapshot.docs.map(doc => ([doc.id, {...doc.data() as StudentPlacementData, id: doc.id}])); const withAdditionalData = await Promise.all(newPlacements.map(async ([id, placement]) => { const student = placement.uid ? await firebaseQuery.getDocData(["users", placement.uid || ""]).catch(() => false) as UserData|false : { details: { forename: placement.studentForename, surname: placement.studentSurname, }, email: placement.studentEmail }; const listing = await firebaseQuery.getDocData(["placementListings", placement.placementId || ""]).catch(() => false) as PlacementListing; return [id, {...placement, student: student, listing: listing}]; })); setPlacements(prev => (Object.entries({...Object.fromEntries(prev), ...Object.fromEntries(withAdditionalData)}))); setLastDoc(snapshot.docs[snapshot.docs.length - 1]); } setLoading(false); }), "placements", constraints, undefined, true); }; useEffect(() => { loadListings(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [queryConstraints]); const onScrollBottom = () => { if (!loading) { setLoading(true); loadListings(); } }; return { placements: Object.fromEntries(placements), onScrollBottom, loading, changeQueryConstraints }; } export function useLoadApplications({user, applicationType, listingId, queryConstraint}:{user: UserData, applicationType?: "all"|"actionRequired"|"awaitingStudent"|"closed", listingId?: string, queryConstraint?: QueryConstraint[]}) { const [applications, setApplications] = useState<[string, Application][]>([]); const [lastDoc, setLastDoc] = useState(null); const [loading, setLoading] = useState(false); const [type, setType] = useState<"actionRequired"|"awaitingStudent"|"closed"|"all">(applicationType || "all"); const firebaseQuery = new FirebaseQuery(); const [queryConstraints, setQueryConstraints] = useState(queryConstraint || []); const changeQueryConstraints = (e: QueryConstraint[]) => { setQueryConstraints([...(queryConstraint || []), ...e]); }; const loadApplications = () => { const constraints:QueryConstraint[] = [where("providerId", "==", user.oId), limit(10), orderBy(documentId())] if (lastDoc?.id) { constraints.push(startAfter(lastDoc)); } switch (type) { case "actionRequired": constraints.push(where("status", "==", "submitted"), where("reqUserType", "==", "Staff")); break; case "awaitingStudent": constraints.push(where("status", "==", "submitted"), where("reqUserType", "==", "Students")); break; case "closed": constraints.push(where("status", "in", ["approved", "declined"])); break; default: constraints.push(where("status", "==", "submitted")); } if (listingId) { constraints.push(where("listingId", "==", listingId)); } console.log("Constraints before user group check", constraints); if (user.viewAddresses !== "all" && user.userGroup !== "admin") { if (user.viewPlacementListings === "all") { if (!user.visibleAddresses?.length) return; constraints.push(where('addressId', 'in', user.visibleAddresses)); } else { if (!user.visibleListings?.length) return; constraints.push(where('placementId', 'in', user.visibleListings)); } } queryConstraints && constraints.unshift(...queryConstraints); console.log("Constraints after user group check", constraints); return firebaseQuery.collectionSnapshot((async (snapshot: QuerySnapshot) => { const deletedApplications = snapshot.docChanges().map((change) => { if (change.type === "removed") { return change.doc.id; } return; }) console.log("applicantCount", snapshot.size); setApplications((prev) => prev.filter(([k]) => !deletedApplications.includes(k))) if (!snapshot.empty) { const newApplications:[string, Application][] = snapshot.docs.map(doc => ([doc.id, {id: doc.id, ...doc.data() as Application}])).filter(([, v]) => (v as Application).status !== "draft") as [string, Application][]; setApplications(prev => ([...prev, ...newApplications])); setLastDoc(snapshot.docs[snapshot.docs.length - 1]); } else { setApplications([]); setLastDoc(null); } setLoading(false); }), "applications", constraints, undefined, true); }; useEffect(() => { loadApplications(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [type, queryConstraints]); const onScrollBottom = () => { if (!loading) { setLoading(true); loadApplications(); } }; return { applications, type, setType, onScrollBottom, loading, changeQueryConstraints }; } export type FilterObject = { [key: string]: { label: string, value?: unknown, values?: {[key:string|number]: string|{label: string, test: QueryFieldFilterConstraint}}, type: "dropdown"|"string"|"number" } } export type DataViewerPaginater = { view: "list"|"table"; queryLimit?: number; formatItems?: (key: string, item: any) => Promise<{ key: string, item: any }> | { key: string, item: any }; snapshot?: boolean; filters?: FilterObject; onSearch?: boolean | ((search?: string, sort?: [string, {value: string, direction: "asc"|"desc"}], page?: number, filters?:FilterObject, limit?: number) => Promise<{ [key: string]: any }>); data?: {[key:string]:{[key:string]: unknown}}|QueryObject[]; additionalEntryProcessing?: (k: string, v: any) => Promise<[string, any]|undefined> | [string, any]|undefined, sorts?: Sorts, initialSort?: string, initialSearch?: string } export function useDataViewerPaginator({view: initialView, sorts, queryLimit=10, additionalEntryProcessing, formatItems, snapshot, filters: initialFilters, initialSort, initialSearch, onSearch, data}:DataViewerPaginater) { const [tableData, setTableData] = useState<{[key:string]:{[key:string]: unknown}}|undefined>(data ? Array.isArray(data) ? {} : Object.fromEntries(Object.entries(data).slice(0, queryLimit)) : undefined); const [page, setPage] = useState([1, 0]); const [view, setView] = useState(initialView); const [filters, setFilters] = useState(initialFilters); const [queryAnchor, setQueryAnchor] = useState<{startKey: string, endKey: string, startQueryPos: number, endQueryPos: number}>({startKey: "", endKey: "", startQueryPos: 0, endQueryPos: 0}); const [prevEntryIds, setPrevEntryIds] = useState<{[key:string]:number}>({}); const [dataListenerUnsubscribe, setDataListenerUnsubscribe] = useState<{[key: string] : Unsubscribe}>(); const [loading, setLoading] = useState(true); const [searchString, setSearchString] = useState(); const [sort, setSort] = useState<[string, {value: string, direction: "asc"|"desc"}]|undefined>((initialSort && sorts && sorts[initialSort]) ? [initialSort, sorts[initialSort]] : undefined); const [fData, setFData] = useState(data); const processedData = async (k: string, v: any):Promise<[string, any] | undefined> => additionalEntryProcessing ? await additionalEntryProcessing(k, v) : [k, v]; const setTableDataFromDefinedData = async () => { if (!fData || Array.isArray(fData)) return; const dataWithAdditionalProcessingPossibleNulls: [string, any][] = (await Promise.all(Object.entries(fData).map(async ([k, v]) => (await processedData(k, v)) as [string, any]))) const dataWithAdditionalProcessing = dataWithAdditionalProcessingPossibleNulls.filter(([k, v]) => v); const searchedData: [string, any][] = searchString ? dataWithAdditionalProcessing.filter(([, v]) => { const values = Object.values(v).join(", "); console.log("VALUESTRING", v); return values.includes(searchString); }) : dataWithAdditionalProcessing; const filteredData:[string, any][] = filters && Object.keys(filters).length > 0 ? searchedData.filter(([, dataValue]) => Object.entries(filters).every(([filterKey, filterValue]) => { const value = dataValue[filterKey]; if ((typeof value === "number") && value === parseInt(filterValue.value as string)) return true; if ((typeof value === "boolean") && value === (filterValue.value === "true")) return true; if ((typeof value === "boolean") && value === (filterValue.value === "false")) return true; if ((typeof value === "string" || Array.isArray(value)) && value.includes(filterValue.value as string)) return true; return false; })) : searchedData; if (view === "table") { if (!queryLimit) throw new Error("Tables must have a limit defined."); const newData:[string, any][] = filteredData.slice((page[0] - 1)*queryLimit, page[0]*queryLimit); setTableData(Object.fromEntries(newData)); if (Object.keys(Object.fromEntries(newData)).pop() === Object.keys(fData).pop()) { setLoading("loaded"); } else { setLoading(false); } return; } if (view === "list") { setTableData(Object.fromEntries(filteredData)); setLoading("loaded"); } }; const getDataFromQuery = async ( itemList: {[key: string]: any} = {}, currentQueryAnchor=queryAnchor, cursorDirection?:"increase"|"decrease"|undefined, prevEntries=prevEntryIds, loadMoreFromQuery=false):Promise => { // if (!filters) return; setLoading(true); if (!Array.isArray(fData)) { setTableDataFromDefinedData(); return; } if (!queryLimit) throw new Error("Firestore queries must have a limit defined."); if (onSearch && (searchString || sort)) { if (typeof onSearch === "boolean") throw new Error("When using Firestore queries, an onSearch function should be passed to retrieve data externally. Additional processing is however completed in this hook."); const searchData = await onSearch(searchString, sort, page[0], filters, queryLimit); console.log("Search data", searchData) const dataWithAdditionalProcessing = ((await Promise.all(Object.entries(searchData).map(async ([k, v]) => await processedData(k, v)))).filter((a) => a?.[1])) as [string, any][]; console.log("dataWithAdditionalProcessing", dataWithAdditionalProcessing) setTableData((old) => { console.log("Setting table data from search") if (view === "table") { console.log("Setting table data as table") return {...Object.fromEntries(dataWithAdditionalProcessing)}; } console.log("Setting table data as list") return {...old, ...Object.fromEntries(dataWithAdditionalProcessing)}; }); if (dataWithAdditionalProcessing.length < queryLimit) { setLoading("loaded"); } else { setLoading(false); } return; } let cursorPos:number; if (page[0] > page[1]) { cursorPos = currentQueryAnchor.endQueryPos; } else { cursorPos = currentQueryAnchor.startQueryPos; } const querySchema:QueryObject = fData[cursorPos]; const createQuery = (queryData:QueryObject) => { const constraints:any[] = []; queryData.where && queryData.where.forEach((w) => { constraints.push(where(...w)); }); console.log("FILTERS IN DATA PAGINATOR", filters); filters && Object.entries(filters).filter(([, value]) => value.value).forEach(([key, value]) => { if (value.type === "dropdown" && value.values && typeof value.values[value.value as string] !== "string") { const chosenValue = value.values[value.value as string] as { label: string; test: QueryFieldFilterConstraint|QueryObjectConstraint; }; constraints.push(Array.isArray(chosenValue.test) ? where(chosenValue.test[0], chosenValue.test[1], chosenValue.test[2]) : chosenValue.test); } else { const filterValue = (value.type === "number" || value.type === "dropdown") ? parseInt(value.value as string) || value.value : value.value; constraints.push(where(key, "==", filterValue)); } }); constraints.push(orderBy(queryData.orderBy ? queryData.orderBy === "documentId" ? documentId() : queryData.orderBy : documentId())); console.log("PAGE", page); console.log("QUERYANC", currentQueryAnchor); if (page[0] > page[1] && !cursorDirection) { // Going up currentQueryAnchor.endKey && constraints.push(startAfter(currentQueryAnchor.endKey)); constraints.push(limit(queryLimit)); if (!loadMoreFromQuery) { currentQueryAnchor = {...currentQueryAnchor, startQueryPos: currentQueryAnchor.endQueryPos}; } } else if (page[0] < page[1] && !cursorDirection) { // Going down if (!loadMoreFromQuery) { currentQueryAnchor = {...currentQueryAnchor, endQueryPos: currentQueryAnchor.startQueryPos}; } constraints.push(limitToLast(queryLimit)); if (currentQueryAnchor.startKey) { currentQueryAnchor.startKey && constraints.push(endBefore(currentQueryAnchor.startKey)); } else { currentQueryAnchor.startKey && constraints.push(endAt(currentQueryAnchor.startKey)); } } else { if (cursorDirection === "decrease") { constraints.push(limitToLast(queryLimit)); } else { constraints.push(limit(queryLimit)); } } return constraints; }; const constraints = createQuery(querySchema); const q = query(collection(db, ...(querySchema.path as [any])), ...(constraints)); console.log("Fetching docs", constraints); if (snapshot) { // Use onSnapshot to get real-time updates const unsubscribe = onSnapshot(q, (querySnapshot) => { handleQuerySnapshot(querySnapshot); }); // Save the unsubscribe function to state so we can dispose of it later setDataListenerUnsubscribe((d) => ({...(d || {}), [cursorPos]: unsubscribe})); return; } else { // Just get the docs without setting up a listener const queryData = await getDocs(q); handleQuerySnapshot(queryData); } // Function to handle query snapshot async function handleQuerySnapshot(querySnapshot: QuerySnapshot) { if (!Array.isArray(fData)) throw new Error("Called querySnapshot but data is defined."); if (!queryLimit) throw new Error("Firestore queries must have a limit defined."); const queryResults: { [key: string]: { [key: string]: unknown } } = {}; let index = 0; // Declare the index variable const reverseIfBack = (docs: QueryDocumentSnapshot[]) => page[0] < page[1] ? docs.reverse() : docs; // Process each document in the querySnapshot for (const doc of reverseIfBack(querySnapshot.docs)) { if ((Object.keys(queryResults).length + Object.keys(itemList).length) === queryLimit) { break; } let position = Object.keys(itemList).length+(page[0]-1)*queryLimit+index+1; if (page[0] < page[1]) { position = (page[0])*queryLimit-index-Object.keys(itemList).length; } if (itemList[doc.id] || (prevEntries[doc.id] && prevEntries[doc.id] !== position)) { console.log("Removing ", doc.id, ": E=", prevEntries[doc.id], ", G=", position); continue; } let item: {[key: string]: unknown}|false = doc.data(); item.id = doc.id; let key:string|undefined = doc.id; // Apply additionalEntryProcessing if provided if (additionalEntryProcessing) { const [processedKey, processedItem] = await additionalEntryProcessing(doc.id, item) || [undefined, undefined]; key = processedKey; item = processedItem; } if (!item || !key) continue; queryResults[key] = item; index += 1; if (prevEntries[key]) continue; prevEntries[key] = position; } if (cursorDirection === "decrease" || page[0] < page[1]) { itemList = {...Object.fromEntries(Object.entries(queryResults).reverse()), ...itemList}; } else { itemList = {...itemList, ...queryResults}; } console.log("ITEM LIST", itemList); // Updating state with the new data and query anchors setPrevEntryIds(prevEntries); if (querySnapshot.size < queryLimit && Object.keys(itemList).length < queryLimit) { // If we have ran out of entries, increase or decrease the index. if (page[0] > page[1] && cursorPos+1 < fData.length) { return getDataFromQuery(itemList, {...currentQueryAnchor, endQueryPos: currentQueryAnchor.endQueryPos+1}, "increase", prevEntries); } else if (page[0] < page[1] && cursorPos > 0) { return getDataFromQuery(itemList, {...currentQueryAnchor, startQueryPos: currentQueryAnchor.startQueryPos-1}, "decrease", prevEntries); } } if (Object.keys(itemList).length < queryLimit && querySnapshot.size === queryLimit) { console.log("Shorter than ten"); return getDataFromQuery(itemList, {...currentQueryAnchor, startKey: Object.keys(itemList)[0], endKey: Object.keys(itemList).slice(-1)[0]}, undefined, prevEntries, true); } if (querySnapshot.size === 0 && Object.keys(itemList).length === 0 && currentQueryAnchor.endQueryPos+1 === fData.length && page[0] > 1) { if (view === "table") { setTableData({}); } setQueryAnchor((a) => ({...a, startKey: ""})); setLoading("loaded"); return; } if (Object.keys(itemList).length < queryLimit) { setLoading("loaded"); } else { setLoading(false); } setQueryAnchor({...currentQueryAnchor, startKey: Object.keys(itemList)[0], endKey: Object.keys(itemList).slice(-1)[0]}); setTableData((old) => { if (view === "table") { return {...itemList}; } return {...old, ...itemList}; }); } }; const reset = () => { console.log("Resetting after filters?"); setPage([1, 0]); setTableData({}); setQueryAnchor({startKey: "", endKey: "", startQueryPos: 0, endQueryPos: 0}); setPrevEntryIds({}); dataListenerUnsubscribe && Object.values(dataListenerUnsubscribe).map((u) => u()); setDataListenerUnsubscribe(undefined); }; useEffect(() => { if (objectsEqualNew(fData, data)) return; console.log("data reset.", fData, data); setFData(data); reset(); }, [data]); const updateFilters = (fFilters?: FilterObject) => { if (!objectsEqualNew(fFilters, filters)) { console.log("FILTER RESET", fFilters, filters); setFilters(fFilters); reset(); } } const updateSearch = (search: string) => { if (searchString === search) return; console.log("SEARCH RESET", searchString, search); setSearchString(search); reset(); } const updateView = (v: "list" | "table") => { if (v === view) return; console.log("View reset", view, v); setView(v); reset(); } // Fetch new data when queries or page change useEffect(() => { getDataFromQuery(); dataListenerUnsubscribe && Object.values(dataListenerUnsubscribe).map((u) => u()); }, [page]); const pageUp = () => { setPage((p) => ([p[0]+1, p[0]])); }; const pageDown = () => { setPage((p) => ([p[0]-1, p[0]])); }; const updateSort = (sortLabel?: string) => { if (!sortLabel) { setSort(undefined); } else { if (!sorts || !sorts[sortLabel]) return; setSort([sortLabel, sorts[sortLabel]]); } reset(); } return ({...{tableData, pageUp, pageDown, search: searchString, setFilters: updateFilters, page: page[0], sorts, loading, sort, updateSort: updateSort, setView: updateView, updateSearch}}); };