diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx index db0a5c9..e0cb43f 100644 --- a/app/onboarding/page.tsx +++ b/app/onboarding/page.tsx @@ -1,8 +1,11 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; +import OnboardingQuestionnaire from "@/components/onboarding/OnboardingQuestionnaire"; + +type OnboardingStep = "organization" | "questionnaire" | "complete"; export default function OnboardingPage() { const router = useRouter(); @@ -12,20 +15,45 @@ export default function OnboardingPage() { return null; } + const [step, setStep] = useState("organization"); const [role, setRole] = useState<"admin" | "employee">("employee"); const [orgName, setOrgName] = useState(""); const [orgInvite, setOrgInvite] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [initializing, setInitializing] = useState(true); + + useEffect(() => { + const checkUserOrganization = async () => { + try { + const response = await fetch("/api/me", { credentials: "include" }); + if (response.ok) { + const userData = await response.json(); + if (userData.organisation) { + if (userData.organisation.role === "admin") { + completeOnboarding(); + } else { + setStep("questionnaire"); + } + } + } + } catch (err) { + console.error("Error checking user organization:", err); + } finally { + setInitializing(false); + } + }; - const handleSubmit = async (e: React.FormEvent) => { + checkUserOrganization(); + }, []); + + const handleOrganizationSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); setLoading(true); try { if (role === "admin") { - // 1) Create the org + link you as admin const create = await fetch("/api/orgs", { method: "POST", credentials: "include", @@ -36,6 +64,7 @@ export default function OnboardingPage() { const body = await create.json().catch(() => ({})); throw new Error(body.message || "Failed to create organization"); } + completeOnboarding(); } else { const addemp = await fetch("/api/orgs/addemployee", { method: "POST", @@ -49,13 +78,47 @@ export default function OnboardingPage() { const body = await addemp.json().catch(() => ({})); throw new Error(body.message || "Failed to add employee"); } + setStep("questionnaire"); + setLoading(false); + } + } catch (err: any) { + setError(err.message); + setLoading(false); + } + }; + + const handleQuestionnaireComplete = async (responses: number[]) => { + setError(null); + setLoading(true); + + try { + if (responses.length > 0) { + const responseSubmit = await fetch("/api/onboarding/responses", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ option_ids: responses }), + }); + if (!responseSubmit.ok) { + const body = await responseSubmit.json().catch(() => ({})); + throw new Error(body.message || "Failed to submit responses"); + } } + completeOnboarding(); + } catch (err: any) { + setError(err.message); + setLoading(false); + } + }; + const completeOnboarding = async () => { + try { const done = await fetch("/api/complete-onboarding", { method: "POST", credentials: "include", }); if (!done.ok) throw new Error("Could not complete onboarding"); + const updatedUser = await fetch("/api/me", { credentials: "include", }).then((r) => r.json()); @@ -66,100 +129,138 @@ export default function OnboardingPage() { setLoading(false); } }; - - return ( -
-

Onboarding

-

- Welcome, {user.firstname || user.email}! Let’s get you set up. -

- - {/* Step 1: Choose role */} -
- - + if (initializing) { + return ( +
+
+
+

Loading...

+
+ ); + } - {role === "admin" && ( -
-
- - setOrgName(e.target.value)} - required - className="w-full border border-gray-300 rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-purple-300" - /> -
- - {error &&

{error}

} + if (step === "organization") { + return ( +
+

Onboarding

+

+ Welcome, {user.firstname || user.email}! Let's get you set up. +

+
- - )} - {role === "employee" && ( -
-
- - setOrgInvite(e.target.value)} - required - className="w-full border border-gray-300 rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-purple-300" - /> -
- - {error &&

{error}

} - -
- )} +
+ + {role === "admin" && ( +
+
+ + setOrgName(e.target.value)} + required + className="w-full border border-gray-300 rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-purple-300" + /> +
+ + {error &&

{error}

} + + +
+ )} + {role === "employee" && ( +
+
+ + setOrgInvite(e.target.value)} + required + className="w-full border border-gray-300 rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-purple-300" + /> +
+ + {error &&

{error}

} + + +
+ )} +
+ ); + } + + if (step === "questionnaire") { + return ( +
+ {error && ( +
+
+ {error} +
+
+ )} + +
+ ); + } + + return ( +
+
+
+

Completing onboarding...

+
); } diff --git a/app/organisation/page.tsx b/app/organisation/page.tsx index 64f169d..4785a44 100644 --- a/app/organisation/page.tsx +++ b/app/organisation/page.tsx @@ -1,10 +1,14 @@ "use client"; import { useAuth } from "@/context/AuthContext"; +import { useState } from "react"; import ManageTags from "@/components/organisation/settings/ManageTags"; +import OnboardingConfig from "@/components/organisation/settings/OnboardingConfig"; export default function OrganisationsPage() { const { user } = useAuth(); + const [activeTab, setActiveTab] = useState<"tags" | "onboarding">("tags"); + if (!user || !user.hasCompletedOnboarding) { return null; } @@ -22,10 +26,41 @@ export default function OrganisationsPage() { return (
-

+

My Organisation

- + + {/* Tab Navigation */} +
+
+ +
+
+ + {/* Tab Content */} + {activeTab === "tags" && } + {activeTab === "onboarding" && }
); } diff --git a/components/onboarding/OnboardingQuestionnaire.tsx b/components/onboarding/OnboardingQuestionnaire.tsx new file mode 100644 index 0000000..4911667 --- /dev/null +++ b/components/onboarding/OnboardingQuestionnaire.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useState, useEffect } from "react"; + +interface Tag { + id: number; + name: string; +} + +interface Option { + id: number; + option_text: string; + tag_id: number | null; + tag_name: string | null; +} + +interface Question { + id: number; + question_text: string; + position: number; + options: Option[]; +} + +interface OnboardingQuestionnaireProps { + onComplete: (responses: number[]) => void; + loading: boolean; +} + +export default function OnboardingQuestionnaire({ + onComplete, + loading, +}: OnboardingQuestionnaireProps) { + const [questions, setQuestions] = useState([]); + const [selectedOptions, setSelectedOptions] = useState>( + new Set() + ); + const [error, setError] = useState(null); + const [fetchLoading, setFetchLoading] = useState(true); + const [currentQuestion, setCurrentQuestion] = useState(0); + + useEffect(() => { + fetchQuestions(); + }, []); + + const fetchQuestions = async () => { + try { + const res = await fetch("/api/onboarding/questions", { + credentials: "include", + }); + if (res.ok) { + const data = await res.json(); + setQuestions(data.questions || []); + } else { + setError("Failed to load questions"); + } + } catch (err) { + setError("Error loading questions"); + console.error(err); + } finally { + setFetchLoading(false); + } + }; + + const handleOptionSelect = (optionId: number) => { + const newSelected = new Set(selectedOptions); + if (newSelected.has(optionId)) { + newSelected.delete(optionId); + } else { + newSelected.add(optionId); + } + setSelectedOptions(newSelected); + }; + + const handleSubmit = () => { + if (selectedOptions.size === 0) { + setError("Please select at least one option"); + return; + } + onComplete(Array.from(selectedOptions)); + }; + + const goToNext = () => { + if (currentQuestion < questions.length - 1) { + setCurrentQuestion(currentQuestion + 1); + } + }; + + const goToPrevious = () => { + if (currentQuestion > 0) { + setCurrentQuestion(currentQuestion - 1); + } + }; + + if (fetchLoading) { + return ( +
+
+
+

Loading questions...

+
+
+ ); + } + + if (error) { + return ( +
+
+ {error} +
+
+ ); + } + + if (questions.length === 0) { + return ( +
+
+

+ No Questions Available +

+

+ Your organization hasn't set up any onboarding questions yet. +

+ +
+
+ ); + } + + const currentQ = questions[currentQuestion]; + const isLastQuestion = currentQuestion === questions.length - 1; + const progress = ((currentQuestion + 1) / questions.length) * 100; + + return ( +
+
+

Skills Assessment

+

+ Help us understand your background and experience level. +

+ +
+
+
+ +

+ Question {currentQuestion + 1} of {questions.length} +

+
+ +
+

{currentQ.question_text}

+ +
+ {currentQ.options.map((option) => ( + + ))} +
+
+ +
+ + +
+ {questions.map((_, index) => ( +
+ + {isLastQuestion ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/components/organisation/settings/OnboardingConfig.tsx b/components/organisation/settings/OnboardingConfig.tsx new file mode 100644 index 0000000..840526b --- /dev/null +++ b/components/organisation/settings/OnboardingConfig.tsx @@ -0,0 +1,357 @@ +"use client"; + +import { useState, useEffect } from "react"; + +interface Tag { + id: number; + name: string; +} + +interface Option { + id: number; + option_text: string; + tag_id: number | null; + tag_name: string | null; +} + +interface Question { + id: number; + question_text: string; + position: number; + options: Option[]; +} + +export default function OnboardingConfig() { + const [questions, setQuestions] = useState([]); + const [tags, setTags] = useState([]); + const [newQuestionText, setNewQuestionText] = useState(""); + const [editingQuestion, setEditingQuestion] = useState(null); + const [newOptionText, setNewOptionText] = useState(""); + const [selectedTagId, setSelectedTagId] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchQuestions = async () => { + try { + const res = await fetch("/api/onboarding/questions", { + credentials: "include", + }); + if (res.ok) { + const data = await res.json(); + setQuestions(data.questions || []); + } else { + setError("Failed to fetch questions"); + } + } catch (err) { + setError("Error fetching questions"); + console.error(err); + } + }; + + const fetchTags = async () => { + try { + const res = await fetch("/api/courses/tags", { credentials: "include" }); + if (res.ok) { + const data = await res.json(); + setTags(data || []); + } else { + setError("Failed to fetch tags"); + } + } catch (err) { + setError("Error fetching tags"); + console.error(err); + } + }; + + useEffect(() => { + fetchQuestions(); + fetchTags(); + }, []); + + const addQuestion = async () => { + const text = newQuestionText.trim(); + if (!text) return; + + setLoading(true); + setError(null); + + try { + const res = await fetch("/api/onboarding/questions", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + question_text: text, + position: questions.length, + }), + }); + + if (res.ok) { + setNewQuestionText(""); + await fetchQuestions(); + } else { + setError("Failed to add question"); + } + } catch (err) { + setError("Error adding question"); + console.error(err); + } finally { + setLoading(false); + } + }; + + const deleteQuestion = async (id: number) => { + if ( + !confirm( + "Delete this question? All associated options will also be deleted." + ) + ) { + return; + } + + setLoading(true); + setError(null); + + try { + const res = await fetch(`/api/onboarding/questions/${id}`, { + method: "DELETE", + credentials: "include", + }); + + if (res.ok) { + await fetchQuestions(); + } else { + setError("Failed to delete question"); + } + } catch (err) { + setError("Error deleting question"); + console.error(err); + } finally { + setLoading(false); + } + }; + + const addOption = async (questionId: number) => { + const text = newOptionText.trim(); + if (!text) return; + + setLoading(true); + setError(null); + + try { + const res = await fetch( + `/api/onboarding/questions/${questionId}/options`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + option_text: text, + ...(selectedTagId && { tag_id: selectedTagId }), + }), + } + ); + + if (res.ok) { + setNewOptionText(""); + setSelectedTagId(null); + setEditingQuestion(null); + await fetchQuestions(); + } else { + setError("Failed to add option"); + } + } catch (err) { + setError("Error adding option"); + console.error(err); + } finally { + setLoading(false); + } + }; + + const deleteOption = async (optionId: number, questionId: number) => { + if (!confirm("Delete this option?")) { + return; + } + + setLoading(true); + setError(null); + + try { + const res = await fetch(`/api/onboarding/options/${optionId}`, { + method: "DELETE", + credentials: "include", + }); + + if (res.ok) { + await fetchQuestions(); + } else { + const errorData = await res.json().catch(() => ({})); + setError(errorData.message || "Failed to delete option"); + } + } catch (err) { + setError("Error deleting option"); + console.error(err); + } finally { + setLoading(false); + } + }; + + return ( +
+

Onboarding Form Configuration

+ + {error && ( +
+ {error} +
+ )} + +
+

Add New Question

+
+ setNewQuestionText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addQuestion(); + } + }} + className="flex-1 p-2 border rounded focus:outline-none focus:ring" + disabled={loading} + /> + +
+
+ +
+ {questions.map((question) => ( +
+
+

{question.question_text}

+
+ + +
+
+ + {question.options.length > 0 ? ( +
+

Options:

+
+ {question.options.map((option) => ( +
+
+ {option.option_text} + {option.tag_name && ( + + {option.tag_name} + + )} +
+ +
+ ))} +
+
+ ) : ( +
+

+ ⚠️ This question has no options yet. Add at least one option + to make it available to employees. +

+
+ )} + + {editingQuestion === question.id && ( +
+

Add New Option

+
+ setNewOptionText(e.target.value)} + className="w-full p-2 border rounded focus:outline-none focus:ring" + disabled={loading} + /> + + +
+
+ )} +
+ ))} +
+ + {questions.length === 0 && !loading && ( +
+ No questions configured yet. Add your first question above. +
+ )} +
+ ); +}