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 @@
-
-
-
- Could not update application! Make sure all fields are filled out.
-
-
- Saved application!
-
-
- Thanks for submitting your application to Kent Hack Enough! You
- will receive an email when your application is accepted or
- rejected.
+
+
+
+
+
+
+ Could not update application! Make sure all fields are filled out.
+
+
+ Saved application!
+
+
+ Thanks for submitting your application to Kent Hack Enough! You
+ will receive an email when your application is accepted or
+ rejected.
- You are free to make changes to your application and save them,
- but please note that this will put your application back at the
- end of the line.
-
+ You are free to make changes to your application and save them,
+ but please note that this will put your application back at the
+ end of the line.
+
@@ -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 = [
+ setArchiveStatusForUser(user, !archived)}>
+ {archived ? "Unarchive" : "Archive"}
+
+ ];
+ if (user.submittedApplication) {
actions.push( showReview(user)}>View Application );
- if (user.applicationApproved)
- actions.push( checkInUser(user)}>{checkedIn ? "Checked in" : "Check in"} )
- return actions;
- }
-
- useEffect(loadUsers, []);
-
- const cardStyle = {
- width: 350,
- margin: 6
+ }
+ if (user.applicationApproved && !archived) {
+ actions.push(
+ checkInUser(user)}>
+ {checkedIn ? "Checked in" : "Check in"}
+
+ );
+ }
+ // 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"