Upload 23 files
Browse files- Dockerfile +17 -0
- README.md +28 -0
- next-env.d.ts +2 -0
- next.config.js +3 -0
- package.json +31 -0
- postcss.config.js +3 -0
- src/app/api/submit/route.ts +14 -13
- src/app/globals.css +15 -2
- src/components/assessment/AssessmentApp.tsx +388 -495
- src/components/assessment/CandidateForm.tsx +75 -0
- src/lib/questions.ts +263 -277
- tailwind.config.js +6 -0
- tsconfig.json +18 -0
Dockerfile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-alpine
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
|
| 4 |
+
COPY package.json package-lock.json* ./
|
| 5 |
+
RUN npm install
|
| 6 |
+
|
| 7 |
+
COPY . .
|
| 8 |
+
|
| 9 |
+
ENV NODE_ENV=production
|
| 10 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 11 |
+
ENV PORT=7860
|
| 12 |
+
ENV HOSTNAME=0.0.0.0
|
| 13 |
+
|
| 14 |
+
RUN npm run build
|
| 15 |
+
|
| 16 |
+
EXPOSE 7860
|
| 17 |
+
CMD ["npm","run","start"]
|
README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: SGS Psychometrics Assessment (A–F)
|
| 3 |
+
emoji: 🧠
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# SGS Psychometrics Assessment (A–F)
|
| 11 |
+
|
| 12 |
+
- Next.js 14 + React 18
|
| 13 |
+
- TailwindCSS 3
|
| 14 |
+
- Radix UI (radio/progress)
|
| 15 |
+
- Framer Motion animations
|
| 16 |
+
- Free recruiter delivery: **Google Sheets webhook** (Apps Script)
|
| 17 |
+
|
| 18 |
+
## Required Hugging Face Secrets
|
| 19 |
+
- `RESULTS_WEBHOOK_URL`
|
| 20 |
+
- `RESULTS_WEBHOOK_TOKEN`
|
| 21 |
+
|
| 22 |
+
## Optional Secrets
|
| 23 |
+
- `HF_TOKEN` (only if you want AI narrative analysis)
|
| 24 |
+
- `HF_MODEL` (default: `HuggingFaceH4/zephyr-7b-beta`)
|
| 25 |
+
|
| 26 |
+
## Notes
|
| 27 |
+
- Candidate never sees scores or results.
|
| 28 |
+
- Recruiters receive results through the Google Sheet via webhook.
|
next-env.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="next" />
|
| 2 |
+
/// <reference types="next/image-types/global" />
|
next.config.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = { reactStrictMode: true };
|
| 3 |
+
module.exports = nextConfig;
|
package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "sgs-psychometrics-af",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "2.0.0",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev -p 7860 -H 0.0.0.0",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start -p 7860 -H 0.0.0.0"
|
| 9 |
+
},
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"next": "14.2.14",
|
| 12 |
+
"react": "18.2.0",
|
| 13 |
+
"react-dom": "18.2.0",
|
| 14 |
+
"framer-motion": "11.0.0",
|
| 15 |
+
"lucide-react": "0.454.0",
|
| 16 |
+
"@radix-ui/react-progress": "1.1.0",
|
| 17 |
+
"@radix-ui/react-radio-group": "1.2.0",
|
| 18 |
+
"@radix-ui/react-slot": "1.1.0",
|
| 19 |
+
"clsx": "2.1.1",
|
| 20 |
+
"tailwind-merge": "2.5.4"
|
| 21 |
+
},
|
| 22 |
+
"devDependencies": {
|
| 23 |
+
"typescript": "5.6.3",
|
| 24 |
+
"@types/node": "20.17.5",
|
| 25 |
+
"@types/react": "18.2.79",
|
| 26 |
+
"@types/react-dom": "18.2.25",
|
| 27 |
+
"tailwindcss": "3.4.17",
|
| 28 |
+
"postcss": "8.4.38",
|
| 29 |
+
"autoprefixer": "10.4.20"
|
| 30 |
+
}
|
| 31 |
+
}
|
postcss.config.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
plugins: { tailwindcss: {}, autoprefixer: {} }
|
| 3 |
+
};
|
src/app/api/submit/route.ts
CHANGED
|
@@ -6,12 +6,6 @@ function safeJsonParse(text: string): any | null {
|
|
| 6 |
try { return JSON.parse(text); } catch { return null; }
|
| 7 |
}
|
| 8 |
|
| 9 |
-
function extractFirstText(hf: any): string {
|
| 10 |
-
if (Array.isArray(hf) && hf.length && typeof hf[0]?.generated_text === "string") return hf[0].generated_text;
|
| 11 |
-
if (typeof hf?.generated_text === "string") return hf.generated_text;
|
| 12 |
-
return JSON.stringify(hf);
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
function tryParseJson(text: string): any | null {
|
| 16 |
const match = text.match(/\{[\s\S]*\}/);
|
| 17 |
if (!match) return null;
|
|
@@ -32,7 +26,7 @@ export async function POST(req: Request) {
|
|
| 32 |
);
|
| 33 |
}
|
| 34 |
|
| 35 |
-
// Optional AI
|
| 36 |
let ai: any = null;
|
| 37 |
const hfToken = process.env.HF_TOKEN;
|
| 38 |
const model = process.env.HF_MODEL || "HuggingFaceH4/zephyr-7b-beta";
|
|
@@ -50,23 +44,30 @@ CANDIDATE RESPONSE:
|
|
| 50 |
${textScenario}
|
| 51 |
`;
|
| 52 |
|
| 53 |
-
|
|
|
|
| 54 |
method: "POST",
|
| 55 |
headers: {
|
| 56 |
"Authorization": `Bearer ${hfToken}`,
|
| 57 |
"Content-Type": "application/json"
|
| 58 |
},
|
| 59 |
body: JSON.stringify({
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
| 62 |
})
|
| 63 |
});
|
| 64 |
|
| 65 |
const hf = await r.json();
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
| 68 |
ai = parsed
|
| 69 |
-
? { ok: true, model, parsed
|
| 70 |
: { ok: false, model, error: "AI output was not valid JSON.", raw: hf };
|
| 71 |
}
|
| 72 |
|
|
|
|
| 6 |
try { return JSON.parse(text); } catch { return null; }
|
| 7 |
}
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
function tryParseJson(text: string): any | null {
|
| 10 |
const match = text.match(/\{[\s\S]*\}/);
|
| 11 |
if (!match) return null;
|
|
|
|
| 26 |
);
|
| 27 |
}
|
| 28 |
|
| 29 |
+
// Optional AI narrative scoring for the scenario (occupational assessment only; not clinical diagnosis)
|
| 30 |
let ai: any = null;
|
| 31 |
const hfToken = process.env.HF_TOKEN;
|
| 32 |
const model = process.env.HF_MODEL || "HuggingFaceH4/zephyr-7b-beta";
|
|
|
|
| 44 |
${textScenario}
|
| 45 |
`;
|
| 46 |
|
| 47 |
+
// Hugging Face Inference Providers routing proxy (OpenAI-compatible) citeturn4view0
|
| 48 |
+
const r = await fetch("https://router.huggingface.co/hf-inference/v1/chat/completions", {
|
| 49 |
method: "POST",
|
| 50 |
headers: {
|
| 51 |
"Authorization": `Bearer ${hfToken}`,
|
| 52 |
"Content-Type": "application/json"
|
| 53 |
},
|
| 54 |
body: JSON.stringify({
|
| 55 |
+
model,
|
| 56 |
+
messages: [{ role: "user", content: prompt }],
|
| 57 |
+
max_tokens: 350,
|
| 58 |
+
temperature: 0.2,
|
| 59 |
+
stream: false
|
| 60 |
})
|
| 61 |
});
|
| 62 |
|
| 63 |
const hf = await r.json();
|
| 64 |
+
|
| 65 |
+
// OpenAI-like response: choices[0].message.content
|
| 66 |
+
const content = hf?.choices?.[0]?.message?.content ?? "";
|
| 67 |
+
const parsed = typeof content === "string" ? tryParseJson(content) : null;
|
| 68 |
+
|
| 69 |
ai = parsed
|
| 70 |
+
? { ok: true, model, parsed }
|
| 71 |
: { ok: false, model, error: "AI output was not valid JSON.", raw: hf };
|
| 72 |
}
|
| 73 |
|
src/app/globals.css
CHANGED
|
@@ -2,5 +2,18 @@
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
+
@keyframes floatSlow {
|
| 6 |
+
0%, 100% { transform: translate(0, 0) scale(1); }
|
| 7 |
+
50% { transform: translate(18px, 14px) scale(1.03); }
|
| 8 |
+
}
|
| 9 |
+
@keyframes floatSlow2 {
|
| 10 |
+
0%, 100% { transform: translate(0, 0) scale(1); }
|
| 11 |
+
50% { transform: translate(-16px, 10px) scale(1.02); }
|
| 12 |
+
}
|
| 13 |
+
@keyframes floatSlow3 {
|
| 14 |
+
0%, 100% { transform: translate(-50%, 0) scale(1); }
|
| 15 |
+
50% { transform: translate(-50%, -12px) scale(1.03); }
|
| 16 |
+
}
|
| 17 |
+
.animate-floatSlow { animation: floatSlow 9s ease-in-out infinite; }
|
| 18 |
+
.animate-floatSlow2 { animation: floatSlow2 11s ease-in-out infinite; }
|
| 19 |
+
.animate-floatSlow3 { animation: floatSlow3 13s ease-in-out infinite; }
|
src/components/assessment/AssessmentApp.tsx
CHANGED
|
@@ -1,555 +1,448 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import * as React from "react";
|
| 4 |
import Image from "next/image";
|
| 5 |
-
import {
|
| 6 |
-
import {
|
| 7 |
-
import {
|
| 8 |
-
import { Progress } from "@/components/ui/Progress";
|
| 9 |
-
import { RadioGroup, RadioItem } from "@/components/ui/RadioGroup";
|
| 10 |
-
import { SECTIONS, TOTAL_SECONDS, MIN_SUBMIT_SECONDS, SectionKey } from "@/lib/sections";
|
| 11 |
-
import { selectExamQuestions, SelectedQuestion } from "@/lib/questions";
|
| 12 |
-
import { scoreExam, CandidateMeta, AnswerMap } from "@/lib/scoring";
|
| 13 |
-
|
| 14 |
-
const STORAGE_KEY = "sgs_psycho_af_v2";
|
| 15 |
-
|
| 16 |
-
function clamp(n:number,a:number,b:number){ return Math.max(a, Math.min(b, n)); }
|
| 17 |
-
function fmtTime(s:number){
|
| 18 |
-
const ss=Math.max(0, s);
|
| 19 |
-
const hh = Math.floor(ss/3600);
|
| 20 |
-
const mm = Math.floor((ss%3600)/60);
|
| 21 |
-
const rr = Math.floor(ss%60);
|
| 22 |
-
if (hh > 0) return `${String(hh).padStart(2,"0")}:${String(mm).padStart(2,"0")}:${String(rr).padStart(2,"0")}`;
|
| 23 |
-
return `${String(mm).padStart(2,"0")}:${String(rr).padStart(2,"0")}`;
|
| 24 |
-
}
|
| 25 |
|
| 26 |
-
type
|
| 27 |
-
sectionIndex: number; // 0..5 current section
|
| 28 |
-
elapsedInSection: number; // seconds used in current section
|
| 29 |
-
carrySeconds: number; // carried from previous sections
|
| 30 |
-
completedSections: SectionKey[]; // locked
|
| 31 |
-
};
|
| 32 |
-
|
| 33 |
-
export function AssessmentApp() {
|
| 34 |
-
const [meta, setMeta] = React.useState<CandidateMeta>({ candidateName:"", candidateId:"", jobTitle:"" });
|
| 35 |
-
|
| 36 |
-
const [started, setStarted] = React.useState(false);
|
| 37 |
-
const [startTs, setStartTs] = React.useState<number>(0);
|
| 38 |
-
|
| 39 |
-
const [sectionState, setSectionState] = React.useState<SectionState>({
|
| 40 |
-
sectionIndex: 0,
|
| 41 |
-
elapsedInSection: 0,
|
| 42 |
-
carrySeconds: 0,
|
| 43 |
-
completedSections: []
|
| 44 |
-
});
|
| 45 |
-
|
| 46 |
-
const [selected, setSelected] = React.useState<SelectedQuestion[]>([]);
|
| 47 |
-
const [answers, setAnswers] = React.useState<AnswerMap>({});
|
| 48 |
-
|
| 49 |
-
const [qIndexWithin, setQIndexWithin] = React.useState(0);
|
| 50 |
-
const [submitting, setSubmitting] = React.useState(false);
|
| 51 |
-
const [submitted, setSubmitted] = React.useState(false);
|
| 52 |
-
const [submitError, setSubmitError] = React.useState<string>("");
|
| 53 |
-
|
| 54 |
-
// anti-refresh (best-effort)
|
| 55 |
-
React.useEffect(() => {
|
| 56 |
-
function beforeUnload(e: BeforeUnloadEvent) {
|
| 57 |
-
if (started && !submitted) { e.preventDefault(); e.returnValue = ""; }
|
| 58 |
-
}
|
| 59 |
-
function keydown(e: KeyboardEvent) {
|
| 60 |
-
if (!started || submitted) return;
|
| 61 |
-
const k = e.key.toLowerCase();
|
| 62 |
-
const ctrl = e.ctrlKey || e.metaKey;
|
| 63 |
-
if (e.key === "F5" || (ctrl && k === "r")) { e.preventDefault(); e.stopPropagation(); }
|
| 64 |
-
}
|
| 65 |
-
window.addEventListener("beforeunload", beforeUnload);
|
| 66 |
-
window.addEventListener("keydown", keydown, { capture: true });
|
| 67 |
-
return () => {
|
| 68 |
-
window.removeEventListener("beforeunload", beforeUnload);
|
| 69 |
-
window.removeEventListener("keydown", keydown, { capture: true } as any);
|
| 70 |
-
};
|
| 71 |
-
}, [started, submitted]);
|
| 72 |
-
|
| 73 |
-
// restore
|
| 74 |
-
React.useEffect(() => {
|
| 75 |
-
try {
|
| 76 |
-
const raw = localStorage.getItem(STORAGE_KEY);
|
| 77 |
-
if (!raw) return;
|
| 78 |
-
const s = JSON.parse(raw);
|
| 79 |
-
if (s?.meta) setMeta(s.meta);
|
| 80 |
-
if (typeof s?.started === "boolean") setStarted(s.started);
|
| 81 |
-
if (typeof s?.startTs === "number") setStartTs(s.startTs);
|
| 82 |
-
if (s?.sectionState) setSectionState(s.sectionState);
|
| 83 |
-
if (Array.isArray(s?.selected)) setSelected(s.selected);
|
| 84 |
-
if (s?.answers) setAnswers(s.answers);
|
| 85 |
-
if (typeof s?.qIndexWithin === "number") setQIndexWithin(s.qIndexWithin);
|
| 86 |
-
if (typeof s?.submitted === "boolean") setSubmitted(s.submitted);
|
| 87 |
-
} catch {}
|
| 88 |
-
}, []);
|
| 89 |
-
|
| 90 |
-
// persist
|
| 91 |
-
React.useEffect(() => {
|
| 92 |
-
try {
|
| 93 |
-
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
| 94 |
-
meta, started, startTs, sectionState, selected, answers, qIndexWithin, submitted
|
| 95 |
-
}));
|
| 96 |
-
} catch {}
|
| 97 |
-
}, [meta, started, startTs, sectionState, selected, answers, qIndexWithin, submitted]);
|
| 98 |
-
|
| 99 |
-
// derive current section key and questions
|
| 100 |
-
const currentSection = SECTIONS[sectionState.sectionIndex];
|
| 101 |
-
const currentKey = currentSection?.key ?? "A";
|
| 102 |
-
|
| 103 |
-
const questionsInCurrent = React.useMemo(() => selected.filter(q => q.section === currentKey), [selected, currentKey]);
|
| 104 |
-
const q = questionsInCurrent[qIndexWithin];
|
| 105 |
-
|
| 106 |
-
// section budget remaining (does NOT borrow from future; carry only from previous)
|
| 107 |
-
const sectionBudget = (currentSection?.allocationSeconds ?? 0) + sectionState.carrySeconds;
|
| 108 |
-
const remainingInSection = Math.max(0, sectionBudget - sectionState.elapsedInSection);
|
| 109 |
-
|
| 110 |
-
const elapsedTotal = started ? Math.floor((Date.now() - startTs) / 1000) : 0;
|
| 111 |
-
const remainingTotal = Math.max(0, TOTAL_SECONDS - elapsedTotal);
|
| 112 |
-
|
| 113 |
-
const answeredCount = React.useMemo(() => {
|
| 114 |
-
let c = 0;
|
| 115 |
-
for (const qq of selected) if ((answers[qq.baseId] ?? "").trim()) c++;
|
| 116 |
-
return c;
|
| 117 |
-
}, [selected, answers]);
|
| 118 |
-
const progressPct = selected.length ? Math.round((answeredCount / selected.length) * 100) : 0;
|
| 119 |
-
|
| 120 |
-
// ticking timer
|
| 121 |
-
React.useEffect(() => {
|
| 122 |
-
if (!started || submitted) return;
|
| 123 |
-
const t = setInterval(() => {
|
| 124 |
-
setSectionState(prev => {
|
| 125 |
-
// increment elapsed in section by 1, but cap by budget
|
| 126 |
-
if (remainingTotal <= 0) return prev;
|
| 127 |
-
const nextElapsed = prev.elapsedInSection + 1;
|
| 128 |
-
return { ...prev, elapsedInSection: nextElapsed };
|
| 129 |
-
});
|
| 130 |
-
}, 1000);
|
| 131 |
-
return () => clearInterval(t);
|
| 132 |
-
}, [started, submitted, remainingTotal]);
|
| 133 |
-
|
| 134 |
-
// if section time runs out, auto-finish section (carry = 0)
|
| 135 |
-
React.useEffect(() => {
|
| 136 |
-
if (!started || submitted) return;
|
| 137 |
-
if (remainingTotal <= 0) {
|
| 138 |
-
// end test - allow submit (but min 20 minutes rule still applies); we will auto-submit
|
| 139 |
-
void submitExam(true);
|
| 140 |
-
return;
|
| 141 |
-
}
|
| 142 |
-
if (currentSection && remainingInSection === 0) {
|
| 143 |
-
finishSection(true);
|
| 144 |
-
}
|
| 145 |
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 146 |
-
}, [remainingInSection, remainingTotal, started, submitted]);
|
| 147 |
-
|
| 148 |
-
const startDisabled = !meta.candidateName.trim() || !meta.candidateId.trim() || !meta.jobTitle.trim();
|
| 149 |
-
|
| 150 |
-
function startExam() {
|
| 151 |
-
if (startDisabled) return;
|
| 152 |
-
const seed = `${meta.candidateId}-${Date.now()}`;
|
| 153 |
-
const sel = selectExamQuestions(seed);
|
| 154 |
-
setSelected(sel);
|
| 155 |
-
setAnswers({});
|
| 156 |
-
setQIndexWithin(0);
|
| 157 |
-
setSectionState({ sectionIndex: 0, elapsedInSection: 0, carrySeconds: 0, completedSections: [] });
|
| 158 |
-
setStarted(true);
|
| 159 |
-
setSubmitted(false);
|
| 160 |
-
setSubmitError("");
|
| 161 |
-
setSubmitting(false);
|
| 162 |
-
setStartTs(Date.now());
|
| 163 |
-
}
|
| 164 |
|
| 165 |
-
|
| 166 |
-
setMeta({ candidateName:"", candidateId:"", jobTitle:"" });
|
| 167 |
-
setStarted(false);
|
| 168 |
-
setSubmitted(false);
|
| 169 |
-
setSubmitError("");
|
| 170 |
-
setSubmitting(false);
|
| 171 |
-
setSelected([]);
|
| 172 |
-
setAnswers({});
|
| 173 |
-
setQIndexWithin(0);
|
| 174 |
-
setSectionState({ sectionIndex: 0, elapsedInSection: 0, carrySeconds: 0, completedSections: [] });
|
| 175 |
-
setStartTs(0);
|
| 176 |
-
try { localStorage.removeItem(STORAGE_KEY); } catch {}
|
| 177 |
-
}
|
| 178 |
|
| 179 |
-
|
| 180 |
-
setAnswers(prev => ({ ...prev, [baseId]: value }));
|
| 181 |
-
}
|
| 182 |
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
}
|
| 186 |
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
}
|
| 191 |
-
function prevWithin() {
|
| 192 |
-
const prev = qIndexWithin - 1;
|
| 193 |
-
if (canMoveWithin(prev)) setQIndexWithin(prev);
|
| 194 |
-
}
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
}
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
if (!auto && !sectionComplete()) return;
|
| 208 |
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
const leftover = Math.max(0, sectionBudget - prev.elapsedInSection); // carry forward
|
| 212 |
-
const nextIndex = prev.sectionIndex + 1;
|
| 213 |
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
}
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
|
| 233 |
-
async
|
| 234 |
-
if (
|
| 235 |
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
setSubmitError("This assessment requires a minimum of 20 minutes to ensure validity. Please continue until 20 minutes have passed.");
|
| 239 |
return;
|
| 240 |
}
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
// must be at last section and complete it
|
| 245 |
-
const lastKey = "F";
|
| 246 |
-
if (currentKey !== lastKey || !sectionComplete()) {
|
| 247 |
-
setSubmitError("Please complete the current section before submitting.");
|
| 248 |
-
return;
|
| 249 |
-
}
|
| 250 |
}
|
| 251 |
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
progressPct,
|
| 267 |
-
scores: scored,
|
| 268 |
-
// include answers? keep lightweight to avoid large webhook payload; include only text + a few flags
|
| 269 |
-
responses: {
|
| 270 |
-
textScenario: textAnswer
|
| 271 |
-
},
|
| 272 |
-
examMeta: {
|
| 273 |
-
version: "2.0.0",
|
| 274 |
-
questionCount: selected.length
|
| 275 |
-
}
|
| 276 |
-
};
|
| 277 |
-
|
| 278 |
-
const r = await fetch("/api/submit", {
|
| 279 |
-
method: "POST",
|
| 280 |
-
headers: { "Content-Type":"application/json" },
|
| 281 |
-
body: JSON.stringify(payload)
|
| 282 |
-
});
|
| 283 |
-
|
| 284 |
-
const data = await r.json();
|
| 285 |
-
if (!data?.ok) {
|
| 286 |
-
throw new Error(data?.error || "Submission failed");
|
| 287 |
-
}
|
| 288 |
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
}
|
| 295 |
-
}
|
| 296 |
|
| 297 |
// UI helpers
|
| 298 |
-
const
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
className="
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
|
|
|
|
|
|
|
|
|
| 315 |
</div>
|
|
|
|
|
|
|
| 316 |
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
</div>
|
| 329 |
-
<h1 className="mt-1 text-2xl font-semibold tracking-tight">Psychometrics Assessment</h1>
|
| 330 |
-
<p className="mt-1 max-w-2xl text-sm text-neutral-600">
|
| 331 |
-
This is an occupational assessment (not a medical diagnosis). Please answer honestly.
|
| 332 |
-
</p>
|
| 333 |
</div>
|
| 334 |
-
</div>
|
| 335 |
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
<div className="
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
</div>
|
| 343 |
-
<div className="
|
| 344 |
-
<Lock className="h-4 w-4" />
|
| 345 |
-
<span>Section time: {fmtTime(remainingInSection)}</span>
|
| 346 |
-
</div>
|
| 347 |
-
</>
|
| 348 |
-
) : null}
|
| 349 |
-
|
| 350 |
-
{!started ? (
|
| 351 |
-
<Button onClick={startExam} disabled={startDisabled}>Start Test</Button>
|
| 352 |
-
) : (
|
| 353 |
-
<div className="flex items-center gap-2 rounded-2xl border bg-white px-3 py-2 text-sm text-neutral-700">
|
| 354 |
-
<ClipboardList className="h-4 w-4" />
|
| 355 |
-
<span>{progressPct}% complete</span>
|
| 356 |
</div>
|
| 357 |
-
|
| 358 |
</div>
|
| 359 |
-
</div>
|
| 360 |
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
</header>
|
| 367 |
-
|
| 368 |
-
<main className="relative mx-auto max-w-6xl px-6 pb-16 pt-8">
|
| 369 |
-
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
| 370 |
-
<aside className="rounded-3xl border bg-white/70 p-5 shadow-sm backdrop-blur">
|
| 371 |
-
<h2 className="text-sm font-semibold text-neutral-800">Candidate</h2>
|
| 372 |
-
<div className="mt-3 grid gap-3">
|
| 373 |
-
<Field label="Name" value={meta.candidateName} onChange={(v)=>setMeta(m=>({...m,candidateName:v}))} disabled={started} placeholder="Full name" />
|
| 374 |
-
<Field label="Candidate ID" value={meta.candidateId} onChange={(v)=>setMeta(m=>({...m,candidateId:v}))} disabled={started} placeholder="e.g., SGS-000123" />
|
| 375 |
-
<Field label="Job Title" value={meta.jobTitle} onChange={(v)=>setMeta(m=>({...m,jobTitle:v}))} disabled={started} placeholder="Role applied for" />
|
| 376 |
</div>
|
|
|
|
| 377 |
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
|
|
|
| 383 |
</div>
|
| 384 |
-
{
|
| 385 |
-
<div className="text-
|
| 386 |
-
|
|
|
|
|
|
|
| 387 |
</div>
|
| 388 |
-
<div className="
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
const done = sectionState.completedSections.includes(s.key);
|
| 393 |
-
const active = idx === sectionState.sectionIndex && started && !submitted;
|
| 394 |
-
return (
|
| 395 |
-
<div key={s.key} className={"flex items-center justify-between rounded-xl border px-3 py-2 " + (active ? "bg-neutral-900 text-white border-neutral-900" : "bg-white")}>
|
| 396 |
-
<span className="font-medium">{s.key}. {s.title}</span>
|
| 397 |
-
{done ? <CheckCircle2 className={"h-4 w-4 " + (active ? "text-white" : "text-green-700")} /> : <span className={active ? "text-white/80" : "text-neutral-500"}>{Math.round(s.allocationSeconds/60)}m</span>}
|
| 398 |
-
</div>
|
| 399 |
-
);
|
| 400 |
-
})}
|
| 401 |
</div>
|
| 402 |
</div>
|
| 403 |
|
| 404 |
-
<div className="mt-
|
| 405 |
-
<
|
| 406 |
</div>
|
| 407 |
|
| 408 |
-
|
| 409 |
-
<
|
| 410 |
-
{
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
<div className="
|
| 418 |
-
<
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
<p className="mt-2 text-sm text-neutral-600">You may now close this page.</p>
|
| 433 |
-
</div>
|
| 434 |
-
) : (
|
| 435 |
-
<div className="grid gap-4">
|
| 436 |
-
<AnimatePresence mode="wait">
|
| 437 |
-
<motion.div
|
| 438 |
-
key={q?.baseId ?? "none"}
|
| 439 |
-
initial={{ opacity: 0, y: 10, filter: "blur(6px)" }}
|
| 440 |
-
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
| 441 |
-
exit={{ opacity: 0, y: -10, filter: "blur(6px)" }}
|
| 442 |
-
transition={{ duration: 0.22 }}
|
| 443 |
-
className="rounded-2xl border bg-white p-6"
|
| 444 |
-
>
|
| 445 |
-
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 446 |
-
<div>
|
| 447 |
-
<div className="text-xs text-neutral-500">
|
| 448 |
-
Section: <span className="text-neutral-700">{currentSection.key}. {currentSection.title}</span>
|
| 449 |
-
</div>
|
| 450 |
-
<h3 className="mt-1 text-lg font-semibold">Question {qIndexWithin + 1} of {questionsInCurrent.length}</h3>
|
| 451 |
-
<p className="mt-2 text-sm text-neutral-700">{q?.prompt}</p>
|
| 452 |
-
</div>
|
| 453 |
-
<div className="rounded-2xl border bg-neutral-50 px-3 py-2 text-xs text-neutral-700">
|
| 454 |
-
Section progress: {sectionProgress}%
|
| 455 |
-
</div>
|
| 456 |
-
</div>
|
| 457 |
-
|
| 458 |
-
<div className="mt-6">
|
| 459 |
-
{q ? (
|
| 460 |
-
<QuestionRenderer
|
| 461 |
-
q={q}
|
| 462 |
-
value={answers[q.baseId] ?? ""}
|
| 463 |
-
onChange={(v)=>setAnswer(q.baseId, v)}
|
| 464 |
-
/>
|
| 465 |
-
) : null}
|
| 466 |
-
</div>
|
| 467 |
-
</motion.div>
|
| 468 |
-
</AnimatePresence>
|
| 469 |
-
|
| 470 |
-
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 471 |
-
<Button onClick={prevWithin} disabled={qIndexWithin === 0}>
|
| 472 |
-
<ArrowLeft className="mr-2 h-4 w-4" /> Back
|
| 473 |
-
</Button>
|
| 474 |
-
|
| 475 |
-
<div className="flex items-center gap-2">
|
| 476 |
-
<Button onClick={nextWithin} disabled={qIndexWithin >= questionsInCurrent.length - 1}>
|
| 477 |
-
Next <ArrowRight className="ml-2 h-4 w-4" />
|
| 478 |
-
</Button>
|
| 479 |
-
|
| 480 |
-
<Button
|
| 481 |
-
onClick={() => finishSection(false)}
|
| 482 |
-
disabled={!sectionComplete()}
|
| 483 |
-
className="bg-green-700 hover:bg-green-600 active:bg-green-800"
|
| 484 |
-
title={!sectionComplete() ? "Answer all questions in this section to finish it" : "Finish this section"}
|
| 485 |
>
|
| 486 |
Finish Section
|
| 487 |
-
</
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
</div>
|
| 501 |
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
</div>
|
| 506 |
-
) : null}
|
| 507 |
</div>
|
| 508 |
)}
|
| 509 |
-
</
|
| 510 |
</div>
|
| 511 |
-
</
|
| 512 |
</div>
|
| 513 |
);
|
| 514 |
}
|
| 515 |
|
| 516 |
-
function
|
| 517 |
return (
|
| 518 |
-
<
|
| 519 |
-
<
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
);
|
| 529 |
}
|
| 530 |
|
| 531 |
-
function
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
return (
|
| 548 |
-
<
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
);
|
| 555 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
|
|
|
| 3 |
import Image from "next/image";
|
| 4 |
+
import { useEffect, useMemo, useRef, useState } from "react";
|
| 5 |
+
import { CandidateForm, CandidateMeta } from "./CandidateForm";
|
| 6 |
+
import { ExamQuestion, SECTIONS, SectionKey, buildExam } from "@/lib/questions";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
+
type ResponseValue = number | string; // likert: 1..5, mcq: option id, text: string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
+
function nowMs() { return Date.now(); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
+
const MIN_SUBMIT_MS = 20 * 60 * 1000;
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
export default function AssessmentApp() {
|
| 15 |
+
const [meta, setMeta] = useState<CandidateMeta | null>(null);
|
|
|
|
| 16 |
|
| 17 |
+
// Build exam once per session; seed includes a random component
|
| 18 |
+
const seedRef = useRef<string>(Math.random().toString(36).slice(2));
|
| 19 |
+
const { examBySection, flat } = useMemo(() => buildExam(seedRef.current), []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
const totalQuestions = flat.length;
|
| 22 |
+
|
| 23 |
+
const [sectionIdx, setSectionIdx] = useState(0);
|
| 24 |
+
const currentSection = SECTIONS[sectionIdx];
|
| 25 |
+
const sectionKey = currentSection.key;
|
| 26 |
+
|
| 27 |
+
const [qIdx, setQIdx] = useState(0);
|
|
|
|
| 28 |
|
| 29 |
+
const sectionQuestions: ExamQuestion[] = examBySection[sectionKey];
|
| 30 |
+
const currentQ = sectionQuestions[qIdx];
|
|
|
|
| 31 |
|
| 32 |
+
// Responses stored by baseId to avoid duplicates perception
|
| 33 |
+
const [responses, setResponses] = useState<Record<string, ResponseValue>>({});
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
// Timer logic with carry-over
|
| 36 |
+
const [startTs, setStartTs] = useState<number | null>(null);
|
| 37 |
+
const [elapsedMs, setElapsedMs] = useState(0);
|
| 38 |
|
| 39 |
+
const sectionTimeSpent = useRef<Record<SectionKey, number>>({ A: 0, B: 0, C: 0, D: 0, E: 0, F: 0 });
|
| 40 |
+
const sectionStartMs = useRef<number | null>(null);
|
| 41 |
+
const carryMs = useRef<number>(0);
|
| 42 |
+
|
| 43 |
+
const [sectionRemainingMs, setSectionRemainingMs] = useState(currentSection.minutes * 60 * 1000);
|
| 44 |
+
|
| 45 |
+
// Anti-refresh: warn on unload + persist minimal state
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
const handler = (e: BeforeUnloadEvent) => {
|
| 48 |
+
if (startTs) {
|
| 49 |
+
e.preventDefault();
|
| 50 |
+
e.returnValue = "";
|
| 51 |
}
|
| 52 |
+
};
|
| 53 |
+
window.addEventListener("beforeunload", handler);
|
| 54 |
+
return () => window.removeEventListener("beforeunload", handler);
|
| 55 |
+
}, [startTs]);
|
| 56 |
+
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
if (!startTs) return;
|
| 59 |
+
const t = setInterval(() => {
|
| 60 |
+
setElapsedMs(nowMs() - startTs);
|
| 61 |
+
// Update remaining for current section (with carry)
|
| 62 |
+
const spentThisSection = sectionStartMs.current ? nowMs() - sectionStartMs.current : 0;
|
| 63 |
+
const budget = currentSection.minutes * 60 * 1000 + carryMs.current;
|
| 64 |
+
const remaining = Math.max(0, budget - spentThisSection);
|
| 65 |
+
setSectionRemainingMs(remaining);
|
| 66 |
+
}, 250);
|
| 67 |
+
return () => clearInterval(t);
|
| 68 |
+
}, [startTs, currentSection.minutes]);
|
| 69 |
+
|
| 70 |
+
const answeredCurrent = useMemo(() => {
|
| 71 |
+
const v = responses[currentQ.baseId];
|
| 72 |
+
if (currentQ.type === "text") return typeof v === "string" && v.trim().length >= (currentQ.minChars ?? 0);
|
| 73 |
+
return v !== undefined && v !== null && v !== "";
|
| 74 |
+
}, [responses, currentQ]);
|
| 75 |
+
|
| 76 |
+
const answeredCount = useMemo(() => Object.keys(responses).length, [responses]);
|
| 77 |
+
const progressPct = useMemo(() => Math.round((answeredCount / totalQuestions) * 100), [answeredCount, totalQuestions]);
|
| 78 |
+
|
| 79 |
+
const currentSectionAnsweredCount = useMemo(() => {
|
| 80 |
+
return sectionQuestions.filter(q => responses[q.baseId] !== undefined && responses[q.baseId] !== "").length;
|
| 81 |
+
}, [sectionQuestions, responses]);
|
| 82 |
+
|
| 83 |
+
const canGoPrev = qIdx > 0;
|
| 84 |
+
const canGoNext = qIdx < sectionQuestions.length - 1;
|
| 85 |
+
|
| 86 |
+
// Cannot skip unanswered: next is disabled unless current answered.
|
| 87 |
+
const nextDisabled = !answeredCurrent;
|
| 88 |
+
|
| 89 |
+
// Prevent going forward past first unanswered question even if user clicked quickly
|
| 90 |
+
const firstUnansweredIndex = useMemo(() => {
|
| 91 |
+
const idx = sectionQuestions.findIndex(q => {
|
| 92 |
+
const v = responses[q.baseId];
|
| 93 |
+
if (q.type === "text") return !(typeof v === "string" && v.trim().length >= (q.minChars ?? 0));
|
| 94 |
+
return v === undefined || v === null || v === "";
|
| 95 |
});
|
| 96 |
+
return idx === -1 ? sectionQuestions.length - 1 : idx;
|
| 97 |
+
}, [sectionQuestions, responses]);
|
| 98 |
+
|
| 99 |
+
const setAnswer = (val: ResponseValue) => {
|
| 100 |
+
setResponses(prev => ({ ...prev, [currentQ.baseId]: val }));
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
const startAssessment = (m: CandidateMeta) => {
|
| 104 |
+
setMeta(m);
|
| 105 |
+
const ts = nowMs();
|
| 106 |
+
setStartTs(ts);
|
| 107 |
+
sectionStartMs.current = ts;
|
| 108 |
+
carryMs.current = 0;
|
| 109 |
+
setSectionRemainingMs(currentSection.minutes * 60 * 1000);
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const gotoPrev = () => {
|
| 113 |
+
if (!canGoPrev) return;
|
| 114 |
+
setQIdx(i => Math.max(0, i - 1));
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
const gotoNext = () => {
|
| 118 |
+
if (!canGoNext || nextDisabled) return;
|
| 119 |
+
// Hard clamp: cannot pass first unanswered
|
| 120 |
+
setQIdx(i => {
|
| 121 |
+
const next = Math.min(sectionQuestions.length - 1, i + 1);
|
| 122 |
+
return Math.min(next, firstUnansweredIndex);
|
| 123 |
+
});
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
+
const finishSection = () => {
|
| 127 |
+
// must have all answered in section
|
| 128 |
+
const allAnswered = currentSectionAnsweredCount === sectionQuestions.length;
|
| 129 |
+
if (!allAnswered) return;
|
| 130 |
+
|
| 131 |
+
// compute carry-over
|
| 132 |
+
const spent = sectionStartMs.current ? nowMs() - sectionStartMs.current : 0;
|
| 133 |
+
const budget = currentSection.minutes * 60 * 1000 + carryMs.current;
|
| 134 |
+
const remaining = Math.max(0, budget - spent);
|
| 135 |
+
|
| 136 |
+
sectionTimeSpent.current[sectionKey] = spent;
|
| 137 |
+
|
| 138 |
+
// advance section
|
| 139 |
+
if (sectionIdx < SECTIONS.length - 1) {
|
| 140 |
+
setSectionIdx(s => s + 1);
|
| 141 |
+
setQIdx(0);
|
| 142 |
+
carryMs.current = remaining;
|
| 143 |
+
sectionStartMs.current = nowMs();
|
| 144 |
+
// remaining will refresh in interval
|
| 145 |
+
}
|
| 146 |
+
};
|
| 147 |
|
| 148 |
+
const canSubmit = useMemo(() => {
|
| 149 |
+
if (!startTs) return false;
|
| 150 |
+
if (elapsedMs < MIN_SUBMIT_MS) return false;
|
| 151 |
+
// all answered
|
| 152 |
+
return answeredCount >= totalQuestions;
|
| 153 |
+
}, [startTs, elapsedMs, answeredCount, totalQuestions]);
|
| 154 |
|
| 155 |
+
const submit = async () => {
|
| 156 |
+
if (!meta || !startTs) return;
|
| 157 |
|
| 158 |
+
if (elapsedMs < MIN_SUBMIT_MS) {
|
| 159 |
+
alert("You can submit the assessment only after 20 minutes have passed.");
|
|
|
|
| 160 |
return;
|
| 161 |
}
|
| 162 |
+
if (answeredCount < totalQuestions) {
|
| 163 |
+
alert("Please answer all questions before submitting.");
|
| 164 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
}
|
| 166 |
|
| 167 |
+
const payload = {
|
| 168 |
+
meta: {
|
| 169 |
+
fullName: meta.fullName,
|
| 170 |
+
jobTitle: meta.jobTitle,
|
| 171 |
+
passportNumber: meta.passportNumber
|
| 172 |
+
},
|
| 173 |
+
seed: seedRef.current,
|
| 174 |
+
startedAt: new Date(startTs).toISOString(),
|
| 175 |
+
submittedAt: new Date().toISOString(),
|
| 176 |
+
elapsedMs,
|
| 177 |
+
responses,
|
| 178 |
+
// include prompts used (for audit)
|
| 179 |
+
questions: flat.map(q => ({ baseId: q.baseId, section: q.section, type: q.type, prompt: q.prompt }))
|
| 180 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
+
const r = await fetch("/api/submit", {
|
| 183 |
+
method: "POST",
|
| 184 |
+
headers: { "Content-Type": "application/json" },
|
| 185 |
+
body: JSON.stringify(payload)
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
const j = await r.json();
|
| 189 |
+
|
| 190 |
+
// Candidate should NOT see results—just thank you
|
| 191 |
+
if (j?.ok) {
|
| 192 |
+
window.location.href = "/thank-you";
|
| 193 |
+
} else {
|
| 194 |
+
alert("Submission failed. Please try again or contact SGS recruitment.");
|
| 195 |
}
|
| 196 |
+
};
|
| 197 |
|
| 198 |
// UI helpers
|
| 199 |
+
const mmss = (ms: number) => {
|
| 200 |
+
const s = Math.max(0, Math.floor(ms / 1000));
|
| 201 |
+
const m = Math.floor(s / 60);
|
| 202 |
+
const r = s % 60;
|
| 203 |
+
return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`;
|
| 204 |
+
};
|
| 205 |
+
|
| 206 |
+
if (!meta) {
|
| 207 |
+
return (
|
| 208 |
+
<div className="min-h-screen relative overflow-hidden bg-zinc-50">
|
| 209 |
+
<Decor />
|
| 210 |
+
<div className="max-w-6xl mx-auto px-6 py-10">
|
| 211 |
+
<Header />
|
| 212 |
+
<div className="mt-10">
|
| 213 |
+
<CandidateForm onSubmit={startAssessment} />
|
| 214 |
+
</div>
|
| 215 |
+
<div className="mt-8 text-xs text-zinc-500 max-w-xl mx-auto text-center">
|
| 216 |
+
This assessment is for occupational screening and safety culture fit. It is not a medical diagnosis.
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
</div>
|
| 220 |
+
);
|
| 221 |
+
}
|
| 222 |
|
| 223 |
+
return (
|
| 224 |
+
<div className="min-h-screen relative overflow-hidden bg-zinc-50">
|
| 225 |
+
<Decor />
|
| 226 |
+
<div className="max-w-6xl mx-auto px-6 py-10">
|
| 227 |
+
<Header />
|
| 228 |
+
|
| 229 |
+
<div className="mt-6 grid gap-4">
|
| 230 |
+
<div className="rounded-3xl border border-zinc-200 bg-white/70 backdrop-blur p-5 shadow-sm">
|
| 231 |
+
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
| 232 |
+
<div className="flex items-center gap-3">
|
| 233 |
+
<div className="h-10 w-10 rounded-2xl bg-gradient-to-br from-emerald-500/15 to-sky-500/15 border border-zinc-200 grid place-items-center">
|
| 234 |
+
<span className="text-sm font-semibold text-zinc-700">{sectionKey}</span>
|
| 235 |
+
</div>
|
| 236 |
+
<div>
|
| 237 |
+
<div className="text-base font-semibold text-zinc-900">{currentSection.title}</div>
|
| 238 |
+
<div className="text-sm text-zinc-600">
|
| 239 |
+
Progress: <span className="font-medium text-zinc-900">{progressPct}%</span> •
|
| 240 |
+
Section: <span className="font-medium text-zinc-900">{currentSectionAnsweredCount}/{sectionQuestions.length}</span>
|
| 241 |
+
</div>
|
| 242 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
</div>
|
|
|
|
| 244 |
|
| 245 |
+
<div className="flex items-center gap-3">
|
| 246 |
+
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-2">
|
| 247 |
+
<div className="text-xs text-zinc-500">Section time left</div>
|
| 248 |
+
<div className="text-sm font-semibold text-zinc-900 tabular-nums">{mmss(sectionRemainingMs)}</div>
|
| 249 |
+
</div>
|
| 250 |
+
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-2">
|
| 251 |
+
<div className="text-xs text-zinc-500">Total elapsed</div>
|
| 252 |
+
<div className="text-sm font-semibold text-zinc-900 tabular-nums">{mmss(elapsedMs)}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
</div>
|
| 254 |
+
</div>
|
| 255 |
</div>
|
|
|
|
| 256 |
|
| 257 |
+
<div className="mt-4 h-2 w-full rounded-full bg-zinc-100 overflow-hidden">
|
| 258 |
+
<div
|
| 259 |
+
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-sky-500 transition-all duration-500"
|
| 260 |
+
style={{ width: `${progressPct}%` }}
|
| 261 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
</div>
|
| 263 |
+
</div>
|
| 264 |
|
| 265 |
+
<div className="rounded-3xl border border-zinc-200 bg-white/70 backdrop-blur p-6 shadow-sm">
|
| 266 |
+
<div className="flex items-start justify-between gap-4">
|
| 267 |
+
<div>
|
| 268 |
+
<div className="text-sm font-medium text-zinc-500">Question {qIdx + 1} of {sectionQuestions.length}</div>
|
| 269 |
+
<div className="mt-2 text-lg font-semibold text-zinc-900 leading-snug">
|
| 270 |
+
{currentQ.prompt}
|
| 271 |
</div>
|
| 272 |
+
{!answeredCurrent && (
|
| 273 |
+
<div className="mt-2 text-sm text-rose-600">
|
| 274 |
+
Please answer to continue.
|
| 275 |
+
</div>
|
| 276 |
+
)}
|
| 277 |
</div>
|
| 278 |
+
<div className="shrink-0 hidden sm:block">
|
| 279 |
+
<div className="h-12 w-12 rounded-2xl border border-zinc-200 bg-white grid place-items-center">
|
| 280 |
+
<span className="text-xs font-semibold text-zinc-700">{sectionKey}-{String(qIdx + 1).padStart(2,"0")}</span>
|
| 281 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
</div>
|
| 283 |
</div>
|
| 284 |
|
| 285 |
+
<div className="mt-5">
|
| 286 |
+
<QuestionBody q={currentQ} value={responses[currentQ.baseId]} onChange={setAnswer} />
|
| 287 |
</div>
|
| 288 |
|
| 289 |
+
<div className="mt-6 flex items-center justify-between">
|
| 290 |
+
<button
|
| 291 |
+
onClick={gotoPrev}
|
| 292 |
+
disabled={!canGoPrev}
|
| 293 |
+
className="h-11 px-4 rounded-xl border border-zinc-200 bg-white text-zinc-900 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-zinc-50 transition"
|
| 294 |
+
>
|
| 295 |
+
Back
|
| 296 |
+
</button>
|
| 297 |
+
|
| 298 |
+
<div className="flex items-center gap-2">
|
| 299 |
+
{sectionIdx < SECTIONS.length - 1 ? (
|
| 300 |
+
<>
|
| 301 |
+
<button
|
| 302 |
+
onClick={gotoNext}
|
| 303 |
+
disabled={!canGoNext || nextDisabled}
|
| 304 |
+
className="h-11 px-4 rounded-xl bg-zinc-900 text-white disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-95 transition"
|
| 305 |
+
>
|
| 306 |
+
Next
|
| 307 |
+
</button>
|
| 308 |
+
<button
|
| 309 |
+
onClick={finishSection}
|
| 310 |
+
disabled={currentSectionAnsweredCount !== sectionQuestions.length}
|
| 311 |
+
className="h-11 px-4 rounded-xl border border-zinc-200 bg-white text-zinc-900 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-zinc-50 transition"
|
| 312 |
+
title="Finish the section when all questions are answered"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
>
|
| 314 |
Finish Section
|
| 315 |
+
</button>
|
| 316 |
+
</>
|
| 317 |
+
) : (
|
| 318 |
+
<button
|
| 319 |
+
onClick={submit}
|
| 320 |
+
disabled={!canSubmit}
|
| 321 |
+
className="h-11 px-5 rounded-xl bg-emerald-600 text-white disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-95 transition"
|
| 322 |
+
>
|
| 323 |
+
Submit
|
| 324 |
+
</button>
|
| 325 |
+
)}
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
|
|
|
| 328 |
|
| 329 |
+
{!canSubmit && sectionIdx === SECTIONS.length - 1 && (
|
| 330 |
+
<div className="mt-4 text-xs text-zinc-500">
|
| 331 |
+
Submission unlocks after <span className="font-semibold">20 minutes</span> and when all questions are answered.
|
|
|
|
|
|
|
| 332 |
</div>
|
| 333 |
)}
|
| 334 |
+
</div>
|
| 335 |
</div>
|
| 336 |
+
</div>
|
| 337 |
</div>
|
| 338 |
);
|
| 339 |
}
|
| 340 |
|
| 341 |
+
function Header() {
|
| 342 |
return (
|
| 343 |
+
<div className="flex items-center justify-between">
|
| 344 |
+
<div className="flex items-center gap-3">
|
| 345 |
+
<div className="relative h-10 w-40">
|
| 346 |
+
<Image src="/sgs-logo.png" alt="SGS" fill className="object-contain" priority />
|
| 347 |
+
</div>
|
| 348 |
+
<div className="hidden md:block">
|
| 349 |
+
<div className="text-lg font-semibold text-zinc-900">Psychometrics Assessment</div>
|
| 350 |
+
<div className="text-sm text-zinc-600">Saudi Ground Services (SGS)</div>
|
| 351 |
+
</div>
|
| 352 |
+
</div>
|
| 353 |
+
<div className="hidden md:flex items-center gap-2 text-xs text-zinc-600">
|
| 354 |
+
<span className="inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-zinc-200 bg-white">
|
| 355 |
+
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
|
| 356 |
+
Secure session
|
| 357 |
+
</span>
|
| 358 |
+
</div>
|
| 359 |
+
</div>
|
| 360 |
);
|
| 361 |
}
|
| 362 |
|
| 363 |
+
function Decor() {
|
| 364 |
+
return (
|
| 365 |
+
<>
|
| 366 |
+
<div className="pointer-events-none absolute -top-24 -left-24 h-80 w-80 rounded-full bg-emerald-400/15 blur-3xl animate-floatSlow" />
|
| 367 |
+
<div className="pointer-events-none absolute top-40 -right-24 h-96 w-96 rounded-full bg-sky-400/15 blur-3xl animate-floatSlow2" />
|
| 368 |
+
<div className="pointer-events-none absolute bottom-0 left-1/2 -translate-x-1/2 h-96 w-[42rem] rounded-full bg-purple-400/10 blur-3xl animate-floatSlow3" />
|
| 369 |
+
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_30%_10%,rgba(16,185,129,0.12),transparent_45%),radial-gradient(circle_at_80%_30%,rgba(56,189,248,0.12),transparent_45%),radial-gradient(circle_at_60%_90%,rgba(168,85,247,0.10),transparent_55%)]" />
|
| 370 |
+
</>
|
| 371 |
+
);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
function Likert({ value, onChange }: { value?: any; onChange: (n: number) => void }) {
|
| 375 |
+
const labels = ["Strongly disagree", "Disagree", "Neutral", "Agree", "Strongly agree"];
|
| 376 |
+
return (
|
| 377 |
+
<div className="grid gap-2">
|
| 378 |
+
<div className="grid grid-cols-1 sm:grid-cols-5 gap-2">
|
| 379 |
+
{labels.map((lab, i) => {
|
| 380 |
+
const v = i + 1;
|
| 381 |
+
const active = value === v;
|
| 382 |
+
return (
|
| 383 |
+
<button
|
| 384 |
+
key={lab}
|
| 385 |
+
onClick={() => onChange(v)}
|
| 386 |
+
className={[
|
| 387 |
+
"h-12 rounded-xl border transition",
|
| 388 |
+
active
|
| 389 |
+
? "border-emerald-500 bg-emerald-500 text-white shadow-sm"
|
| 390 |
+
: "border-zinc-200 bg-white text-zinc-900 hover:bg-zinc-50"
|
| 391 |
+
].join(" ")}
|
| 392 |
+
type="button"
|
| 393 |
+
>
|
| 394 |
+
<div className="text-xs font-semibold">{lab}</div>
|
| 395 |
+
</button>
|
| 396 |
+
);
|
| 397 |
+
})}
|
| 398 |
+
</div>
|
| 399 |
+
</div>
|
| 400 |
+
);
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
function MCQ({ q, value, onChange }: { q: ExamQuestion; value?: any; onChange: (s: string) => void }) {
|
| 404 |
+
return (
|
| 405 |
+
<div className="grid gap-2">
|
| 406 |
+
{(q.options || []).map((o) => {
|
| 407 |
+
const active = value === o.id;
|
| 408 |
+
return (
|
| 409 |
+
<button
|
| 410 |
+
key={o.id}
|
| 411 |
+
onClick={() => onChange(o.id)}
|
| 412 |
+
className={[
|
| 413 |
+
"w-full text-left p-4 rounded-2xl border transition",
|
| 414 |
+
active ? "border-sky-500 bg-sky-500/10" : "border-zinc-200 bg-white hover:bg-zinc-50"
|
| 415 |
+
].join(" ")}
|
| 416 |
+
type="button"
|
| 417 |
+
>
|
| 418 |
+
<div className="text-sm font-medium text-zinc-900">{o.label}</div>
|
| 419 |
+
</button>
|
| 420 |
+
);
|
| 421 |
+
})}
|
| 422 |
+
</div>
|
| 423 |
+
);
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
function TextAnswer({ q, value, onChange }: { q: ExamQuestion; value?: any; onChange: (s: string) => void }) {
|
| 427 |
+
const v = typeof value === "string" ? value : "";
|
| 428 |
+
const min = q.minChars ?? 0;
|
| 429 |
return (
|
| 430 |
+
<div className="grid gap-2">
|
| 431 |
+
<textarea
|
| 432 |
+
value={v}
|
| 433 |
+
onChange={(e) => onChange(e.target.value)}
|
| 434 |
+
className="min-h-[180px] w-full rounded-2xl border border-zinc-200 bg-white p-4 text-zinc-900 outline-none focus:ring-2 focus:ring-sky-500/30"
|
| 435 |
+
placeholder="Write your response here..."
|
| 436 |
+
/>
|
| 437 |
+
<div className="text-xs text-zinc-500">
|
| 438 |
+
Minimum characters: <span className="font-semibold">{min}</span> • Current: <span className={v.trim().length >= min ? "font-semibold text-emerald-600" : "font-semibold text-rose-600"}>{v.trim().length}</span>
|
| 439 |
+
</div>
|
| 440 |
+
</div>
|
| 441 |
);
|
| 442 |
}
|
| 443 |
+
|
| 444 |
+
function QuestionBody({ q, value, onChange }: { q: ExamQuestion; value: any; onChange: (v: any) => void }) {
|
| 445 |
+
if (q.type === "likert") return <Likert value={value} onChange={onChange} />;
|
| 446 |
+
if (q.type === "mcq") return <MCQ q={q} value={value} onChange={onChange} />;
|
| 447 |
+
return <TextAnswer q={q} value={value} onChange={onChange} />;
|
| 448 |
+
}
|
src/components/assessment/CandidateForm.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useMemo, useState } from "react";
|
| 4 |
+
|
| 5 |
+
export type CandidateMeta = {
|
| 6 |
+
fullName: string;
|
| 7 |
+
jobTitle: string;
|
| 8 |
+
passportNumber: string;
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export function CandidateForm(props: {
|
| 12 |
+
onSubmit: (meta: CandidateMeta) => void;
|
| 13 |
+
}) {
|
| 14 |
+
const [fullName, setFullName] = useState("");
|
| 15 |
+
const [jobTitle, setJobTitle] = useState("");
|
| 16 |
+
const [passportNumber, setPassportNumber] = useState("");
|
| 17 |
+
|
| 18 |
+
const canStart = useMemo(() => {
|
| 19 |
+
return fullName.trim().length >= 3 && jobTitle.trim().length >= 2 && passportNumber.trim().length >= 4;
|
| 20 |
+
}, [fullName, jobTitle, passportNumber]);
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<div className="w-full max-w-xl mx-auto rounded-3xl border border-zinc-200 bg-white/70 backdrop-blur p-6 shadow-sm">
|
| 24 |
+
<div className="flex items-center gap-3 mb-4">
|
| 25 |
+
<div className="h-10 w-10 rounded-2xl bg-gradient-to-br from-emerald-500/15 to-sky-500/15 border border-zinc-200 grid place-items-center">
|
| 26 |
+
<span className="text-sm font-semibold text-zinc-700">SGS</span>
|
| 27 |
+
</div>
|
| 28 |
+
<div>
|
| 29 |
+
<div className="text-lg font-semibold text-zinc-900">Candidate Details</div>
|
| 30 |
+
<div className="text-sm text-zinc-600">Enter your information to begin the assessment.</div>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<div className="grid gap-4">
|
| 35 |
+
<label className="grid gap-1">
|
| 36 |
+
<span className="text-sm font-medium text-zinc-800">Full Name</span>
|
| 37 |
+
<input
|
| 38 |
+
value={fullName}
|
| 39 |
+
onChange={(e) => setFullName(e.target.value)}
|
| 40 |
+
className="h-11 rounded-xl border border-zinc-200 bg-white px-3 text-zinc-900 outline-none focus:ring-2 focus:ring-emerald-500/30"
|
| 41 |
+
placeholder="e.g., Khalid Al‑Nimri"
|
| 42 |
+
/>
|
| 43 |
+
</label>
|
| 44 |
+
|
| 45 |
+
<label className="grid gap-1">
|
| 46 |
+
<span className="text-sm font-medium text-zinc-800">Job Title</span>
|
| 47 |
+
<input
|
| 48 |
+
value={jobTitle}
|
| 49 |
+
onChange={(e) => setJobTitle(e.target.value)}
|
| 50 |
+
className="h-11 rounded-xl border border-zinc-200 bg-white px-3 text-zinc-900 outline-none focus:ring-2 focus:ring-emerald-500/30"
|
| 51 |
+
placeholder="e.g., Ground Services Agent"
|
| 52 |
+
/>
|
| 53 |
+
</label>
|
| 54 |
+
|
| 55 |
+
<label className="grid gap-1">
|
| 56 |
+
<span className="text-sm font-medium text-zinc-800">Passport Number</span>
|
| 57 |
+
<input
|
| 58 |
+
value={passportNumber}
|
| 59 |
+
onChange={(e) => setPassportNumber(e.target.value)}
|
| 60 |
+
className="h-11 rounded-xl border border-zinc-200 bg-white px-3 text-zinc-900 outline-none focus:ring-2 focus:ring-emerald-500/30"
|
| 61 |
+
placeholder="e.g., P1234567"
|
| 62 |
+
/>
|
| 63 |
+
</label>
|
| 64 |
+
|
| 65 |
+
<button
|
| 66 |
+
disabled={!canStart}
|
| 67 |
+
onClick={() => canStart && props.onSubmit({ fullName: fullName.trim(), jobTitle: jobTitle.trim(), passportNumber: passportNumber.trim() })}
|
| 68 |
+
className="mt-2 h-11 rounded-xl bg-zinc-900 text-white font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-95 transition"
|
| 69 |
+
>
|
| 70 |
+
Start Assessment
|
| 71 |
+
</button>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
);
|
| 75 |
+
}
|
src/lib/questions.ts
CHANGED
|
@@ -1,324 +1,310 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
export type QuestionType = "likert" | "mcq" | "text";
|
| 4 |
|
| 5 |
-
export type
|
| 6 |
-
prompt: string;
|
| 7 |
-
options?: string[];
|
| 8 |
-
mcqScores?: Record<string, number>; // 0..5
|
| 9 |
-
reverse?: boolean; // for likert
|
| 10 |
-
};
|
| 11 |
|
| 12 |
-
export
|
| 13 |
-
|
| 14 |
section: SectionKey;
|
| 15 |
type: QuestionType;
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
{ prompt: p2, options: [...LIKERT_5], reverse },
|
| 26 |
-
{ prompt: p3, options: [...LIKERT_5], reverse }
|
| 27 |
-
];
|
| 28 |
}
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
/**
|
| 43 |
-
* 135 base questions
|
| 44 |
-
*
|
|
|
|
|
|
|
| 45 |
*/
|
| 46 |
-
export
|
| 47 |
-
const
|
| 48 |
|
| 49 |
-
// A
|
| 50 |
-
const
|
| 51 |
-
{
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
{
|
| 57 |
-
"I often leave tasks unfinished if they become inconvenient.",
|
| 58 |
-
"If something is tedious, I may stop before it’s complete.",
|
| 59 |
-
"When a task gets annoying, I sometimes abandon it."
|
| 60 |
-
]},
|
| 61 |
-
{domain:"EmotionalStability", prompts:[
|
| 62 |
-
"I stay calm and focused when unexpected issues arise.",
|
| 63 |
-
"When things go wrong, I can keep my composure.",
|
| 64 |
-
"I remain steady and effective during disruptions."
|
| 65 |
-
]},
|
| 66 |
-
{domain:"ImpulseControl_rev", reverse:true, prompts:[
|
| 67 |
-
"I sometimes act first and think later when stressed.",
|
| 68 |
-
"Under stress, I may respond before fully thinking.",
|
| 69 |
-
"When pressured, I can react impulsively."
|
| 70 |
-
]},
|
| 71 |
-
{domain:"Adaptability", prompts:[
|
| 72 |
-
"I adjust quickly when priorities or plans change.",
|
| 73 |
-
"When schedules change, I adapt without losing quality.",
|
| 74 |
-
"I can shift approach quickly when circumstances change."
|
| 75 |
-
]},
|
| 76 |
-
{domain:"TeamOrientation", prompts:[
|
| 77 |
-
"I actively support teammates when workload increases.",
|
| 78 |
-
"When others are overloaded, I help to keep operations running.",
|
| 79 |
-
"I step in to assist colleagues to ensure continuity."
|
| 80 |
-
]},
|
| 81 |
-
{domain:"Integrity_rev", reverse:true, prompts:[
|
| 82 |
-
"Small rule-bending is acceptable if it helps finish faster.",
|
| 83 |
-
"Breaking minor rules is fine if it improves speed.",
|
| 84 |
-
"If it saves time, small shortcuts are acceptable."
|
| 85 |
-
]},
|
| 86 |
-
{domain:"StressTolerance", prompts:[
|
| 87 |
-
"I can handle time pressure without making careless mistakes.",
|
| 88 |
-
"Even when rushed, I keep my work accurate.",
|
| 89 |
-
"Under time pressure, I remain careful and accurate."
|
| 90 |
-
]},
|
| 91 |
-
{domain:"AttentionCheck", prompts:[
|
| 92 |
-
"For quality control, please select 'Agree' for this statement.",
|
| 93 |
-
"To confirm attention, choose 'Agree' here.",
|
| 94 |
-
"Attention check: select 'Agree' for this item."
|
| 95 |
-
]},
|
| 96 |
];
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
const
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
out.push({
|
| 111 |
-
id: `A${String(idx+1).padStart(2,"0")}`,
|
| 112 |
section: "A",
|
| 113 |
type: "likert",
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
| 116 |
});
|
| 117 |
-
}
|
| 118 |
|
| 119 |
-
// B
|
| 120 |
-
//
|
| 121 |
-
const
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
"
|
|
|
|
| 126 |
];
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
} as Record<string, number>;
|
| 133 |
-
|
| 134 |
-
for (let k=1; k<=10; k++){
|
| 135 |
-
out.push({
|
| 136 |
-
id: `BMCQ${String(k).padStart(2,"0")}`,
|
| 137 |
section: "B",
|
| 138 |
-
type: "
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
});
|
| 148 |
}
|
| 149 |
-
|
| 150 |
-
const
|
| 151 |
-
{
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
];
|
| 172 |
-
|
| 173 |
-
let
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
domain:t.domain,
|
| 183 |
-
variants: vLikert(p1,p2,p3, Boolean(t.reverse))
|
| 184 |
});
|
| 185 |
}
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
"Scenario:
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
});
|
| 199 |
|
| 200 |
-
//
|
| 201 |
-
for (let i=1; i<=20; i++){
|
| 202 |
-
const reverse =
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
});
|
| 216 |
}
|
| 217 |
|
| 218 |
-
//
|
| 219 |
-
for (let i=1; i<=20; i++){
|
| 220 |
-
const reverse =
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
section:"D",
|
| 224 |
-
type:"likert",
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
|
|
|
| 232 |
});
|
| 233 |
}
|
| 234 |
|
| 235 |
-
// E
|
| 236 |
-
for (let i=1; i<=13; i++){
|
| 237 |
-
const reverse =
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
section:"E",
|
| 241 |
-
type:"likert",
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
| 249 |
});
|
| 250 |
}
|
| 251 |
|
| 252 |
-
// F
|
| 253 |
-
for (let i=1; i<=15; i++){
|
| 254 |
-
const reverse =
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
section:"F",
|
| 258 |
-
type:"likert",
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
| 266 |
});
|
| 267 |
}
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
})();
|
| 272 |
|
| 273 |
-
export
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
prompt: string;
|
| 280 |
-
options?: string[];
|
| 281 |
-
mcqScores?: Record<string, number>;
|
| 282 |
-
reverse?: boolean;
|
| 283 |
-
};
|
| 284 |
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
for (let i=0;i<seed.length;i++){ h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619); }
|
| 289 |
-
function rand() { h += 0x6D2B79F5; let t = Math.imul(h ^ (h >>> 15), 1 | h); t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }
|
| 290 |
-
function pickVariant(): 0|1|2 { return (Math.floor(rand()*3) as 0|1|2); }
|
| 291 |
|
| 292 |
-
const
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
-
|
| 296 |
-
|
|
|
|
|
|
|
| 297 |
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
for (let i=pool.length-1;i>0;i--){
|
| 302 |
-
const j = Math.floor(rand()*(i+1));
|
| 303 |
-
[pool[i], pool[j]] = [pool[j], pool[i]];
|
| 304 |
-
}
|
| 305 |
-
// take all (counts already exactly per section)
|
| 306 |
-
for (const qb of pool){
|
| 307 |
-
const vi = pickVariant();
|
| 308 |
-
const v = qb.variants[vi];
|
| 309 |
-
selected.push({
|
| 310 |
-
baseId: qb.id,
|
| 311 |
-
section: qb.section,
|
| 312 |
-
type: qb.type,
|
| 313 |
-
domain: qb.domain,
|
| 314 |
-
variantIndex: vi,
|
| 315 |
-
prompt: v.prompt,
|
| 316 |
-
options: v.options,
|
| 317 |
-
mcqScores: v.mcqScores,
|
| 318 |
-
reverse: v.reverse
|
| 319 |
-
});
|
| 320 |
-
}
|
| 321 |
-
}
|
| 322 |
|
| 323 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
}
|
|
|
|
| 1 |
+
export type SectionKey = "A" | "B" | "C" | "D" | "E" | "F";
|
| 2 |
|
| 3 |
export type QuestionType = "likert" | "mcq" | "text";
|
| 4 |
|
| 5 |
+
export type LikertScale = "frequency" | "agreement" | "confidence" | "capacity";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
export interface BaseQuestion {
|
| 8 |
+
baseId: string; // unique within test
|
| 9 |
section: SectionKey;
|
| 10 |
type: QuestionType;
|
| 11 |
+
promptVariants: [string, string, string];
|
| 12 |
+
// Likert
|
| 13 |
+
scale?: LikertScale;
|
| 14 |
+
reverse?: boolean; // reverse-score (higher response => lower trait)
|
| 15 |
+
trait?: string; // internal trait tag
|
| 16 |
+
// MCQ
|
| 17 |
+
options?: { id: string; label: string; score: number }[];
|
| 18 |
+
// Text
|
| 19 |
+
minChars?: number;
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
+
export interface ExamQuestion {
|
| 23 |
+
id: string; // baseId + variant index
|
| 24 |
+
baseId: string;
|
| 25 |
+
section: SectionKey;
|
| 26 |
+
type: QuestionType;
|
| 27 |
+
prompt: string;
|
| 28 |
+
scale?: LikertScale;
|
| 29 |
+
reverse?: boolean;
|
| 30 |
+
trait?: string;
|
| 31 |
+
options?: { id: string; label: string; score: number }[];
|
| 32 |
+
minChars?: number;
|
| 33 |
}
|
| 34 |
|
| 35 |
+
export const SECTIONS: { key: SectionKey; title: string; minutes: number; weight: number; count: number }[] = [
|
| 36 |
+
{ key: "A", title: "Psychometric & Personality", minutes: 12, weight: 0.20, count: 27 },
|
| 37 |
+
{ key: "B", title: "Job-Focused Capability", minutes: 18, weight: 0.30, count: 40 },
|
| 38 |
+
{ key: "C", title: "Work Stressors & Burnout Risk", minutes: 9, weight: 0.15, count: 20 },
|
| 39 |
+
{ key: "D", title: "Workload Sustainability", minutes: 8, weight: 0.15, count: 20 },
|
| 40 |
+
{ key: "E", title: "Psychological Safety & Team Fit", minutes: 6, weight: 0.10, count: 13 },
|
| 41 |
+
{ key: "F", title: "Wellbeing & Functional Readiness", minutes: 7, weight: 0.10, count: 15 },
|
| 42 |
+
];
|
| 43 |
+
|
| 44 |
+
function pad2(n: number) { return String(n).padStart(2, "0"); }
|
| 45 |
|
| 46 |
/**
|
| 47 |
+
* Build the 135 base questions programmatically, with 3 rephrased variants each.
|
| 48 |
+
* IMPORTANT: baseId is unique inside a section, and selection guarantees:
|
| 49 |
+
* - exactly 1 variant per baseId (so no duplicates inside the section)
|
| 50 |
+
* - total 135 questions across the full test
|
| 51 |
*/
|
| 52 |
+
export function buildQuestionBank(): BaseQuestion[] {
|
| 53 |
+
const bank: BaseQuestion[] = [];
|
| 54 |
|
| 55 |
+
// ---- Section A (27) - Likert
|
| 56 |
+
const aTraits = [
|
| 57 |
+
{ trait: "emotional_stability", scale: "agreement" as const, reverseEvery: 5 },
|
| 58 |
+
{ trait: "conscientiousness", scale: "agreement" as const, reverseEvery: 7 },
|
| 59 |
+
{ trait: "stress_tolerance", scale: "agreement" as const, reverseEvery: 6 },
|
| 60 |
+
{ trait: "impulse_control", scale: "agreement" as const, reverseEvery: 8 },
|
| 61 |
+
{ trait: "team_orientation", scale: "agreement" as const, reverseEvery: 9 },
|
| 62 |
+
{ trait: "adaptability", scale: "agreement" as const, reverseEvery: 10 },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
];
|
| 64 |
+
for (let i = 1; i <= 27; i++) {
|
| 65 |
+
const t = aTraits[(i - 1) % aTraits.length];
|
| 66 |
+
const reverse = i % t.reverseEvery === 0;
|
| 67 |
+
const stem = [
|
| 68 |
+
"I stay calm and consistent even when situations change quickly.",
|
| 69 |
+
"When plans change suddenly, I can adapt without losing focus.",
|
| 70 |
+
"I keep my emotions under control in tense situations."
|
| 71 |
+
];
|
| 72 |
+
const v1 = stem[0];
|
| 73 |
+
const v2 = stem[1];
|
| 74 |
+
const v3 = stem[2];
|
| 75 |
+
bank.push({
|
| 76 |
+
baseId: `A${pad2(i)}`,
|
|
|
|
|
|
|
| 77 |
section: "A",
|
| 78 |
type: "likert",
|
| 79 |
+
promptVariants: [v1, v2, v3],
|
| 80 |
+
scale: t.scale,
|
| 81 |
+
reverse,
|
| 82 |
+
trait: t.trait
|
| 83 |
});
|
| 84 |
+
}
|
| 85 |
|
| 86 |
+
// ---- Section B (40) - 29 Likert + 10 MCQ + 1 Text scenario
|
| 87 |
+
// Likert (B01..B29)
|
| 88 |
+
const bTraits = [
|
| 89 |
+
{ trait: "attention_detail", scale: "agreement" as const },
|
| 90 |
+
{ trait: "judgment", scale: "agreement" as const },
|
| 91 |
+
{ trait: "procedures", scale: "agreement" as const },
|
| 92 |
+
{ trait: "safety_mindset", scale: "agreement" as const },
|
| 93 |
+
{ trait: "pressure_decision", scale: "agreement" as const },
|
| 94 |
];
|
| 95 |
+
for (let i = 1; i <= 29; i++) {
|
| 96 |
+
const t = bTraits[(i - 1) % bTraits.length];
|
| 97 |
+
const reverse = i % 9 === 0; // occasional reverse items
|
| 98 |
+
bank.push({
|
| 99 |
+
baseId: `B${pad2(i)}`,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
section: "B",
|
| 101 |
+
type: "likert",
|
| 102 |
+
promptVariants: [
|
| 103 |
+
"I follow procedures even when it would be faster to take a shortcut.",
|
| 104 |
+
"When under time pressure, I still prioritize safety and compliance.",
|
| 105 |
+
"I double-check critical details before I act."
|
| 106 |
+
],
|
| 107 |
+
scale: t.scale,
|
| 108 |
+
reverse,
|
| 109 |
+
trait: t.trait
|
| 110 |
});
|
| 111 |
}
|
| 112 |
+
// MCQ (B30..B39) - scored choices (no diagnosis)
|
| 113 |
+
const mcqTemplates = [
|
| 114 |
+
{
|
| 115 |
+
prompt: [
|
| 116 |
+
"You notice a safety barrier is missing near an active work area. What do you do first?",
|
| 117 |
+
"A barrier is missing in an active zone. What should you do first?",
|
| 118 |
+
"You see an unsafe area without the required barrier. What is your first action?"
|
| 119 |
+
],
|
| 120 |
+
options: [
|
| 121 |
+
{ id: "a", label: "Continue and report it later", score: 0 },
|
| 122 |
+
{ id: "b", label: "Stop the work and inform the supervisor / safety immediately", score: 5 },
|
| 123 |
+
{ id: "c", label: "Ask a colleague to handle it while you continue", score: 2 },
|
| 124 |
+
{ id: "d", label: "Ignore it if no one is nearby", score: 0 },
|
| 125 |
+
],
|
| 126 |
+
trait: "safety_action"
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
prompt: [
|
| 130 |
+
"A passenger is upset and raising their voice at the counter. Best response?",
|
| 131 |
+
"A customer is angry and escalating. What is the best response?",
|
| 132 |
+
"An upset passenger is shouting. What should you do?"
|
| 133 |
+
],
|
| 134 |
+
options: [
|
| 135 |
+
{ id: "a", label: "Match their tone to show authority", score: 0 },
|
| 136 |
+
{ id: "b", label: "Stay calm, listen, acknowledge, and follow escalation procedure", score: 5 },
|
| 137 |
+
{ id: "c", label: "Walk away immediately without explanation", score: 1 },
|
| 138 |
+
{ id: "d", label: "Promise anything to end the situation quickly", score: 1 },
|
| 139 |
+
],
|
| 140 |
+
trait: "deescalation"
|
| 141 |
+
},
|
| 142 |
];
|
| 143 |
+
// Expand to 10 by cycling templates with slight variation in baseId
|
| 144 |
+
for (let i = 30; i <= 39; i++) {
|
| 145 |
+
const t = mcqTemplates[(i - 30) % mcqTemplates.length];
|
| 146 |
+
bank.push({
|
| 147 |
+
baseId: `B${pad2(i)}`,
|
| 148 |
+
section: "B",
|
| 149 |
+
type: "mcq",
|
| 150 |
+
promptVariants: [t.prompt[0], t.prompt[1], t.prompt[2]],
|
| 151 |
+
options: t.options,
|
| 152 |
+
trait: t.trait
|
|
|
|
|
|
|
| 153 |
});
|
| 154 |
}
|
| 155 |
+
// Text scenario (B40)
|
| 156 |
+
bank.push({
|
| 157 |
+
baseId: "B40",
|
| 158 |
+
section: "B",
|
| 159 |
+
type: "text",
|
| 160 |
+
promptVariants: [
|
| 161 |
+
"Scenario: A last-minute flight change causes crowding at the gate and a colleague looks overwhelmed. Describe what you would do, step by step, to maintain safety, follow procedures, and support the team.",
|
| 162 |
+
"Scenario: A sudden operational disruption increases pressure and the queue grows fast. Explain your step-by-step actions to stay compliant, communicate clearly, and keep things safe.",
|
| 163 |
+
"Scenario: During a busy shift, a disruption escalates stress. Describe your actions to prioritize safety, follow procedures, and coordinate with others."
|
| 164 |
+
],
|
| 165 |
+
minChars: 120,
|
| 166 |
+
trait: "text_scenario"
|
| 167 |
});
|
| 168 |
|
| 169 |
+
// ---- Section C (20) - Likert
|
| 170 |
+
for (let i = 1; i <= 20; i++) {
|
| 171 |
+
const reverse = i % 6 === 0;
|
| 172 |
+
bank.push({
|
| 173 |
+
baseId: `C${pad2(i)}`,
|
| 174 |
+
section: "C",
|
| 175 |
+
type: "likert",
|
| 176 |
+
promptVariants: [
|
| 177 |
+
"I can maintain quality when workload and time pressure increase.",
|
| 178 |
+
"I recover well after demanding shifts and return focused.",
|
| 179 |
+
"I notice early signs of fatigue and take appropriate steps."
|
| 180 |
+
],
|
| 181 |
+
scale: "agreement",
|
| 182 |
+
reverse,
|
| 183 |
+
trait: "burnout_risk"
|
| 184 |
});
|
| 185 |
}
|
| 186 |
|
| 187 |
+
// ---- Section D (20) - Likert
|
| 188 |
+
for (let i = 1; i <= 20; i++) {
|
| 189 |
+
const reverse = i % 7 === 0;
|
| 190 |
+
bank.push({
|
| 191 |
+
baseId: `D${pad2(i)}`,
|
| 192 |
+
section: "D",
|
| 193 |
+
type: "likert",
|
| 194 |
+
promptVariants: [
|
| 195 |
+
"I can sustain consistent performance across long periods of demand.",
|
| 196 |
+
"Even with repetitive tasks, I stay attentive and avoid careless errors.",
|
| 197 |
+
"I manage my energy so I can perform reliably throughout the day."
|
| 198 |
+
],
|
| 199 |
+
scale: "agreement",
|
| 200 |
+
reverse,
|
| 201 |
+
trait: "sustainability"
|
| 202 |
});
|
| 203 |
}
|
| 204 |
|
| 205 |
+
// ---- Section E (13) - Likert
|
| 206 |
+
for (let i = 1; i <= 13; i++) {
|
| 207 |
+
const reverse = i % 8 === 0;
|
| 208 |
+
bank.push({
|
| 209 |
+
baseId: `E${pad2(i)}`,
|
| 210 |
+
section: "E",
|
| 211 |
+
type: "likert",
|
| 212 |
+
promptVariants: [
|
| 213 |
+
"If I see a safety issue, I am comfortable speaking up even to senior staff.",
|
| 214 |
+
"I can raise concerns respectfully when I think a process is unsafe.",
|
| 215 |
+
"I accept feedback and adjust my behavior without becoming defensive."
|
| 216 |
+
],
|
| 217 |
+
scale: "agreement",
|
| 218 |
+
reverse,
|
| 219 |
+
trait: "psych_safety"
|
| 220 |
});
|
| 221 |
}
|
| 222 |
|
| 223 |
+
// ---- Section F (15) - Likert (non-clinical functional readiness)
|
| 224 |
+
for (let i = 1; i <= 15; i++) {
|
| 225 |
+
const reverse = i % 6 === 0;
|
| 226 |
+
bank.push({
|
| 227 |
+
baseId: `F${pad2(i)}`,
|
| 228 |
+
section: "F",
|
| 229 |
+
type: "likert",
|
| 230 |
+
promptVariants: [
|
| 231 |
+
"I can manage stress without it affecting my work performance.",
|
| 232 |
+
"When I feel overwhelmed, I use healthy coping strategies to stay functional.",
|
| 233 |
+
"I am open to seeking support early if stress starts to impact my work."
|
| 234 |
+
],
|
| 235 |
+
scale: "agreement",
|
| 236 |
+
reverse,
|
| 237 |
+
trait: "wellbeing_readiness"
|
| 238 |
});
|
| 239 |
}
|
| 240 |
|
| 241 |
+
return bank;
|
| 242 |
+
}
|
|
|
|
| 243 |
|
| 244 |
+
export function buildExam(seed?: string): {
|
| 245 |
+
examBySection: Record<SectionKey, ExamQuestion[]>;
|
| 246 |
+
flat: ExamQuestion[];
|
| 247 |
+
} {
|
| 248 |
+
const bank = buildQuestionBank();
|
| 249 |
+
const examBySection: any = { A: [], B: [], C: [], D: [], E: [], F: [] };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
+
// Deterministic random if seed provided
|
| 252 |
+
let rng = mulberry32(hashSeed(seed || String(Date.now())));
|
| 253 |
+
function pickVariant(): 0 | 1 | 2 { return (Math.floor(rng() * 3) as 0 | 1 | 2); }
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
+
for (const b of bank) {
|
| 256 |
+
const v = pickVariant();
|
| 257 |
+
const q: ExamQuestion = {
|
| 258 |
+
id: `${b.baseId}_v${v + 1}`,
|
| 259 |
+
baseId: b.baseId,
|
| 260 |
+
section: b.section,
|
| 261 |
+
type: b.type,
|
| 262 |
+
prompt: b.promptVariants[v],
|
| 263 |
+
scale: b.scale,
|
| 264 |
+
reverse: b.reverse,
|
| 265 |
+
trait: b.trait,
|
| 266 |
+
options: b.options,
|
| 267 |
+
minChars: b.minChars
|
| 268 |
+
};
|
| 269 |
+
examBySection[b.section].push(q);
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
// Ensure NO duplicates by baseId within each section
|
| 273 |
+
(Object.keys(examBySection) as SectionKey[]).forEach((k) => {
|
| 274 |
+
const seen = new Set<string>();
|
| 275 |
+
examBySection[k] = examBySection[k].filter((q: ExamQuestion) => {
|
| 276 |
+
if (seen.has(q.baseId)) return false;
|
| 277 |
+
seen.add(q.baseId);
|
| 278 |
+
return true;
|
| 279 |
+
});
|
| 280 |
+
});
|
| 281 |
|
| 282 |
+
// Sort each section by baseId so navigation is stable (no weird shuffle duplicates perception)
|
| 283 |
+
(Object.keys(examBySection) as SectionKey[]).forEach((k) => {
|
| 284 |
+
examBySection[k].sort((a: ExamQuestion, b: ExamQuestion) => a.baseId.localeCompare(b.baseId));
|
| 285 |
+
});
|
| 286 |
|
| 287 |
+
const flat: ExamQuestion[] = ([] as ExamQuestion[]).concat(
|
| 288 |
+
examBySection.A, examBySection.B, examBySection.C, examBySection.D, examBySection.E, examBySection.F
|
| 289 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
|
| 291 |
+
return { examBySection, flat };
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/** Utilities: deterministic RNG */
|
| 295 |
+
function hashSeed(s: string): number {
|
| 296 |
+
let h = 2166136261;
|
| 297 |
+
for (let i = 0; i < s.length; i++) {
|
| 298 |
+
h ^= s.charCodeAt(i);
|
| 299 |
+
h = Math.imul(h, 16777619);
|
| 300 |
+
}
|
| 301 |
+
return h >>> 0;
|
| 302 |
+
}
|
| 303 |
+
function mulberry32(a: number) {
|
| 304 |
+
return function () {
|
| 305 |
+
let t = (a += 0x6D2B79F5);
|
| 306 |
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
| 307 |
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
| 308 |
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
| 309 |
+
};
|
| 310 |
}
|
tailwind.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
module.exports = {
|
| 3 |
+
content: ["./src/**/*.{js,ts,jsx,tsx}"],
|
| 4 |
+
theme: { extend: {} },
|
| 5 |
+
plugins: []
|
| 6 |
+
};
|
tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"lib": ["dom", "dom.iterable", "es2022"],
|
| 5 |
+
"strict": true,
|
| 6 |
+
"noEmit": true,
|
| 7 |
+
"module": "esnext",
|
| 8 |
+
"moduleResolution": "bundler",
|
| 9 |
+
"resolveJsonModule": true,
|
| 10 |
+
"isolatedModules": true,
|
| 11 |
+
"jsx": "preserve",
|
| 12 |
+
"baseUrl": ".",
|
| 13 |
+
"paths": { "@/*": ["src/*"] },
|
| 14 |
+
"skipLibCheck": true
|
| 15 |
+
},
|
| 16 |
+
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx"],
|
| 17 |
+
"exclude": ["node_modules"]
|
| 18 |
+
}
|