diff --git a/src/pages/dash/a/[token]/index.js b/src/pages/dash/a/[token]/index.js
index e531f47..43297d4 100644
--- a/src/pages/dash/a/[token]/index.js
+++ b/src/pages/dash/a/[token]/index.js
@@ -12,6 +12,7 @@ import HeadGroup from '@codeday/topocons/Icon/HeadGroup';
import UiWarning from '@codeday/topocons/Icon/UiWarning';
import ShootingStar from '@codeday/topocons/Icon/ShootingStar';
import UiUpload from '@codeday/topocons/Icon/UiUpload';
+import Survey from '@codeday/topocons/Icon/Survey';
import Page from '../../../../components/Page';
import { Icon } from '@chakra-ui/react';
import { BulkImportStudents, ImportMatches } from './index.gql';
@@ -91,6 +92,7 @@ export default function AdminDashboard() {
Match Board
studentsUploadRef.current?.click()}>Bulk Import Students
matchesUploadRef.current?.click()}>Import Matches
+ Surveys
diff --git a/src/pages/dash/a/[token]/surveys.gql b/src/pages/dash/a/[token]/surveys.gql
new file mode 100644
index 0000000..a2c39dd
--- /dev/null
+++ b/src/pages/dash/a/[token]/surveys.gql
@@ -0,0 +1,24 @@
+query GetSurveysQuery {
+ labs {
+ surveys {
+ id
+ name
+ personType
+ selfSchema
+ selfUi
+ peerSchema
+ peerUi
+ menteeSchema
+ menteeUi
+ mentorSchema
+ mentorUi
+ projectSchema
+ projectUi
+ occurrences {
+ id
+ visibleAt
+ dueAt
+ }
+ }
+ }
+}
diff --git a/src/pages/dash/a/[token]/surveys.js b/src/pages/dash/a/[token]/surveys.js
new file mode 100644
index 0000000..a561fe4
--- /dev/null
+++ b/src/pages/dash/a/[token]/surveys.js
@@ -0,0 +1,146 @@
+import { useRouter } from 'next/router';
+import { apiFetch } from '@codeday/topo/utils';
+import { Content } from '@codeday/topo/Molecule';
+import { Box, Button, Heading, Text } from '@codeday/topo/Atom';
+import {
+ Accordion,
+ AccordionButton,
+ AccordionIcon,
+ AccordionItem,
+ AccordionPanel,
+} from '@chakra-ui/react';
+import { DateTime } from 'luxon';
+import Page from '../../../../components/Page';
+import RsjForm from '../../../../components/RsjForm';
+import { GetSurveysQuery } from './surveys.gql';
+
+const SCHEMA_SECTIONS = [
+ { key: 'self', title: 'Self-Reflection' },
+ { key: 'peer', title: 'Peer Reflection (about a teammate)' },
+ { key: 'mentee', title: 'Mentee Reflection (mentor about a student)' },
+ { key: 'mentor', title: 'Mentor Reflection (student about a mentor)' },
+ { key: 'project', title: 'Project Reflection' },
+];
+
+function fillNameTemplate(schema) {
+ return JSON.parse(JSON.stringify(schema || {}).replace(/{{name}}/g, 'this person'));
+}
+
+function hasSchema(schema) {
+ return schema && Object.keys(schema).length > 0;
+}
+
+function getRelevantOccurrence(occurrences) {
+ if (!occurrences || occurrences.length === 0) return null;
+ const now = DateTime.now();
+ const sorted = [...occurrences].sort((a, b) => DateTime.fromISO(a.dueAt) - DateTime.fromISO(b.dueAt));
+ const next = sorted.find((occ) => DateTime.fromISO(occ.dueAt) >= now);
+ if (next) return { occurrence: next, label: 'Next' };
+ return { occurrence: sorted[sorted.length - 1], label: 'Last' };
+}
+
+const PERSON_TYPE_ORDER = ['STUDENT', 'MENTOR'];
+
+function personTypeLabel(personType) {
+ return `${personType.charAt(0)}${personType.slice(1).toLowerCase()} Surveys`;
+}
+
+function groupByPersonType(surveys) {
+ const groups = new Map();
+ surveys.forEach((survey) => {
+ if (!groups.has(survey.personType)) groups.set(survey.personType, []);
+ groups.get(survey.personType).push(survey);
+ });
+ return [...groups.entries()].sort(([a], [b]) => {
+ const ai = PERSON_TYPE_ORDER.indexOf(a);
+ const bi = PERSON_TYPE_ORDER.indexOf(b);
+ if (ai === -1 && bi === -1) return a.localeCompare(b);
+ if (ai === -1) return 1;
+ if (bi === -1) return -1;
+ return ai - bi;
+ });
+}
+
+function SurveyCard({ survey, token }) {
+ const relevant = getRelevantOccurrence(survey.occurrences);
+ return (
+
+ {survey.name}
+
+
+
+
+ Preview Questions
+
+
+
+ {SCHEMA_SECTIONS.every(({ key }) => !hasSchema(survey[`${key}Schema`])) && (
+ No questions configured.
+ )}
+ {SCHEMA_SECTIONS.filter(({ key }) => hasSchema(survey[`${key}Schema`])).map(({ key, title }) => (
+
+ {title}
+ {}}
+ children={true}
+ />
+
+ ))}
+
+
+
+
+ {relevant ? (
+
+ {relevant.label} occurrence — Sent {DateTime.fromISO(relevant.occurrence.visibleAt).toLocaleString(DateTime.DATETIME_MED)}
+ {' — '}
+ Due {DateTime.fromISO(relevant.occurrence.dueAt).toLocaleString(DateTime.DATETIME_MED)}
+
+ ) : (
+ No occurrences yet.
+ )}
+
+
+
+ );
+}
+
+export default function AdminSurveys({ surveys }) {
+ const { query } = useRouter();
+
+ return (
+
+
+
+ Surveys
+
+ {surveys.length === 0 && No surveys are configured for this session.}
+
+ {groupByPersonType(surveys).map(([personType, group]) => (
+
+
+ {personTypeLabel(personType)}
+
+ {group.map((survey) => (
+
+ ))}
+
+ ))}
+
+
+ );
+}
+
+export async function getServerSideProps({ params: { token } }) {
+ const res = await apiFetch(GetSurveysQuery, {}, { 'X-Labs-Authorization': `Bearer ${token}` });
+ return {
+ props: {
+ surveys: res.labs.surveys,
+ },
+ };
+}
diff --git a/src/pages/dash/a/[token]/surveys/[id].gql b/src/pages/dash/a/[token]/surveys/[id].gql
new file mode 100644
index 0000000..fd00a40
--- /dev/null
+++ b/src/pages/dash/a/[token]/surveys/[id].gql
@@ -0,0 +1,22 @@
+query GetSurveyQuery($id: String!) {
+ labs {
+ survey(survey: $id) {
+ id
+ name
+ personType
+ occurrences {
+ id
+ visibleAt
+ dueAt
+ surveyResponses {
+ id
+ caution
+ authorMentor { id name }
+ authorStudent { id name }
+ mentor { id name }
+ student { id name }
+ }
+ }
+ }
+ }
+}
diff --git a/src/pages/dash/a/[token]/surveys/[id].js b/src/pages/dash/a/[token]/surveys/[id].js
new file mode 100644
index 0000000..65923b0
--- /dev/null
+++ b/src/pages/dash/a/[token]/surveys/[id].js
@@ -0,0 +1,107 @@
+import { useRouter } from 'next/router';
+import { apiFetch } from '@codeday/topo/utils';
+import { Content } from '@codeday/topo/Molecule';
+import { Box, Button, Heading, Text } from '@codeday/topo/Atom';
+import {
+ Accordion,
+ AccordionButton,
+ AccordionIcon,
+ AccordionItem,
+ AccordionPanel,
+ Badge,
+} from '@chakra-ui/react';
+import { DateTime } from 'luxon';
+import Page from '../../../../../components/Page';
+import SurveyDetails from '../../../../../components/Dashboard/SurveyDetails';
+import { GetSurveyQuery } from './[id].gql';
+
+function getCautionColors(caution) {
+ if (caution > 0.9) return { bg: 'red.500', color: 'red.50' };
+ if (caution > 0.1) return { bg: 'orange.500', color: 'orange.50' };
+ return {};
+}
+
+function responseLabel(sr) {
+ const author = sr.authorMentor || sr.authorStudent;
+ const target = sr.student || sr.mentor;
+ if (!author) return 'Unknown';
+ if (target && target.id === author.id) return `${author.name} (Self-Reflection)`;
+ if (target) return `${author.name} on ${target.name}`;
+ return author.name;
+}
+
+export default function AdminSurveyDetail({ survey }) {
+ const { query } = useRouter();
+
+ if (!survey) {
+ return (
+
+
+
+ Survey not found.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {survey.name}
+ {survey.personType}
+
+
+ {survey.occurrences.length === 0 && No occurrences yet.}
+
+
+ {[...survey.occurrences]
+ .sort((a, b) => DateTime.fromISO(b.dueAt) - DateTime.fromISO(a.dueAt))
+ .map((occ) => (
+
+
+
+ Sent {DateTime.fromISO(occ.visibleAt).toLocaleString(DateTime.DATETIME_MED)}
+ {' — '}
+ Due {DateTime.fromISO(occ.dueAt).toLocaleString(DateTime.DATETIME_MED)}
+ {' — '}
+ {occ.surveyResponses.length} response{occ.surveyResponses.length === 1 ? '' : 's'}
+
+
+
+
+ {occ.surveyResponses.length === 0 ? (
+ No responses yet.
+ ) : (
+
+ {occ.surveyResponses.map((sr) => (
+
+
+ {responseLabel(sr)}
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ ))}
+
+
+
+ );
+}
+
+export async function getServerSideProps({ params: { token, id } }) {
+ const res = await apiFetch(GetSurveyQuery, { id }, { 'X-Labs-Authorization': `Bearer ${token}` });
+ return {
+ props: {
+ survey: res.labs.survey || null,
+ },
+ };
+}