diff --git a/global-includes/users.ts b/global-includes/users.ts index b85e275..1e393db 100644 --- a/global-includes/users.ts +++ b/global-includes/users.ts @@ -268,6 +268,9 @@ export class User extends EntityBase { @VFields.boolean({ allowApiUpdate: [UserRole.Admin, UserRole.Staff] }) checkedIn = false; + @VFields.boolean({ allowApiUpdate: [UserRole.Admin, UserRole.Staff] }) + archived = false; + /** Called on backend when OAuth succeeds; finds or creates a User object in * the database and returns it. A session is then created using the returned * User object*/ @@ -391,6 +394,17 @@ export class User extends EntityBase { ); } + @BackendMethod({ allowed: true }) + static async withdrawRegistration() { + const user = remult.user as User; + if (!user) { + throw "Not logged in"; + } + user.submittedApplication = false; + user.applicationApproved = false; + await remult.repo(User).save(user); + } + @BackendMethod({allowed: true}) static async uploadResume(base64Resume: string, filename: string) { const user = remult.user as User; diff --git a/package.json b/package.json index 76e9418..6fe65ce 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "multer": "^1.4.5-lts.1", "nanoid": "^5.0.6", "next": "^13.2.4", - "next-usequerystate": "^1.7.2", + "nuqs": "^1.17.1", "octokit": "^2.0.14", "parse-headers": "^2.0.5", "passport": "^0.6.0", diff --git a/public-frontend/src/views/Contact.vue b/public-frontend/src/views/Contact.vue index 710b151..8f7bc1b 100644 --- a/public-frontend/src/views/Contact.vue +++ b/public-frontend/src/views/Contact.vue @@ -93,6 +93,10 @@ export default { @import "@/styles/global.scss"; @import '@/styles/space.scss'; +::placeholder { + color: black; +} + #contact { @include bg-primary; text-align: left; diff --git a/public-frontend/src/views/Profile.vue b/public-frontend/src/views/Profile.vue index 062fa83..d297383 100644 --- a/public-frontend/src/views/Profile.vue +++ b/public-frontend/src/views/Profile.vue @@ -218,25 +218,28 @@ @@ -276,7 +279,9 @@ const otherRestriction = ref(false); const alternateEmail = ref(false); const alternateEmailValue = ref(""); +// this is basically an enum: either "success", "failed", or "pending" const submissionStatus = ref('pending'); + const saveStatus = ref(""); const receivingEmails = ref(true); @@ -359,6 +364,11 @@ const submitForm = async () => { } } +const deregister = async () => { + await User.withdrawRegistration(); + submissionStatus.value = "pending"; +} + diff --git a/staff-frontend/pages/index.jsx b/staff-frontend/pages/index.jsx index 4650212..fe93e56 100644 --- a/staff-frontend/pages/index.jsx +++ b/staff-frontend/pages/index.jsx @@ -1,11 +1,11 @@ -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect } from "react"; import KHELayout from "../layouts/layout.jsx"; import { remult } from "remult"; -import { Email, EmailSource } from "../../global-includes/email-address.ts"; -import { Card, Col, Layout, Row, Skeleton, Statistic } from "antd"; +import { Email } from "../../global-includes/email-address.ts"; +import { Card, Layout, Row, Skeleton, Statistic } from "antd"; import { User } from "../../global-includes/users.ts"; import Link from "next/link.js"; -import { Bar, BarChart, CartesianGrid, Legend, Tooltip, XAxis, YAxis } from "recharts"; +import { Bar, BarChart, CartesianGrid, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts"; const { Sider, Content } = Layout; @@ -53,27 +53,75 @@ const UsersBarChart = () => { ) -} +}; -function HomePage() { +const UsersLineGraph = () => { const [loading, setLoading] = useState(false); + const [chart, setChart] = useState({}); + + useEffect(() => { + + const repo = remult.repo(User); + + const loadChart = async () => { + + const dataPoints = 20; + + const earliest = (await repo.findFirst()).createdAt; + const now = new Date(); + const range = now.getTime() - earliest.getTime(); + const rangeBetweenPoints = range / (dataPoints - 1); + + const points = [{name: earliest.toLocaleDateString(), amount: 0}]; + for (let i = 1; i < dataPoints; ++i) { + const before = new Date(earliest.getTime() + i * rangeBetweenPoints); + const count = await repo.count({ createdAt: { $lt: before } }); + points.push({ + name: before.toLocaleDateString(), + amount: count + }); + } + + setChart(points); + setLoading(false); + } + + setLoading(true); + loadChart(); + }, []); + + return ( + + + + + + + + + + ) +}; + +function HomePage() { + + const [loadingCounts, setLoadingCounts] = useState(false); const [counts, setCounts] = useState(null); - const [chart, setChart] = useState([]); useEffect(() => { const loadCounts = async () => { setCounts({ emails: await remult.repo(Email).count(), - early: await Email.getEmailList(EmailSource.Early2023).then(l => l.length), - users: await remult.repo(User).count() + users: await remult.repo(User).count(), + accepted: await remult.repo(User).count({applicationApproved: true}) }); - setLoading(false); + setLoadingCounts(false); } - setLoading(true); + setLoadingCounts(true); loadCounts(); }, []); @@ -85,22 +133,26 @@ function HomePage() { - - - - Total Email Addresses In DB} value={counts?.emails} /> + User Accounts} + value={counts?.users} + /> - User Accounts} value={counts?.users} /> + Accepted Applications} + value={counts?.accepted} + /> - - - - - + + + + diff --git a/staff-frontend/pages/tickets.jsx b/staff-frontend/pages/tickets.jsx index 90e20b7..f19cd16 100644 --- a/staff-frontend/pages/tickets.jsx +++ b/staff-frontend/pages/tickets.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo, useRef } from "react"; import { SupportTicket, SupportTicketController, TicketMessage, TicketStatus } from "../../global-includes/support-ticket"; import { remult } from "remult"; -import { useQueryState } from "next-usequerystate"; +import { useQueryState } from "nuqs"; import { Layout, Menu, Button, Popconfirm, Badge, Form, Switch, Input } from "antd"; const { Sider } = Layout; const { TextArea } = Input; diff --git a/staff-frontend/pages/users.jsx b/staff-frontend/pages/users.jsx index b3843bc..3a1546a 100644 --- a/staff-frontend/pages/users.jsx +++ b/staff-frontend/pages/users.jsx @@ -4,23 +4,147 @@ import KHELayout from "../layouts/layout"; import { User } from "../../global-includes/users"; import style from "./users.module.css"; -import { Button, Card, Layout, Modal, Row, Col, Divider, Tooltip, Grid } from "antd"; +import { Button, Card, Layout, Modal, Row, Col, Divider, Tooltip, Menu } from "antd"; import { Email, EmailTemplates } from "../../global-includes/email-address"; -const { Content } = Layout; +const { Content, Sider } = Layout; + +function UserModalContent({registration}) { + + const dietaryRestrictions = registration.dietaryRestrictions.map( + r => r == "Other" ? + `Other (${registration.optionalExtraRestriction || "not specified"})` : + r + ).join(", ") || "This user has no dietary restrictions."; + + return <> + Personal + + Age: {registration.age} + School: {registration.school} + Phone: {registration.phone} + Class Standing: {registration.schoolStatus} + Gender: {registration.gender} + Major: {registration.major} + + Website: + + {registration.link} + + + Attended KHE: {registration.attendedKhe ? "Yes" : "No"} + Pronouns: {registration.pronouns} + Ethnicity: {registration.ethnicity} + Sexuality: {registration.sexuality} + Shirt Size: {registration.shirtSize} + State: {registration.state} + Country: {registration.country} + + Dietary Restrictions + + {dietaryRestrictions} + + MLH + + {registration.name} has {registration.mlhConduct ? "accepted" : "not accepted"} the MLH Code of Conduct. + {registration.name} has {registration.mlhShare ? "accepted" : "not accepted"} the MLH Share. + + ; +} export default function UsersManager() { const [users, setUsers] = useState([]); const [viewing, setViewing] = useState(null); const [loading, setLoading] = useState(false); - const repo = remult.repo(User); + const userRepo = remult.repo(User); const showReview = (user) => setViewing(user); const closeReview = () => setViewing(null); + const cardStyle = { + width: 400, + margin: 6 + } + + const userStatuses = { + accountCreated: { + label: "Account Created", + filter: { + applicationApproved: false, + submittedApplication: false, + checkedIn: false, + archived: false + } + }, + applicationReceived: { + label: "Application Received", + filter: { + submittedApplication: true, + applicationApproved: false, + checkedIn: false, + archived: false + } + }, + approved: { + label: "Application Approved", + filter: { + submittedApplication: true, + applicationApproved: true, + checkedIn: false, + archived: false + } + }, + checkedIn: { + label: "Checked In", + filter: { + checkedIn: true, + archived: false + } + }, + archived: { + label: "Archived", + filter: { + archived: true + } + }, + }; + + // this object is populated in loadUsers based on the counts of users that + // meet the filters in userStatuses + const [userStatusCounts, setUserStatusCounts] = useState({}); + + const userStatusMenuItems = Object.keys(userStatuses).map( + key => { + const count = userStatusCounts[key] ? ` (${userStatusCounts[key]})` : ""; + const label = userStatuses[key].label + count; + return { label, key }; + } + ); + + const [viewingStatus, setViewingStatus] = useState( + "applicationReceived" + ); + + const navigateStatuses = (clickedItem) => { + setViewingStatus(clickedItem.key); + }; + + const loadUsers = () => { + userRepo + .find({where: userStatuses[viewingStatus].filter}) + .then(setUsers); + for (const status of Object.keys(userStatuses)){ + userRepo + .count(userStatuses[status].filter) + .then(count => setUserStatusCounts(c => ({...c, [status]: count}))); + } + } + + useEffect(loadUsers, [viewingStatus]); + const approveUser = async (user) => { setLoading(true); - await repo.save({ id: user.id, applicationApproved: true }); + await userRepo.save({ id: user.id, applicationApproved: true }); await Email.sendTemplateEmail(EmailTemplates.Approved, user.email || user.registration.email) closeReview(); setLoading(false); @@ -29,54 +153,76 @@ export default function UsersManager() { const checkInUser = async (user) => { setLoading(true); - await repo.save({ id: user.id, checkedIn: true }); + await userRepo.save({ id: user.id, checkedIn: true }); closeReview(); setLoading(false); loadUsers(); } - const loadUsers = () => { - repo.find().then(setUsers); + const setArchiveStatusForUser = async (user, value) => { + setLoading(true); + await userRepo.save({id: user.id, archived: value}); + closeReview(); + setLoading(false); + loadUsers(); } const getActions = (user) => { const checkedIn = user.checkedIn; - const actions = []; - if (user.submittedApplication) + const archived = user.archived; + const actions = [ + + ]; + if (user.submittedApplication) { actions.push(); - if (user.applicationApproved) - actions.push() - return actions; - } - - useEffect(loadUsers, []); - - const cardStyle = { - width: 350, - margin: 6 + } + if (user.applicationApproved && !archived) { + actions.push( + + ); + } + // the antd Card component expects an array of "action" components to + // display at the bottom of the card. however, the way that they space + // out an array of e.g. three buttons sucks, so i'm adding a custom + // container to wrap them + return [
+ {actions} +
]; } return - -
- {users.map((user, i) => - {user.roles.join(", ")}} - actions={getActions(user)} - style={cardStyle}> - {user.submittedApplication && !user.applicationApproved && This user is awaiting approval!} -

This account is registered with {user.method}. It was created on {user.createdAt.toLocaleDateString()}.

-
- )} -
-
+ + + + + +
+ {users.map((user, i) => + {user.roles.join(", ")}} + actions={getActions(user)} + style={cardStyle}> + {user.submittedApplication && !user.applicationApproved && This user is awaiting approval!} +

This account is registered with {user.method}. It was created on {user.createdAt.toLocaleDateString()}.

+
+ )} +
+
+
{/* TODO: really do not like this really long JSON access syntax (viewing.registration.someotherlongname), - this should become it's own component at some point + this should become its own component at some point */} - {viewing && - <> - Personal - - Age: {viewing.registration.age} - School: {viewing.registration.school} - Phone: {viewing.registration.phone} - Class Standing: {viewing.registration.schoolStatus} - Gender: {viewing.registration.gender} - Major: {viewing.registration.major} - - Website: - - {viewing.registration.link} - - - Attended KHE: {viewing.registration.attendedKhe ? "Yes" : "No"} - Pronouns: {viewing.registration.pronouns} - Ethnicity: {viewing.registration.ethnicity} - Sexuality: {viewing.registration.sexuality} - Shirt Size: {viewing.registration.shirtSize} - State: {viewing.registration.state} - Country: {viewing.registration.country} - - Dietary Restrictions - - {viewing.registration.dietaryRestrictions.join(", ") || "This user has no dietary restrictions."} - - MLH - - {viewing.registration.name} has {viewing.registration.mlhConduct ? "accepted" : "not accepted"} the MLH Code of Conduct. - {viewing.registration.name} has {viewing.registration.mlhShare ? "accepted" : "not accepted"} the MLH Share. - - - } + {viewing && } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index e2dd31f..52fac26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2812,6 +2812,11 @@ minimist@^1.2.5, minimist@^1.2.6: resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== +mitt@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" + integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== + mkdirp@^0.5.4: version "0.5.6" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz" @@ -2881,11 +2886,6 @@ negotiator@0.6.3: resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== -next-usequerystate@^1.7.2: - version "1.7.2" - resolved "https://registry.npmjs.org/next-usequerystate/-/next-usequerystate-1.7.2.tgz" - integrity sha512-5ChbGoG/TGYRuKtpI4fKf1wsePYrO75srXwLcuPR0Nnbm7Tspfbx0L6215PRmUb9ZJCRLoXy6J3CqJRZYCot3w== - next@^13.2.4: version "13.2.4" resolved "https://registry.npmjs.org/next/-/next-13.2.4.tgz" @@ -2960,6 +2960,13 @@ nth-check@^2.0.1: dependencies: boolbase "^1.0.0" +nuqs@^1.17.1: + version "1.17.1" + resolved "https://registry.yarnpkg.com/nuqs/-/nuqs-1.17.1.tgz#26873cf91add0dc376e6a6c916ea89b1d70f5b4a" + integrity sha512-zJPisj8L+SzKzt57c5s3fJW0ikksPr+PyeLyNwvx0i5ggTr5XR5uFhCoX/T+Mkmb6X5UxHB/0nnuZyFiQsh0cA== + dependencies: + mitt "^3.0.1" + nwsapi@^2.2.2: version "2.2.2" resolved "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.2.tgz"