From ff155e57d9e18ec24123e68c63c12abd1478b9d3 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 01:05:36 +0800 Subject: [PATCH 01/21] Create onboarding form configuration for admin --- app/organisation/page.tsx | 39 ++- .../settings/OnboardingConfig.tsx | 287 ++++++++++++++++++ 2 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 components/organisation/settings/OnboardingConfig.tsx 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/organisation/settings/OnboardingConfig.tsx b/components/organisation/settings/OnboardingConfig.tsx new file mode 100644 index 0000000..9a3b709 --- /dev/null +++ b/components/organisation/settings/OnboardingConfig.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { useState, useEffect } from "react"; + +interface Tag { + id: number; + name: string; +} + +interface Option { + id: number; + option_text: string; + tag_id: number; + tag_name: string; +} + +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 || !selectedTagId) 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, + 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); + } + }; + + return ( +
+

Onboarding Form Configuration

+ + {error && ( +
+ {error} +
+ )} + + {/* Add New Question */} +
+

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 List */} +
+ {questions.map((question) => ( +
+
+

{question.question_text}

+
+ + +
+
+ + {/* Options */} + {question.options.length > 0 && ( +
+

Options:

+
+ {question.options.map((option) => ( +
+ {option.option_text} + + {option.tag_name} + +
+ ))} +
+
+ )} + + {/* Add Option Form */} + {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. +
+ )} +
+ ); +} \ No newline at end of file From 2c1ff577d5b824836db1586cce502e868bbb23b3 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 09:15:48 +0800 Subject: [PATCH 02/21] Add onboarding form for emplpoyee --- app/onboarding/page.tsx | 277 ++++++++++++------ .../onboarding/OnboardingQuestionnaire.tsx | 227 ++++++++++++++ .../settings/OnboardingConfig.tsx | 79 +++-- 3 files changed, 466 insertions(+), 117 deletions(-) create mode 100644 components/onboarding/OnboardingQuestionnaire.tsx 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/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 index 9a3b709..56b762c 100644 --- a/components/organisation/settings/OnboardingConfig.tsx +++ b/components/organisation/settings/OnboardingConfig.tsx @@ -10,8 +10,8 @@ interface Tag { interface Option { id: number; option_text: string; - tag_id: number; - tag_name: string; + tag_id: number | null; + tag_name: string | null; } interface Question { @@ -33,7 +33,9 @@ export default function OnboardingConfig() { const fetchQuestions = async () => { try { - const res = await fetch("/api/onboarding/questions", { credentials: "include" }); + const res = await fetch("/api/onboarding/questions", { + credentials: "include", + }); if (res.ok) { const data = await res.json(); setQuestions(data.questions || []); @@ -80,7 +82,7 @@ export default function OnboardingConfig() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question_text: text, - position: questions.length + position: questions.length, }), }); @@ -99,7 +101,11 @@ export default function OnboardingConfig() { }; const deleteQuestion = async (id: number) => { - if (!confirm("Delete this question? All associated options will also be deleted.")) { + if ( + !confirm( + "Delete this question? All associated options will also be deleted." + ) + ) { return; } @@ -127,21 +133,24 @@ export default function OnboardingConfig() { const addOption = async (questionId: number) => { const text = newOptionText.trim(); - if (!text || !selectedTagId) return; + 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, - tag_id: selectedTagId - }), - }); + 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(""); @@ -169,7 +178,6 @@ export default function OnboardingConfig() {
)} - {/* Add New Question */}

Add New Question

@@ -197,15 +205,21 @@ export default function OnboardingConfig() {
- {/* Questions List */}
{questions.map((question) => ( -
+

{question.question_text}

- {/* Options */} {question.options.length > 0 && (

Options:

{question.options.map((option) => ( -
+
{option.option_text} - - {option.tag_name} - + {option.tag_name && ( + + {option.tag_name} + + )}
))}
)} - {/* Add Option Form */} {editingQuestion === question.id && (

Add New Option

@@ -252,11 +269,15 @@ export default function OnboardingConfig() { />
); -} \ No newline at end of file +} From 092c0572a62f52ca78e2f46d68da8cbf7c9fe724 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 09:49:06 +0800 Subject: [PATCH 03/21] Handle options for onboarding form --- .../settings/OnboardingConfig.tsx | 63 ++++++++++++++++--- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/components/organisation/settings/OnboardingConfig.tsx b/components/organisation/settings/OnboardingConfig.tsx index 56b762c..840526b 100644 --- a/components/organisation/settings/OnboardingConfig.tsx +++ b/components/organisation/settings/OnboardingConfig.tsx @@ -168,6 +168,34 @@ export default function OnboardingConfig() { } }; + 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

@@ -234,7 +262,7 @@ export default function OnboardingConfig() {
- {question.options.length > 0 && ( + {question.options.length > 0 ? (

Options:

@@ -243,16 +271,37 @@ export default function OnboardingConfig() { key={option.id} className="flex items-center justify-between bg-gray-50 p-2 rounded" > - {option.option_text} - {option.tag_name && ( - - {option.tag_name} - - )} +
+ {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 && ( From 5c8f67f8a42836d5e829e804e6b6a57f9d20579f Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 12:19:41 +0800 Subject: [PATCH 04/21] Add basic roadmap --- app/roadmap/page.tsx | 138 ++++++++++++- components/roadmap/RoadmapEditor.tsx | 298 +++++++++++++++++++++++++++ components/roadmap/RoadmapList.tsx | 97 +++++++++ components/roadmap/types.ts | 30 +++ 4 files changed, 559 insertions(+), 4 deletions(-) create mode 100644 components/roadmap/RoadmapEditor.tsx create mode 100644 components/roadmap/RoadmapList.tsx create mode 100644 components/roadmap/types.ts diff --git a/app/roadmap/page.tsx b/app/roadmap/page.tsx index bf5fba0..f175c90 100644 --- a/app/roadmap/page.tsx +++ b/app/roadmap/page.tsx @@ -1,8 +1,138 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useAuth } from "@/context/AuthContext"; +import RoadmapList from "@/components/roadmap/RoadmapList"; +import RoadmapEditor from "@/components/roadmap/RoadmapEditor"; +import { Roadmap } from "@/components/roadmap/types"; + export default function RoadmapPage() { + const { user } = useAuth(); + const [roadmaps, setRoadmaps] = useState([]); + const [selectedRoadmap, setSelectedRoadmap] = useState(null); + const [loading, setLoading] = useState(true); + + if (!user || !user.hasCompletedOnboarding) { + return null; + } + + useEffect(() => { + fetchRoadmaps(); + }, []); + + const fetchRoadmaps = async () => { + try { + const response = await fetch("/api/roadmaps", { + credentials: "include", + }); + + if (response.ok) { + const data = await response.json(); + setRoadmaps(data.roadmaps || []); + } + } catch (error) { + console.error("Failed to fetch roadmaps:", error); + } finally { + setLoading(false); + } + }; + + const createNewRoadmap = async () => { + const name = prompt("Enter roadmap name:"); + if (!name?.trim()) return; + + try { + const response = await fetch("/api/roadmaps", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ name: name.trim() }), + }); + + if (response.ok) { + const data = await response.json(); + setRoadmaps([data.roadmap, ...roadmaps]); + setSelectedRoadmap(data.roadmap); + } + } catch (error) { + console.error("Failed to create roadmap:", error); + } + }; + + const autoGenerateRoadmap = async () => { + const name = prompt("Enter roadmap name:", "My AI-Generated Roadmap"); + if (!name?.trim()) return; + + try { + const response = await fetch("/api/roadmaps/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ name: name.trim() }), + }); + + if (response.ok) { + const data = await response.json(); + setRoadmaps([data.roadmap, ...roadmaps]); + setSelectedRoadmap(data.roadmap); + + if (data.modulesAdded > 0) { + alert(`Generated roadmap with ${data.modulesAdded} recommended modules based on your skills! You've been auto-enrolled in ${data.enrolledCourses} courses.`); + } else { + alert("Generated empty roadmap - no modules found matching your skills. You can add modules manually."); + } + } + } catch (error) { + console.error("Failed to auto-generate roadmap:", error); + } + }; + + const deleteRoadmap = async (roadmapId: number) => { + try { + const response = await fetch(`/api/roadmaps/${roadmapId}`, { + method: "DELETE", + credentials: "include", + }); + + if (response.ok) { + setRoadmaps(roadmaps.filter(r => r.id !== roadmapId)); + if (selectedRoadmap?.id === roadmapId) { + setSelectedRoadmap(null); + } + } + } catch (error) { + console.error("Failed to delete roadmap:", error); + } + }; + + const updateRoadmap = (updatedRoadmap: Roadmap) => { + setRoadmaps(roadmaps.map(r => + r.id === updatedRoadmap.id ? updatedRoadmap : r + )); + setSelectedRoadmap(updatedRoadmap); + }; + + if (loading) { + return
Loading your roadmaps...
; + } + + if (selectedRoadmap) { + return ( + setSelectedRoadmap(null)} + onUpdate={updateRoadmap} + /> + ); + } + return ( -
-

Roadmap

-

Roadmap page content goes here.

-
+ ); } diff --git a/components/roadmap/RoadmapEditor.tsx b/components/roadmap/RoadmapEditor.tsx new file mode 100644 index 0000000..cd83ed3 --- /dev/null +++ b/components/roadmap/RoadmapEditor.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Roadmap, RoadmapItem, Module } from "./types"; + +interface Props { + roadmap: Roadmap; + onBack: () => void; + onUpdate: (roadmap: Roadmap) => void; +} + +export default function RoadmapEditor({ roadmap, onBack, onUpdate }: Props) { + const [roadmapName, setRoadmapName] = useState(roadmap.name); + const [items, setItems] = useState([]); + const [availableModules, setAvailableModules] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [showModuleSelector, setShowModuleSelector] = useState(false); + + useEffect(() => { + fetchRoadmapItems(); + fetchAvailableModules(); + }, [roadmap.id]); + + const fetchRoadmapItems = async () => { + try { + const response = await fetch(`/api/roadmaps/${roadmap.id}/items`, { + credentials: "include", + }); + if (response.ok) { + const data = await response.json(); + setItems(data.items || []); + } + } catch (error) { + console.error("Failed to fetch roadmap items:", error); + } finally { + setLoading(false); + } + }; + + const fetchAvailableModules = async () => { + try { + // Get user-tag-based recommendations first + const userTagResponse = await fetch("/api/materials/by-user-tags", { + credentials: "include", + }); + + let userRecommendedModules = []; + if (userTagResponse.ok) { + const userData = await userTagResponse.json(); + userRecommendedModules = userData.materials || []; + } + + // Then get all modules + const allResponse = await fetch("/api/materials", { + credentials: "include", + }); + + let allModules = []; + if (allResponse.ok) { + const allData = await allResponse.json(); + allModules = allData.materials || []; + } + + // Combine with user recommendations first + const recommendedIds = new Set(userRecommendedModules.map(m => m.id)); + const otherModules = allModules.filter(m => !recommendedIds.has(m.id)); + + setAvailableModules([...userRecommendedModules, ...otherModules]); + } catch (error) { + console.error("Failed to fetch modules:", error); + setAvailableModules([]); + } + }; + + const updateRoadmapName = async () => { + if (roadmapName.trim() === roadmap.name) return; + + setSaving(true); + try { + const response = await fetch(`/api/roadmaps/${roadmap.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ name: roadmapName.trim() }), + }); + + if (response.ok) { + const data = await response.json(); + onUpdate(data.roadmap); + } + } catch (error) { + console.error("Failed to update roadmap name:", error); + } finally { + setSaving(false); + } + }; + + const addModule = async (moduleId: number) => { + try { + const response = await fetch(`/api/roadmaps/${roadmap.id}/items`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ module_id: moduleId }), + }); + + if (response.ok) { + await fetchRoadmapItems(); + setShowModuleSelector(false); + } + } catch (error) { + console.error("Failed to add module:", error); + } + }; + + const removeModule = async (moduleId: number) => { + if (!confirm("Remove this module from your roadmap?")) return; + + try { + const response = await fetch(`/api/roadmaps/${roadmap.id}/items/${moduleId}`, { + method: "DELETE", + credentials: "include", + }); + + if (response.ok) { + setItems(items.filter(item => item.module_id !== moduleId)); + } + } catch (error) { + console.error("Failed to remove module:", error); + } + }; + + if (loading) { + return
Loading roadmap...
; + } + + return ( +
+
+ +
+ +
+
+ setRoadmapName(e.target.value)} + className="flex-1 text-2xl font-semibold border-b-2 border-transparent hover:border-gray-300 focus:border-purple-600 focus:outline-none" + onBlur={updateRoadmapName} + onKeyDown={(e) => e.key === "Enter" && updateRoadmapName()} + /> + {saving && Saving...} +
+ +
+

Learning Modules

+ +
+ + {items.length === 0 ? ( +
+

No modules in this roadmap yet.

+ +
+ ) : ( +
+ {items.map((item, index) => ( +
+
+ + #{index + 1} + +
+

{item.module_title}

+

+ {item.course_name} • {item.module_type} +

+ {item.description && ( +

{item.description}

+ )} +
+ + {item.enrollment_status === 'enrolled' ? 'Enrolled' : 'Not Enrolled'} + + + {item.module_status.replace('_', ' ')} + +
+
+
+ +
+ ))} +
+ )} +
+ + {showModuleSelector && ( +
+
+
+

Add Module to Roadmap

+ +
+ + {availableModules.length === 0 ? ( +

No modules available to add.

+ ) : ( +
+ {availableModules + .filter(module => !items.some(item => item.module_id === module.id)) + .map((module, index) => { + const isRecommended = module.matching_tags > 0; + return ( +
+
+
+

{module.module_title}

+ {isRecommended && ( + + ✨ Recommended + + )} +
+

+ {module.course_name} • {module.module_type} +

+ {module.description && ( +

{module.description}

+ )} + {module.tags && ( +

+ Tags: {module.tags.filter(tag => tag).join(', ') || 'None'} +

+ )} +
+ +
+ ); + })} +
+ )} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/roadmap/RoadmapList.tsx b/components/roadmap/RoadmapList.tsx new file mode 100644 index 0000000..56dfd0b --- /dev/null +++ b/components/roadmap/RoadmapList.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState } from "react"; +import { Roadmap } from "./types"; + +interface Props { + roadmaps: Roadmap[]; + onSelect: (roadmap: Roadmap) => void; + onDelete: (roadmapId: number) => void; + onCreateNew: () => void; + onAutoGenerate: () => void; +} + +export default function RoadmapList({ roadmaps, onSelect, onDelete, onCreateNew, onAutoGenerate }: Props) { + const [deletingId, setDeletingId] = useState(null); + + const handleDelete = async (roadmapId: number) => { + if (!confirm("Are you sure you want to delete this roadmap?")) return; + + setDeletingId(roadmapId); + try { + await onDelete(roadmapId); + } finally { + setDeletingId(null); + } + }; + + return ( +
+
+

My Learning Roadmaps

+
+ + +
+
+ + {roadmaps.length === 0 ? ( +
+

You don't have any roadmaps yet.

+
+ + +
+
+ ) : ( +
+ {roadmaps.map((roadmap) => ( +
+

+ {roadmap.name} +

+
+ + +
+
+ ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/roadmap/types.ts b/components/roadmap/types.ts new file mode 100644 index 0000000..fd89802 --- /dev/null +++ b/components/roadmap/types.ts @@ -0,0 +1,30 @@ +export interface Roadmap { + id: number; + name: string; + user_id: number; +} + +export interface RoadmapItem { + position: number; + module_id: number; + module_title: string; + description: string; + module_type: string; + file_url: string; + course_name: string; + course_id: number; + enrollment_status: 'enrolled' | 'not_enrolled'; + module_status: 'not_started' | 'in_progress' | 'completed'; +} + +export interface Module { + id: number; + module_title: string; + description: string; + module_type: string; + file_url: string; + course_name: string; + course_id: number; + tags: string[]; + matching_tags?: number; +} \ No newline at end of file From 9a606a093239809ef637808992f3c18f4192a353 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 13:03:34 +0800 Subject: [PATCH 05/21] Add roadmap with direct url to the module --- components/roadmap/RoadmapEditor.tsx | 256 +++++++++++++++++++-------- 1 file changed, 187 insertions(+), 69 deletions(-) diff --git a/components/roadmap/RoadmapEditor.tsx b/components/roadmap/RoadmapEditor.tsx index cd83ed3..d9a059f 100644 --- a/components/roadmap/RoadmapEditor.tsx +++ b/components/roadmap/RoadmapEditor.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import Link from "next/link"; import { Roadmap, RoadmapItem, Module } from "./types"; interface Props { @@ -40,32 +41,33 @@ export default function RoadmapEditor({ roadmap, onBack, onUpdate }: Props) { const fetchAvailableModules = async () => { try { - // Get user-tag-based recommendations first const userTagResponse = await fetch("/api/materials/by-user-tags", { credentials: "include", }); - + let userRecommendedModules = []; if (userTagResponse.ok) { const userData = await userTagResponse.json(); userRecommendedModules = userData.materials || []; } - // Then get all modules const allResponse = await fetch("/api/materials", { credentials: "include", }); - + let allModules = []; if (allResponse.ok) { const allData = await allResponse.json(); allModules = allData.materials || []; } - // Combine with user recommendations first - const recommendedIds = new Set(userRecommendedModules.map(m => m.id)); - const otherModules = allModules.filter(m => !recommendedIds.has(m.id)); - + const recommendedIds = new Set( + userRecommendedModules.map((m: Module) => m.id) + ); + const otherModules = allModules.filter( + (m: Module) => !recommendedIds.has(m.id) + ); + setAvailableModules([...userRecommendedModules, ...otherModules]); } catch (error) { console.error("Failed to fetch modules:", error); @@ -75,7 +77,7 @@ export default function RoadmapEditor({ roadmap, onBack, onUpdate }: Props) { const updateRoadmapName = async () => { if (roadmapName.trim() === roadmap.name) return; - + setSaving(true); try { const response = await fetch(`/api/roadmaps/${roadmap.id}`, { @@ -118,16 +120,49 @@ export default function RoadmapEditor({ roadmap, onBack, onUpdate }: Props) { if (!confirm("Remove this module from your roadmap?")) return; try { - const response = await fetch(`/api/roadmaps/${roadmap.id}/items/${moduleId}`, { - method: "DELETE", + const response = await fetch( + `/api/roadmaps/${roadmap.id}/items/${moduleId}`, + { + method: "DELETE", + credentials: "include", + } + ); + + if (response.ok) { + setItems(items.filter((item) => item.module_id !== moduleId)); + } + } catch (error) { + console.error("Failed to remove module:", error); + } + }; + + const handleNotEnrolledClick = async (item: RoadmapItem) => { + if ( + !confirm( + `You need to enroll in "${item.course_name}" to access this module. Enroll now?` + ) + ) { + return; + } + + try { + const response = await fetch("/api/courses/enroll-course", { + method: "POST", + headers: { "Content-Type": "application/json" }, credentials: "include", + body: JSON.stringify({ courseId: item.course_id }), }); if (response.ok) { - setItems(items.filter(item => item.module_id !== moduleId)); + await fetchRoadmapItems(); + window.location.href = `/courses/${item.course_id}/modules/${item.module_id}`; + } else { + const data = await response.json(); + alert(`Failed to enroll: ${data.message}`); } } catch (error) { - console.error("Failed to remove module:", error); + console.error("Failed to enroll in course:", error); + alert("Failed to enroll in course. Please try again."); } }; @@ -161,12 +196,21 @@ export default function RoadmapEditor({ roadmap, onBack, onUpdate }: Props) {

Learning Modules

- +
+ + +
{items.length === 0 ? ( @@ -181,51 +225,115 @@ export default function RoadmapEditor({ roadmap, onBack, onUpdate }: Props) {
) : (
- {items.map((item, index) => ( -
-
- - #{index + 1} - -
-

{item.module_title}

-

- {item.course_name} • {item.module_type} -

- {item.description && ( -

{item.description}

- )} -
- - {item.enrollment_status === 'enrolled' ? 'Enrolled' : 'Not Enrolled'} - - - {item.module_status.replace('_', ' ')} - + {items.map((item, index) => { + const moduleUrl = `/courses/${item.course_id}/modules/${item.module_id}`; + const isAccessible = item.enrollment_status === "enrolled"; + const wasManuallyUnenrolled = + item.enrollment_status === "not_enrolled" && + (item.module_status === "in_progress" || + item.module_status === "completed"); + + return ( +
+
+ + #{index + 1} + +
+
+

{item.module_title}

+ {isAccessible ? ( + + + + ) : wasManuallyUnenrolled ? ( + + ) : ( + + )} +
+

+ {item.course_name} • {item.module_type} +

+ {item.description && ( +

+ {item.description} +

+ )} + {wasManuallyUnenrolled && ( +

+ ⚠️ Access lost: You were manually unenrolled from this + course +

+ )} +
+ + {item.enrollment_status === "enrolled" + ? "Enrolled" + : "Not Enrolled"} + + + {item.module_status.replace("_", " ")} + +
+
- -
- ))} + ); + })}
)}
@@ -242,25 +350,31 @@ export default function RoadmapEditor({ roadmap, onBack, onUpdate }: Props) { ✕
- + {availableModules.length === 0 ? (

No modules available to add.

) : (
{availableModules - .filter(module => !items.some(item => item.module_id === module.id)) + .filter( + (module) => + !items.some((item) => item.module_id === module.id) + ) .map((module, index) => { - const isRecommended = module.matching_tags > 0; + const isRecommended = + module.matching_tags && module.matching_tags > 0; return (
-

{module.module_title}

+

+ {module.module_title} +

{isRecommended && ( ✨ Recommended @@ -271,11 +385,15 @@ export default function RoadmapEditor({ roadmap, onBack, onUpdate }: Props) { {module.course_name} • {module.module_type}

{module.description && ( -

{module.description}

+

+ {module.description} +

)} {module.tags && (

- Tags: {module.tags.filter(tag => tag).join(', ') || 'None'} + Tags:{" "} + {module.tags.filter((tag) => tag).join(", ") || + "None"}

)}
@@ -295,4 +413,4 @@ export default function RoadmapEditor({ roadmap, onBack, onUpdate }: Props) { )}
); -} \ No newline at end of file +} From c032abdc65a5d9d8d82d0cb004f29418203361c4 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 13:04:27 +0800 Subject: [PATCH 06/21] Update prompt name --- app/roadmap/page.tsx | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/app/roadmap/page.tsx b/app/roadmap/page.tsx index f175c90..235de26 100644 --- a/app/roadmap/page.tsx +++ b/app/roadmap/page.tsx @@ -25,7 +25,7 @@ export default function RoadmapPage() { const response = await fetch("/api/roadmaps", { credentials: "include", }); - + if (response.ok) { const data = await response.json(); setRoadmaps(data.roadmaps || []); @@ -60,7 +60,7 @@ export default function RoadmapPage() { }; const autoGenerateRoadmap = async () => { - const name = prompt("Enter roadmap name:", "My AI-Generated Roadmap"); + const name = prompt("Enter roadmap name:", "My Auto-Generated Roadmap"); if (!name?.trim()) return; try { @@ -75,11 +75,15 @@ export default function RoadmapPage() { const data = await response.json(); setRoadmaps([data.roadmap, ...roadmaps]); setSelectedRoadmap(data.roadmap); - + if (data.modulesAdded > 0) { - alert(`Generated roadmap with ${data.modulesAdded} recommended modules based on your skills! You've been auto-enrolled in ${data.enrolledCourses} courses.`); + alert( + `Generated roadmap with ${data.modulesAdded} recommended modules based on your skills! You've been auto-enrolled in ${data.enrolledCourses} courses.` + ); } else { - alert("Generated empty roadmap - no modules found matching your skills. You can add modules manually."); + alert( + "Generated empty roadmap - no modules found matching your skills. You can add modules manually." + ); } } } catch (error) { @@ -95,7 +99,7 @@ export default function RoadmapPage() { }); if (response.ok) { - setRoadmaps(roadmaps.filter(r => r.id !== roadmapId)); + setRoadmaps(roadmaps.filter((r) => r.id !== roadmapId)); if (selectedRoadmap?.id === roadmapId) { setSelectedRoadmap(null); } @@ -106,9 +110,9 @@ export default function RoadmapPage() { }; const updateRoadmap = (updatedRoadmap: Roadmap) => { - setRoadmaps(roadmaps.map(r => - r.id === updatedRoadmap.id ? updatedRoadmap : r - )); + setRoadmaps( + roadmaps.map((r) => (r.id === updatedRoadmap.id ? updatedRoadmap : r)) + ); setSelectedRoadmap(updatedRoadmap); }; From aff758c20b62ba55f2934d106cb8ee99f85cbcd4 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 13:43:48 +0800 Subject: [PATCH 07/21] Add basic settings --- app/settings/page.tsx | 30 +- .../settings/MemberPasswordSettings.tsx | 155 ++++++++++ .../settings/MemberProfileSettings.tsx | 204 ++++++++++++++ .../members/settings/MemberSkillsSettings.tsx | 264 ++++++++++++++++++ 4 files changed, 651 insertions(+), 2 deletions(-) create mode 100644 components/members/settings/MemberPasswordSettings.tsx create mode 100644 components/members/settings/MemberProfileSettings.tsx create mode 100644 components/members/settings/MemberSkillsSettings.tsx diff --git a/app/settings/page.tsx b/app/settings/page.tsx index a6b1030..14e6222 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,5 +1,8 @@ import { getAuthUser } from "@/lib/auth"; import OrgSettings from "@/components/organisation/settings/OrgSettings"; +import MemberProfileSettings from "@/components/members/settings/MemberProfileSettings"; +import MemberPasswordSettings from "@/components/members/settings/MemberPasswordSettings"; +import MemberSkillsSettings from "@/components/members/settings/MemberSkillsSettings"; export default async function SettingsPage() { const user = await getAuthUser(); @@ -21,8 +24,31 @@ export default async function SettingsPage() { if (isMember) { return ( -
-

Member Settings

+
+
+
+

+ Member Settings +

+

+ Manage your profile and account settings +

+
+ +
+
+ +
+ +
+ +
+ + {/*
+ +
*/} +
+
); } diff --git a/components/members/settings/MemberPasswordSettings.tsx b/components/members/settings/MemberPasswordSettings.tsx new file mode 100644 index 0000000..85f7468 --- /dev/null +++ b/components/members/settings/MemberPasswordSettings.tsx @@ -0,0 +1,155 @@ +"use client"; +import { useState } from "react"; + +export default function MemberPasswordSettings() { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setMessage(null); + setError(null); + + // Validate password confirmation + if (newPassword !== confirmPassword) { + setError("New passwords do not match"); + return; + } + + // Validate password strength + if (newPassword.length < 8) { + setError("New password must be at least 8 characters long"); + return; + } + + setIsSaving(true); + try { + const res = await fetch("/api/users/password", { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + currentPassword, + newPassword, + }), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "Failed to update password"); + } + + // Clear form on success + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setError(null); + setMessage("Password updated successfully"); + } catch (err: any) { + setError(err.message || "Failed to update password"); + setMessage(null); + console.error("Error updating password:", err); + } finally { + setIsSaving(false); + } + }; + + const handleReset = () => { + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setError(null); + setMessage(null); + }; + + return ( +
+

+ Change Password +

+
+ {/* Current Password */} +
+ + setCurrentPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 p-2 focus:border-purple-700 focus:ring focus:ring-purple-200" + placeholder="Enter your current password" + /> +
+ + {/* New Password */} +
+ + setNewPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 p-2 focus:border-purple-700 focus:ring focus:ring-purple-200" + placeholder="Enter your new password" + /> +

+ Password must be at least 8 characters long +

+
+ + {/* Confirm New Password */} +
+ + setConfirmPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 p-2 focus:border-purple-700 focus:ring focus:ring-purple-200" + placeholder="Confirm your new password" + /> +
+ + {error && ( +
{error}
+ )} + {message && ( +
+ {message} +
+ )} + + {/* Save / Cancel */} +
+ + +
+
+
+ ); +} diff --git a/components/members/settings/MemberProfileSettings.tsx b/components/members/settings/MemberProfileSettings.tsx new file mode 100644 index 0000000..6c1ecd2 --- /dev/null +++ b/components/members/settings/MemberProfileSettings.tsx @@ -0,0 +1,204 @@ +"use client"; +import { useState, useEffect } from "react"; +import { useAuth } from "@/context/AuthContext"; + +interface UserProfileData { + id: string | number; + firstname: string; + lastname: string; + email: string; +} + +export default function MemberProfileSettings() { + const { user, setUser } = useAuth(); + const [initialData, setInitialData] = useState(null); + const [firstname, setFirstname] = useState(""); + const [lastname, setLastname] = useState(""); + const [email, setEmail] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + + useEffect(() => { + if (user) { + const userData = { + id: user.userId || "", + firstname: user.firstname || "", + lastname: user.lastname || "", + email: user.email || "", + }; + setInitialData(userData); + if (user.firstname) { + setFirstname(user.firstname); + } + if (user.lastname) { + setLastname(user.lastname); + } + if (user.email) { + setEmail(user.email); + } + setLoading(false); + } + }, [user]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setMessage(null); + setError(null); + + if ( + initialData && + initialData.firstname === firstname && + initialData.lastname === lastname && + initialData.email === email + ) { + setMessage("No changes made"); + return; + } + + setIsSaving(true); + try { + const res = await fetch("/api/users/profile", { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + firstname, + lastname, + email, + }), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "Failed to update profile"); + } + + const updatedUser = await res.json(); + + // Update the auth context with new user data + if (updatedUser.user) { + setUser(updatedUser.user); + } + + setInitialData({ id: user?.userId || "", firstname, lastname, email }); + setError(null); + setMessage("Profile updated successfully"); + } catch (err: any) { + setError(err.message || "Failed to update profile"); + setMessage(null); + console.error("Error updating profile:", err); + } finally { + setIsSaving(false); + } + }; + + const handleReset = () => { + if (initialData) { + setFirstname(initialData.firstname); + setLastname(initialData.lastname); + setEmail(initialData.email); + setError(null); + setMessage(null); + } + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); + } + + return ( +
+

+ Profile Settings +

+
+ {/* First Name */} +
+ + setFirstname(e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 p-2 focus:border-purple-700 focus:ring focus:ring-purple-200" + /> +
+ + {/* Last Name */} +
+ + setLastname(e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 p-2 focus:border-purple-700 focus:ring focus:ring-purple-200" + /> +
+ + {/* Email */} +
+ + setEmail(e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 p-2 focus:border-purple-700 focus:ring focus:ring-purple-200" + /> +
+ + {error && ( +
{error}
+ )} + {message && ( +
+ {message} +
+ )} + + {/* Save / Cancel */} +
+ + +
+
+
+ ); +} diff --git a/components/members/settings/MemberSkillsSettings.tsx b/components/members/settings/MemberSkillsSettings.tsx new file mode 100644 index 0000000..39c3e6f --- /dev/null +++ b/components/members/settings/MemberSkillsSettings.tsx @@ -0,0 +1,264 @@ +"use client"; +import { useState, useEffect } from "react"; + +interface Skill { + id: number; + name: string; +} + +interface UserSkill { + id: number; + skill_id: number; + skill_name: string; + level: 'beginner' | 'intermediate' | 'advanced' | 'expert'; +} + +const SKILL_LEVELS = [ + { value: 'beginner', label: 'Beginner' }, + { value: 'intermediate', label: 'Intermediate' }, + { value: 'advanced', label: 'Advanced' }, + { value: 'expert', label: 'Expert' } +]; + +export default function MemberSkillsSettings() { + const [userSkills, setUserSkills] = useState([]); + const [availableSkills, setAvailableSkills] = useState([]); + const [selectedSkill, setSelectedSkill] = useState(""); + const [selectedLevel, setSelectedLevel] = useState("beginner"); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + + useEffect(() => { + loadUserSkills(); + }, []); + + const loadUserSkills = async () => { + try { + const res = await fetch("/api/users/skills", { + credentials: "include", + }); + if (!res.ok) throw new Error("Failed to load skills"); + const data = await res.json(); + setUserSkills(data.userSkills || []); + setAvailableSkills(data.availableSkills || []); + setError(null); + } catch (err: any) { + setError(err.message || "Failed to load skills"); + console.error("Error loading skills:", err); + } finally { + setLoading(false); + } + }; + + const handleAddSkill = async () => { + if (!selectedSkill) return; + + setError(null); + setMessage(null); + + try { + const res = await fetch("/api/users/skills", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + skill_id: parseInt(selectedSkill), + level: selectedLevel, + }), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "Failed to add skill"); + } + + await loadUserSkills(); + setSelectedSkill(""); + setSelectedLevel("beginner"); + setMessage("Skill added successfully"); + } catch (err: any) { + setError(err.message || "Failed to add skill"); + console.error("Error adding skill:", err); + } + }; + + const handleUpdateSkill = async (skillId: number, newLevel: string) => { + setError(null); + setMessage(null); + + try { + const res = await fetch("/api/users/skills", { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + skill_id: skillId, + level: newLevel, + }), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "Failed to update skill"); + } + + await loadUserSkills(); + setMessage("Skill updated successfully"); + } catch (err: any) { + setError(err.message || "Failed to update skill"); + console.error("Error updating skill:", err); + } + }; + + const handleRemoveSkill = async (skillId: number) => { + setError(null); + setMessage(null); + + try { + const res = await fetch("/api/users/skills", { + method: "DELETE", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + skill_id: skillId, + }), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "Failed to remove skill"); + } + + await loadUserSkills(); + setMessage("Skill removed successfully"); + } catch (err: any) { + setError(err.message || "Failed to remove skill"); + console.error("Error removing skill:", err); + } + }; + + const getAvailableSkillsForAdding = () => { + const userSkillIds = userSkills.map(us => us.skill_id); + return availableSkills.filter(skill => !userSkillIds.includes(skill.id)); + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+ ); + } + + return ( +
+

+ Skills Management +

+ + {/* Add New Skill */} +
+

Add New Skill

+
+
+ + +
+
+ + +
+
+ +
+
+
+ + {/* Current Skills */} +
+

Current Skills

+ {userSkills.length === 0 ? ( +

No skills added yet. Add some skills above to get started!

+ ) : ( +
+ {userSkills.map(userSkill => ( +
+
+ {userSkill.skill_name} +
+
+ + +
+
+ ))} +
+ )} +
+ + {error && ( +
{error}
+ )} + {message && ( +
+ {message} +
+ )} +
+ ); +} \ No newline at end of file From d75419a2c7b27d207e1d4cc9fd0f269f00db8870 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 13:54:58 +0800 Subject: [PATCH 08/21] Update admin view to have member settings and move organisation settings under my organisation --- app/organisation/page.tsx | 18 +++++++++++++-- app/settings/page.tsx | 22 +++++++++++++++++-- .../organisation/settings/OrgSettings.tsx | 18 +++++++-------- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/app/organisation/page.tsx b/app/organisation/page.tsx index 4785a44..4c421c4 100644 --- a/app/organisation/page.tsx +++ b/app/organisation/page.tsx @@ -4,10 +4,13 @@ import { useAuth } from "@/context/AuthContext"; import { useState } from "react"; import ManageTags from "@/components/organisation/settings/ManageTags"; import OnboardingConfig from "@/components/organisation/settings/OnboardingConfig"; +import OrgSettings from "@/components/organisation/settings/OrgSettings"; export default function OrganisationsPage() { const { user } = useAuth(); - const [activeTab, setActiveTab] = useState<"tags" | "onboarding">("tags"); + const [activeTab, setActiveTab] = useState< + "tags" | "onboarding" | "orgSettings" + >("tags"); if (!user || !user.hasCompletedOnboarding) { return null; @@ -29,7 +32,7 @@ export default function OrganisationsPage() {

My Organisation

- + {/* Tab Navigation */}
@@ -54,6 +57,16 @@ export default function OrganisationsPage() { > Onboarding Form +
@@ -61,6 +74,7 @@ export default function OrganisationsPage() { {/* Tab Content */} {activeTab === "tags" && } {activeTab === "onboarding" && } + {activeTab === "orgSettings" && }
); } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 14e6222..ecbf480 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -16,8 +16,26 @@ export default async function SettingsPage() { if (isAdmin) { return ( -
- +
+
+
+

+ Member Settings +

+

+ Manage your profile and account settings +

+
+
+
+ +
+ +
+ +
+
+
); } diff --git a/components/organisation/settings/OrgSettings.tsx b/components/organisation/settings/OrgSettings.tsx index 9a1a6e6..7127072 100644 --- a/components/organisation/settings/OrgSettings.tsx +++ b/components/organisation/settings/OrgSettings.tsx @@ -14,7 +14,7 @@ export default function OrgSettings() { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [aiEnabled, setAiEnabled] = useState(false); - const [transferEmail, setTransferEmail] = useState(""); + // const [transferEmail, setTransferEmail] = useState(""); const [isSaving, setIsSaving] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -86,12 +86,12 @@ export default function OrgSettings() { } }; - const handleTransfer = async () => { - if (!transferEmail) return; - // open confirmation modal... - // then call API to transfer ownership - alert(`Transfer ownership is not implemented yet.`); - }; + // const handleTransfer = async () => { + // if (!transferEmail) return; + // // open confirmation modal... + // // then call API to transfer ownership + // alert(`Transfer ownership is not implemented yet.`); + // }; return (
@@ -143,7 +143,7 @@ export default function OrgSettings() {
{/* Transfer Ownership */} -
+ {/*
@@ -163,7 +163,7 @@ export default function OrgSettings() { Transfer
-
+
*/} {error && (
{error}
From ebfd90b7d879d0b549bc7f0e8a633c87c9a22104 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 18:00:32 +0800 Subject: [PATCH 09/21] bsic roadmap --- app/organisation/page.tsx | 38 +- app/settings/page.tsx | 4 +- .../members/settings/MemberSkillsSettings.tsx | 461 +++++++++++++++--- .../organisation/courses/CourseCard.tsx | 31 +- .../organisation/courses/CourseForm.tsx | 95 +++- .../organisation/courses/ModuleCard.tsx | 15 +- .../organisation/courses/ModuleForm.tsx | 30 +- .../organisation/settings/ManageChannels.tsx | 129 +++++ .../organisation/settings/ManageLevels.tsx | 146 ++++++ .../organisation/settings/ManageSkills.tsx | 129 +++++ .../settings/OnboardingConfig.tsx | 177 +++++-- components/reports/AdminBasicReport.tsx | 67 ++- components/reports/BasicReport.tsx | 44 +- components/roadmap/RoadmapEditor.tsx | 183 ++++++- components/roadmap/types.ts | 27 +- 15 files changed, 1376 insertions(+), 200 deletions(-) create mode 100644 components/organisation/settings/ManageChannels.tsx create mode 100644 components/organisation/settings/ManageLevels.tsx create mode 100644 components/organisation/settings/ManageSkills.tsx diff --git a/app/organisation/page.tsx b/app/organisation/page.tsx index 4c421c4..b172f42 100644 --- a/app/organisation/page.tsx +++ b/app/organisation/page.tsx @@ -2,15 +2,17 @@ import { useAuth } from "@/context/AuthContext"; import { useState } from "react"; -import ManageTags from "@/components/organisation/settings/ManageTags"; +import ManageSkills from "@/components/organisation/settings/ManageSkills"; +import ManageChannels from "@/components/organisation/settings/ManageChannels"; +import ManageLevels from "@/components/organisation/settings/ManageLevels"; import OnboardingConfig from "@/components/organisation/settings/OnboardingConfig"; import OrgSettings from "@/components/organisation/settings/OrgSettings"; export default function OrganisationsPage() { const { user } = useAuth(); const [activeTab, setActiveTab] = useState< - "tags" | "onboarding" | "orgSettings" - >("tags"); + "skills" | "channels" | "levels" | "onboarding" | "orgSettings" + >("skills"); if (!user || !user.hasCompletedOnboarding) { return null; @@ -38,14 +40,34 @@ export default function OrganisationsPage() {
{/* Tab Content */} - {activeTab === "tags" && } + {activeTab === "skills" && } + {activeTab === "channels" && } + {activeTab === "levels" && } {activeTab === "onboarding" && } {activeTab === "orgSettings" && }
diff --git a/app/settings/page.tsx b/app/settings/page.tsx index ecbf480..e6ce558 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -62,9 +62,9 @@ export default async function SettingsPage() {
- {/*
+
-
*/} +
diff --git a/components/members/settings/MemberSkillsSettings.tsx b/components/members/settings/MemberSkillsSettings.tsx index 39c3e6f..5addfd9 100644 --- a/components/members/settings/MemberSkillsSettings.tsx +++ b/components/members/settings/MemberSkillsSettings.tsx @@ -13,6 +13,36 @@ interface UserSkill { level: 'beginner' | 'intermediate' | 'advanced' | 'expert'; } +interface Channel { + id: number; + name: string; + description?: string; +} + +interface Level { + id: number; + name: string; + description?: string; + sort_order?: number; +} + +interface UserChannel { + id: number; + channel_id: number; + channel_name: string; + channel_description?: string; + preference_rank: number; +} + +interface UserLevel { + id: number; + level_id: number; + level_name: string; + level_description?: string; + sort_order?: number; + preference_rank: number; +} + const SKILL_LEVELS = [ { value: 'beginner', label: 'Beginner' }, { value: 'intermediate', label: 'Intermediate' }, @@ -25,12 +55,22 @@ export default function MemberSkillsSettings() { const [availableSkills, setAvailableSkills] = useState([]); const [selectedSkill, setSelectedSkill] = useState(""); const [selectedLevel, setSelectedLevel] = useState("beginner"); + + // Channel and Level preferences + const [userChannels, setUserChannels] = useState([]); + const [userLevels, setUserLevels] = useState([]); + const [availableChannels, setAvailableChannels] = useState([]); + const [availableLevels, setAvailableLevels] = useState([]); + const [selectedChannel, setSelectedChannel] = useState(""); + const [selectedPreferenceLevel, setSelectedPreferenceLevel] = useState(""); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [message, setMessage] = useState(null); useEffect(() => { loadUserSkills(); + loadUserPreferences(); }, []); const loadUserSkills = async () => { @@ -46,6 +86,24 @@ export default function MemberSkillsSettings() { } catch (err: any) { setError(err.message || "Failed to load skills"); console.error("Error loading skills:", err); + } + }; + + const loadUserPreferences = async () => { + try { + const res = await fetch("/api/users/preferences", { + credentials: "include", + }); + if (!res.ok) throw new Error("Failed to load preferences"); + const data = await res.json(); + setUserChannels(data.userChannels || []); + setUserLevels(data.userLevels || []); + setAvailableChannels(data.availableChannels || []); + setAvailableLevels(data.availableLevels || []); + setError(null); + } catch (err: any) { + setError(err.message || "Failed to load preferences"); + console.error("Error loading preferences:", err); } finally { setLoading(false); } @@ -138,11 +196,135 @@ export default function MemberSkillsSettings() { } }; + const handleAddChannel = async () => { + if (!selectedChannel) return; + + setError(null); + setMessage(null); + + try { + const res = await fetch("/api/users/preferences/channels", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + channel_id: parseInt(selectedChannel), + }), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "Failed to add channel preference"); + } + + await loadUserPreferences(); + setSelectedChannel(""); + setMessage("Channel preference added successfully"); + } catch (err: any) { + setError(err.message || "Failed to add channel preference"); + console.error("Error adding channel preference:", err); + } + }; + + const handleAddLevel = async () => { + if (!selectedPreferenceLevel) return; + + setError(null); + setMessage(null); + + try { + const res = await fetch("/api/users/preferences/levels", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + level_id: parseInt(selectedPreferenceLevel), + }), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "Failed to add level preference"); + } + + await loadUserPreferences(); + setSelectedPreferenceLevel(""); + setMessage("Level preference added successfully"); + } catch (err: any) { + setError(err.message || "Failed to add level preference"); + console.error("Error adding level preference:", err); + } + }; + + const handleRemoveChannel = async (channelId: number) => { + setError(null); + setMessage(null); + + try { + const res = await fetch("/api/users/preferences/channels", { + method: "DELETE", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + channel_id: channelId, + }), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "Failed to remove channel preference"); + } + + await loadUserPreferences(); + setMessage("Channel preference removed successfully"); + } catch (err: any) { + setError(err.message || "Failed to remove channel preference"); + console.error("Error removing channel preference:", err); + } + }; + + const handleRemoveLevel = async (levelId: number) => { + setError(null); + setMessage(null); + + try { + const res = await fetch("/api/users/preferences/levels", { + method: "DELETE", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + level_id: levelId, + }), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "Failed to remove level preference"); + } + + await loadUserPreferences(); + setMessage("Level preference removed successfully"); + } catch (err: any) { + setError(err.message || "Failed to remove level preference"); + console.error("Error removing level preference:", err); + } + }; + const getAvailableSkillsForAdding = () => { const userSkillIds = userSkills.map(us => us.skill_id); return availableSkills.filter(skill => !userSkillIds.includes(skill.id)); }; + const getAvailableChannelsForAdding = () => { + const userChannelIds = userChannels.map(uc => uc.channel_id); + return availableChannels.filter(channel => !userChannelIds.includes(channel.id)); + }; + + const getAvailableLevelsForAdding = () => { + const userLevelIds = userLevels.map(ul => ul.level_id); + return availableLevels.filter(level => !userLevelIds.includes(level.id)); + }; + if (loading) { return (
@@ -158,104 +340,241 @@ export default function MemberSkillsSettings() { } return ( -
+

- Skills Management + Learning Preferences

- {/* Add New Skill */} -
-

Add New Skill

-
-
- - -
-
- - -
-
+
+ + {/* Skills Section */} +
+

Skills

+ + {/* Add New Skill */} +
+

Add New Skill

+
+
+ + +
+
+ + +
+ + {/* Current Skills */} +
+

Current Skills

+ {userSkills.length === 0 ? ( +

No skills added yet.

+ ) : ( +
+ {userSkills.map(userSkill => ( +
+
+ {userSkill.skill_name} +
+
+ + +
+
+ ))} +
+ )} +
- {/* Current Skills */} -
-

Current Skills

- {userSkills.length === 0 ? ( -

No skills added yet. Add some skills above to get started!

- ) : ( + {/* Channels Section */} +
+

Preferred Channels

+ + {/* Add New Channel */} +
+

Add Channel Preference

- {userSkills.map(userSkill => ( -
-
- {userSkill.skill_name} -
-
- +
+ + +
+ +
+
+ + {/* Current Channels */} +
+

Current Preferences

+ {userChannels.length === 0 ? ( +

No channel preferences set.

+ ) : ( +
+ {userChannels.map(userChannel => ( +
+
+ {userChannel.channel_name} + {userChannel.channel_description && ( +

{userChannel.channel_description}

+ )} +
-
- ))} + ))} +
+ )} +
+
+ + {/* Levels Section */} +
+

Preferred Levels

+ + {/* Add New Level */} +
+

Add Level Preference

+
+
+ + +
+
- )} +
+ + {/* Current Levels */} +
+

Current Preferences

+ {userLevels.length === 0 ? ( +

No level preferences set.

+ ) : ( +
+ {userLevels.map(userLevel => ( +
+
+ {userLevel.level_name} + {userLevel.level_description && ( +

{userLevel.level_description}

+ )} +
+ +
+ ))} +
+ )} +
+
+
{error && ( -
{error}
+
{error}
)} {message && ( -
+
{message}
)} diff --git a/components/organisation/courses/CourseCard.tsx b/components/organisation/courses/CourseCard.tsx index ba75070..2b3892c 100644 --- a/components/organisation/courses/CourseCard.tsx +++ b/components/organisation/courses/CourseCard.tsx @@ -2,9 +2,17 @@ import Link from "next/link"; -export interface Tag { +export interface Channel { id: number; name: string; + description?: string; +} + +export interface Level { + id: number; + name: string; + description?: string; + sort_order?: number; } export interface Course { @@ -13,7 +21,8 @@ export interface Course { description?: string; total_modules?: number; completed_modules?: number; - tags?: Tag[]; + channel?: Channel; + level?: Level; } interface Props { @@ -118,16 +127,18 @@ export default function CourseCard({

{course.name}

{course.description?.slice(0, 100)}

- {course.tags && course.tags.length > 0 && ( + {(course.channel || course.level) && (
- {course.tags.map((t) => ( - - {t.name} + {course.channel && ( + + {course.channel.name} + + )} + {course.level && ( + + {course.level.name} - ))} + )}
)} diff --git a/components/organisation/courses/CourseForm.tsx b/components/organisation/courses/CourseForm.tsx index 19a9525..21413b9 100644 --- a/components/organisation/courses/CourseForm.tsx +++ b/components/organisation/courses/CourseForm.tsx @@ -9,11 +9,19 @@ export interface Course { id: number; name: string; description?: string; - tags?: { id: number; name: string }[]; + channel?: { id: number; name: string; description?: string }; + level?: { id: number; name: string; description?: string; sort_order?: number }; } -interface Tag { +interface Channel { id: number; name: string; + description?: string; +} +interface Level { + id: number; + name: string; + description?: string; + sort_order?: number; } interface Option { value: number; @@ -28,17 +36,30 @@ interface Props { export default function CourseForm({ mode, courseId }: Props) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); - const [allTags, setAllTags] = useState([]); - const [options, setOptions] = useState([]); - const [selected, setSelected] = useState([]); + const [allChannels, setAllChannels] = useState([]); + const [allLevels, setAllLevels] = useState([]); + const [channelOptions, setChannelOptions] = useState([]); + const [levelOptions, setLevelOptions] = useState([]); + const [selectedChannel, setSelectedChannel] = useState
-
- - setSelectedChannel(opt as Option)} + placeholder="Select a channel..." + /> +
+
+ + setSelected(opts as Option[])} + placeholder="Select skills..." />
diff --git a/components/organisation/settings/ManageChannels.tsx b/components/organisation/settings/ManageChannels.tsx new file mode 100644 index 0000000..f457d40 --- /dev/null +++ b/components/organisation/settings/ManageChannels.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState, useEffect } from "react"; + +interface Channel { + id: number; + name: string; + description: string; +} + +export default function ManageChannels() { + const [channels, setChannels] = useState([]); + const [newName, setNewName] = useState(""); + const [newDescription, setNewDescription] = useState(""); + + const fetchChannels = () => + fetch("/api/courses/channels", { credentials: "include" }) + .then((r) => r.json()) + .then(setChannels) + .catch(console.error); + + useEffect(() => { + fetchChannels(); + }, []); + + const addChannel = async () => { + const name = newName.trim(); + if (!name) return; + const res = await fetch("/api/courses/add-channel", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + description: newDescription.trim() + }), + }); + if (res.ok) { + setNewName(""); + setNewDescription(""); + fetchChannels(); + } else { + alert("Failed to add channel"); + } + }; + + const deleteChannel = async (id: number) => { + if ( + !confirm( + "Delete this channel? All courses using this channel will be affected" + ) + ) + return; + const res = await fetch("/api/courses/delete-channel", { + method: "DELETE", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ channelId: id }), + }); + if (res.ok) fetchChannels(); + else alert("Delete failed"); + }; + + return ( +
+

Manage Channels

+

Channels represent topics or subject areas for courses

+ +
+
+ setNewName(e.target.value)} + className="w-full p-2 border rounded focus:outline-none focus:ring" + /> +