diff --git a/app/applications/page.tsx b/app/applications/page.tsx index 7a671e8..17e1c5f 100644 --- a/app/applications/page.tsx +++ b/app/applications/page.tsx @@ -12,13 +12,20 @@ import { updateUserStatus, updateApplicationScore, getQuestionText, + getPortalConfig, } from "@/lib/firebaseUtils"; -import { CombinedApplicationData, APPLICATION_STATUS } from "@/lib/types"; +import { CombinedApplicationData, APPLICATION_STATUS, PortalConfig } from "@/lib/types"; +import ApplicationAcceptModal from "@/components/ApplicationAcceptModal"; +import { calculateAge } from "@/lib/evaluator"; export default function Applications() { + const [config, setConfig] = useState(null); const [applications, setApplications] = useState( [] ); + const [applicationsOriginal, setApplicationsOriginal] = useState( + [] + ); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedApplication, setSelectedApplication] = @@ -37,12 +44,122 @@ export default function Applications() { bigProblem: "Problem to Solve", interestingProject: "Interesting Project", }); + const [searchName, setSearchName] = useState(""); + const [searchSort, setSearchSort] = useState(""); + const [isSortDescending, setIsSortDescending] = useState(false); + + const onChangeSearchQuery = (e: React.ChangeEvent) => { + setSearchName(e.target.value); + + // possible for gender + const genderFiltered = applicationsOriginal.filter(app => app.gender_identity?.toLowerCase().includes(e.target.value.toLowerCase())) + + // for university + const uniFiltered = applicationsOriginal.filter(app => app.school?.toLowerCase().includes(e.target.value.toLowerCase())) + + // for status + const statusFiltered = applicationsOriginal.filter(app => app.status?.toLowerCase().includes(e.target.value.toLowerCase())) + + // for name + const nameFiltered = applicationsOriginal.filter(app => app.firstName?.toLowerCase().includes(e.target.value.toLowerCase())) + + // for last name + const lastNameFiltered = applicationsOriginal.filter(app => app.lastName?.toLowerCase().includes(e.target.value.toLowerCase())) + + // for email + const emailFiltered = applicationsOriginal.filter(app => app.email?.toLowerCase().includes(e.target.value.toLowerCase())) + + // for desired role + const roleFiltered = applicationsOriginal.filter(app => app.desiredRoles?.toLowerCase().includes(e.target.value.toLowerCase())) + + // for age + const ageFiltered = applicationsOriginal.filter(app => { + const age = calculateAge(app.date_of_birth); + return age.toString().includes(e.target.value); + }) + + // for year + const schoolYearFiltered = applicationsOriginal.filter(app => app.year?.toString().includes(e.target.value)) + + const allResults = genderFiltered.concat(uniFiltered, statusFiltered, nameFiltered, lastNameFiltered, emailFiltered, roleFiltered, ageFiltered, schoolYearFiltered, schoolYearFiltered); + const uniqueResults = allResults.filter((app, index, self) => + index === self.findIndex(a => a.id === app.id) + ); + setApplications(uniqueResults); + } + + const getSortValue = (app: CombinedApplicationData, sortField: string) => { + switch (sortField) { + case "score": + return app.score || 0; + case "applicationCreatedAt": + return new Date(app.applicationCreatedAt).getTime(); + case "applicationUpdatedAt": + return new Date(app.applicationUpdatedAt).getTime(); + case "email": + return app.email; + case "firstName": + return app.firstName || ""; + case "lastName": + return app.lastName || ""; + default: + return ""; + } + }; + + const applySorting = (sortField: string, descending: boolean = false) => { + if (sortField === "none") { + setApplications([...applicationsOriginal]); + return; + } + + const sorted = [...applications].sort((a, b) => { + const aValue = getSortValue(a, sortField); + const bValue = getSortValue(b, sortField); + + if (typeof aValue === "string" && typeof bValue === "string") { + return descending ? bValue.localeCompare(aValue) : aValue.localeCompare(bValue); + } + + if (typeof aValue === "number" && typeof bValue === "number") { + return descending ? bValue - aValue : aValue - bValue; + } + + return 0; + }); + + setApplications(sorted); + }; + + const onChangeSearchSort = (e: React.ChangeEvent) => { + setSearchSort(e.target.value); + applySorting(e.target.value, isSortDescending); + }; + + const onChangeIsSortDescending = () => { + const newIsSortDescending = !isSortDescending; + setIsSortDescending(newIsSortDescending); + applySorting(searchSort, newIsSortDescending); + }; useEffect(() => { + loadConfig(); loadApplications(); loadQuestionTexts(); }, []); + const loadConfig = async () => { + try { + setLoading(true); + const portalConfig = await getPortalConfig(); + setConfig(portalConfig); + } catch { + setError("Failed to load portal configuration"); + } finally { + setLoading(false); + } + }; + const loadApplications = async () => { try { setLoading(true); @@ -52,6 +169,7 @@ export default function Applications() { const data = await fetchApplicationsWithUsers(); setApplications(data); + setApplicationsOriginal(data); if (data.length > 0) { setSelectedApplication(data[0]); setEvaluationScore(data[0].score?.toString() || ""); @@ -94,7 +212,7 @@ export default function Applications() { if (!selectedApplication) return; const score = parseFloat(evaluationScore); - if (score >= 0 && score <= 10) { + if (score >= 0 && score <= (config?.maxApplicationEvaluationScore || 20)) { try { const success = await updateApplicationScore( selectedApplication.id, @@ -340,24 +458,6 @@ export default function Applications() { } }; - const calculateAge = (dateOfBirth: string): number => { - try { - const birth = new Date(dateOfBirth); - const today = new Date(); - let age = today.getFullYear() - birth.getFullYear(); - const monthDiff = today.getMonth() - birth.getMonth(); - if ( - monthDiff < 0 || - (monthDiff === 0 && today.getDate() < birth.getDate()) - ) { - age--; - } - return age; - } catch { - return 0; - } - }; - if (loading) { return (
@@ -460,6 +560,39 @@ export default function Applications() { className="card flex flex-col" style={{ height: "calc(100vh - 400px)" }} > +
+ +

Support name, email, status, university, gender, role, age, school year.

+ +
+
+

Sort by

+ +
+
+

Desc

+ +
+
+

Applications List ({displayableApplications.length}) @@ -467,13 +600,13 @@ export default function Applications() {

- + `} */} {displayableApplications.length === 0 ? (
No applications found @@ -483,11 +616,10 @@ export default function Applications() {
handleApplicationSelect(application)} - className={`w-full max-w-full p-4 border-b border-white/10 cursor-pointer transition-colors hover:bg-white/5 ${ - selectedApplication?.id === application.id - ? "bg-primary/10 border-primary/30" - : "" - }`} + className={`w-full max-w-full p-4 border-b border-white/10 cursor-pointer transition-colors hover:bg-white/5 ${selectedApplication?.id === application.id + ? "bg-primary/10 border-primary/30" + : "" + }`} >

@@ -496,7 +628,7 @@ export default function Applications() {
{application.score ? (
- {application.score}/10 + {application.score}/{config?.maxApplicationEvaluationScore || 20}
) : (
@@ -681,7 +813,7 @@ export default function Applications() { value={selectedApplication.motivation || "No response"} readOnly className="input w-full resize-none bg-white/5 border-white/20 text-white/80 text-sm leading-relaxed overflow-y-auto" - style={{ maxHeight: "120px", minHeight: "80px" }} + style={{ maxHeight: "500px", minHeight: "250px" }} />
@@ -693,7 +825,7 @@ export default function Applications() { value={selectedApplication.bigProblem || "No response"} readOnly className="input w-full resize-none bg-white/5 border-white/20 text-white/80 text-sm leading-relaxed overflow-y-auto" - style={{ maxHeight: "120px", minHeight: "80px" }} + style={{ maxHeight: "500px", minHeight: "250px" }} />
@@ -707,7 +839,7 @@ export default function Applications() { } readOnly className="input w-full resize-none bg-white/5 border-white/20 text-white/80 text-sm leading-relaxed overflow-y-auto" - style={{ maxHeight: "120px", minHeight: "80px" }} + style={{ maxHeight: "500px", minHeight: "250px" }} />

@@ -795,7 +927,7 @@ export default function Applications() { disabled={ !evaluationScore || parseFloat(evaluationScore) < 0 || - parseFloat(evaluationScore) > 10 + parseFloat(evaluationScore) > (config?.maxApplicationEvaluationScore || 20) } className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed" > @@ -817,40 +949,40 @@ export default function Applications() {
{selectedApplication.status !== APPLICATION_STATUS.ACCEPTED && ( - - )} + + )} {selectedApplication.status !== APPLICATION_STATUS.REJECTED && ( - - )} + + )}
{(selectedApplication.status === APPLICATION_STATUS.ACCEPTED || selectedApplication.status === - APPLICATION_STATUS.REJECTED) && ( -
-

- Current Status:{" "} - - {selectedApplication.status} - -

-
- )} + APPLICATION_STATUS.REJECTED) && ( +
+

+ Current Status:{" "} + + {selectedApplication.status} + +

+
+ )}
@@ -865,63 +997,7 @@ export default function Applications() {
{showAcceptModal && ( -
-
-
-

- Accept Participants -

- -
- -
-

- Select criteria for automatically accepting participants: -

- -
-

Coming Soon

-

- This feature will allow you to bulk accept participants based - on scores, status, and other criteria. The implementation is - in progress. -

-
-
- -
- - -
-
-
+ )} ); diff --git a/app/layout.tsx b/app/layout.tsx index dbf9f1e..4b9688c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import { AuthProvider } from "@/contexts/AuthContext"; import ProtectedRoute from "../components/ProtectedRoute"; import Sidebar from "@/components/Sidebar"; import { inject } from "@vercel/analytics"; +import { Toaster } from "react-hot-toast"; // Initialize Vercel Analytics inject(); @@ -47,6 +48,7 @@ export default function RootLayout({ +
diff --git a/components/ApplicationAcceptModal.tsx b/components/ApplicationAcceptModal.tsx new file mode 100644 index 0000000..0891008 --- /dev/null +++ b/components/ApplicationAcceptModal.tsx @@ -0,0 +1,591 @@ +import { useEffect, useState } from "react" +import { APPLICATION_STATUS, CombinedApplicationData, fetchApplicationsWithUsers, formatApplicationDate, getEducationLevel, getPortalConfig, getQuestionText, getYearSuffix, updateApplicationAcceptanceEmail, updateApplicationStatus } from "@/lib/firebaseUtils" +import AcceptingApplicationRowComponent from "./lists/AcceptingApplicationRow" +import LoadingSpinner from "./LoadingSpinner" +import { Loader2, Podcast, SeparatorHorizontal, X } from "lucide-react" +import { calculateAge } from "@/lib/evaluator" +import toast from "react-hot-toast" +import { PortalConfig } from "@/lib/types" + +interface ApplicationAcceptModalProps { + setShowAcceptModal: (value: boolean) => void +} + +export default function ApplicationAcceptModal({ setShowAcceptModal }: ApplicationAcceptModalProps) { + const [config, setConfig] = useState(null); + const [configError, setConfigError] = useState("") + const [isLoading, setIsLoading] = useState(true) + const [minScore, setMinScore] = useState(undefined) + const [minScoreError, setMinScoreError] = useState("") + const [combinedApplications, setCombinedApplications] = useState([]) + const [previewModalActive, setPreviewModalActive] = useState(false) + const [currentApplicationPreview, setCurrentApplicationPreview] = useState(undefined) + const [toAcceptApplications, setToAcceptApplications] = useState([]) + const [confirmationModalActive, setConfirmationModalActive] = useState(false) + const [confirmationError, setConfirmationError] = useState("") + const [isAcceptingLoading, setIsAcceptingLoading] = useState(false) + + const [questionTexts, setQuestionTexts] = useState<{ + motivation: string; + bigProblem: string; + interestingProject: string; + }>({ + motivation: "Motivation", + bigProblem: "Problem to Solve", + interestingProject: "Interesting Project", + }); + + const loadConfig = async () => { + try { + setIsLoading(true); + const portalConfig = await getPortalConfig(); + setConfig(portalConfig); + } catch { + setConfigError("Failed to load portal configuration"); + console.log("Failed to load portal config.") + } finally { + setIsLoading(false); + } + }; + + const loadQuestionTexts = async () => { + try { + const [motivationText, bigProblemText, interestingProjectText] = + await Promise.all([ + getQuestionText("motivation"), + getQuestionText("bigProblem"), + getQuestionText("interestingProject"), + ]); + + setQuestionTexts({ + motivation: motivationText, + bigProblem: bigProblemText, + interestingProject: interestingProjectText, + }); + } catch (error) { + console.error("Error loading question texts:", error); + } + }; + + const onChangeMinScore = (e: React.ChangeEvent) => { + const value = Number(e.target.value) + if (value < 0 || value > 10) { + setMinScoreError("Score must be between 0 and 10") + } else { + setMinScore(value) + setMinScoreError("") + setToAcceptApplications([]) + } + } + + const onPreviewApplication = (application: CombinedApplicationData) => { + setCurrentApplicationPreview(application) + setPreviewModalActive(true) + } + + const handleIsToAcceptChange = (application: CombinedApplicationData) => { + if (toAcceptApplications.includes(application)) { + setToAcceptApplications(toAcceptApplications.filter(app => app.id !== application.id)) + } else { + setToAcceptApplications([...toAcceptApplications, application]) + } + } + + const handleSelectAll = () => { + setToAcceptApplications(combinedApplications) + } + + const handleUnselectAll = () => { + setToAcceptApplications([]) + } + + const handleAcceptSelected = () => { + if (minScore === undefined || minScore === 0) { + setConfirmationError("Score threshold cannot be empty") + return + } + else if (toAcceptApplications.length === 0) { + setConfirmationError("Select minimum 1 application") + return + } + setConfirmationError("") + setConfirmationModalActive(true) + } + + const handleAcceptSubmit = async () => { + let successCount = 0; + let failCount = 0; + setIsAcceptingLoading(true) + try { + const applications = toAcceptApplications.filter(app => app.score !== undefined && app.score >= minScore!) + + const results = await Promise.allSettled(applications.map(async (application) => { + try { + const result = await updateApplicationStatus(application.id, APPLICATION_STATUS.ACCEPTED) + return { success: result, application }; + } catch (error) { + return { success: false, application }; + } + })); + + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + try { + const response = await fetch("/api/send-email", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: result.value.application.email, + rsvpDeadline: "2025-07-01", + teamDeadline: "2025-07-01", + eventStartDate: "2025-07-24", + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + console.error("Failed to send acceptance email:", errorData); + failCount++ + continue; + } + + // add to db acceptance email sent + try { + await updateApplicationAcceptanceEmail(result.value.application.id) + } catch (error) { + console.error(`Error updating application acceptance email for ${result.value.application.id}:`, error); + failCount++ + continue; + } + } catch (emailError) { + console.error("Error sending acceptance email:", emailError); + } + successCount++; + } else { + failCount++; + } + } + + toast((t) => ( +
+

Successfully accepted {successCount} applications.

+

{failCount} applications failed to process.

+

This window will refresh automatically in 7 seconds.

+
+ ), { + duration: 7000 + }); + + setTimeout(() => { + window.location.reload() + }, 7000) + } catch (error) { + console.log(`Error when bulk accept: ${error}`) + toast.error("Something went wrong. Please check log.") + } finally { + console.log('Finally block executing'); + setShowAcceptModal(false) + setPreviewModalActive(false) + setConfirmationModalActive(false) + setIsAcceptingLoading(false) + } + } + + useEffect(() => { + loadConfig() + loadQuestionTexts() + }, []) + + useEffect(() => { + setIsLoading(true) + const scoreFilter = minScore === 0 ? undefined : minScore; + fetchApplicationsWithUsers("submitted", scoreFilter).then((applications) => { + setCombinedApplications(applications.filter(app => app.score !== undefined)) + }) + setIsLoading(false) + }, [minScore]) + + if (configError) { +
+

{configError}

+
+ } + + return ( +
+
+
+
+

+ Accept Participants +

+

Bulk accept participants based on scores, status, and other criteria.

+
+ +
+ +
+
+

+ Score threshold to accept applicants: +

+
+ + {minScoreError} +
+
+ +
+ + {isLoading ? ( +
+ +
+ ) : ( + <> +
+

Showing {combinedApplications.length} applications passing the score threshold

+
+
+ + +
+ Selected {toAcceptApplications.length} +
+
+ +
+ {combinedApplications.map((application) => ( +
handleIsToAcceptChange(application)}> + +
+ ))} +
+ + )} + +
+ +
+ + +
+ +

{confirmationError}

+
+ + {previewModalActive && ( +
+
+
setPreviewModalActive(false)}> + +
+ +
+

+ Application Preview +

+ + {currentApplicationPreview && ( + +
+
+
+
+

+ {currentApplicationPreview.firstName} +

+
+

+ Email:{" "} + {currentApplicationPreview.email} +

+

+ School:{" "} + {currentApplicationPreview.school} +

+

+ Education:{" "} + {getEducationLevel(currentApplicationPreview.education)} +

+

+ Year:{" "} + {getYearSuffix(currentApplicationPreview.year)} +

+

+ Age:{" "} + {calculateAge(currentApplicationPreview.date_of_birth)} +

+

+ Hackathons:{" "} + {currentApplicationPreview.hackathonCount} previous +

+

+ Desired Roles:{" "} + {currentApplicationPreview.desiredRoles} +

+
+
+
+
+ Links & Documents +
+
+ {currentApplicationPreview.resume && ( + + + + + Resume (PDF) + + )} + {currentApplicationPreview.portfolio && + currentApplicationPreview.portfolio !== "X" && ( + + + + + Portfolio + + )} + {currentApplicationPreview.github && + currentApplicationPreview.github !== "X" && ( + + + + + GitHub + + )} + {currentApplicationPreview.linkedin && + currentApplicationPreview.linkedin !== "X" && ( + + + + + LinkedIn + + )} +
+
+
+ +
+
+ {questionTexts.motivation} +
+