diff --git a/app/courses/[courseId]/layout.tsx b/app/courses/[courseId]/layout.tsx index 5c27e2b..c1d7f8a 100644 --- a/app/courses/[courseId]/layout.tsx +++ b/app/courses/[courseId]/layout.tsx @@ -15,7 +15,7 @@ export default async function CourseLayout({ const response = { id: courseId, - name: "Sample Course", // Replace with actual API call to fetch course details + name: "Sample Course", }; return (
diff --git a/app/courses/[courseId]/modules/[moduleId]/page.tsx b/app/courses/[courseId]/modules/[moduleId]/page.tsx index f948bb9..c4844b4 100644 --- a/app/courses/[courseId]/modules/[moduleId]/page.tsx +++ b/app/courses/[courseId]/modules/[moduleId]/page.tsx @@ -1,4 +1,3 @@ -// app/courses/[courseId]/modules/[moduleId]/page.tsx import ModuleDetail from "@/components/organisation/courses/ModuleDetail"; import { getAuthUser } from "@/lib/auth"; diff --git a/app/courses/layout.tsx b/app/courses/layout.tsx index 675d669..ea2f2a1 100644 --- a/app/courses/layout.tsx +++ b/app/courses/layout.tsx @@ -1,4 +1,3 @@ -// app/courses/layout.tsx import { ReactNode } from "react"; export default function CoursesLayout({ children }: { children: ReactNode }) { diff --git a/app/courses/new/page.tsx b/app/courses/new/page.tsx index f47e241..3ec8774 100644 --- a/app/courses/new/page.tsx +++ b/app/courses/new/page.tsx @@ -1,4 +1,3 @@ -// app/courses/new/page.tsx import { redirect } from "next/navigation"; import { getAuthUser } from "@/lib/auth"; import CourseForm from "@/components/organisation/courses/CourseForm"; diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index da6092a..f47d987 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,4 +1,6 @@ import { getAuthUser } from "@/lib/auth"; +import UserDashboard from "@/components/dashboard/UserDashboard"; +import AdminDashboard from "@/components/dashboard/AdminDashboard"; export default async function DashboardPage() { const user = await getAuthUser(); @@ -6,12 +8,19 @@ export default async function DashboardPage() { return null; } + const isAdmin = user?.organisation?.role === "admin"; + if (isAdmin) { + return ( +
+

Dashboard

+ +
+ ); + } return ( -
-

Welcome, {user.firstname || user.email}

-

Organisation: {user.organisation?.organisationname}

-

Role: {user.organisation.role}

-

Dashboard is currently in progress.

+
+

Dashboard

+
); } diff --git a/app/history/page.tsx b/app/history/page.tsx index 093fd4e..b9e7649 100644 --- a/app/history/page.tsx +++ b/app/history/page.tsx @@ -1,8 +1,22 @@ +"use client"; + +import { useAuth } from "@/context/AuthContext"; +import HistoryComponent from "@/components/history/HistoryComponent"; export default function HistoryPage() { + const { user } = useAuth(); + + if (!user || !user.hasCompletedOnboarding) { + return null; + } return (
-

History

-

History page content goes here.

+

+ Activity History +

+

+ View your recent activity and changes made in the system. +

+
); } 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..b172f42 100644 --- a/app/organisation/page.tsx +++ b/app/organisation/page.tsx @@ -1,10 +1,19 @@ "use client"; import { useAuth } from "@/context/AuthContext"; -import ManageTags from "@/components/organisation/settings/ManageTags"; +import { useState } from "react"; +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< + "skills" | "channels" | "levels" | "onboarding" | "orgSettings" + >("skills"); + if (!user || !user.hasCompletedOnboarding) { return null; } @@ -22,10 +31,74 @@ export default function OrganisationsPage() { return (
-

+

My Organisation

- + + {/* Tab Navigation */} +
+
+ +
+
+ + {/* Tab Content */} + {activeTab === "skills" && } + {activeTab === "channels" && } + {activeTab === "levels" && } + {activeTab === "onboarding" && } + {activeTab === "orgSettings" && }
); } diff --git a/app/roadmap/page.tsx b/app/roadmap/page.tsx index bf5fba0..235de26 100644 --- a/app/roadmap/page.tsx +++ b/app/roadmap/page.tsx @@ -1,8 +1,142 @@ +"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 Auto-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/app/settings/page.tsx b/app/settings/page.tsx index a6b1030..e6ce558 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(); @@ -13,16 +16,57 @@ export default async function SettingsPage() { if (isAdmin) { return ( -
- +
+
+
+

+ Member Settings +

+

+ Manage your profile and account settings +

+
+
+
+ +
+ +
+ +
+
+
); } if (isMember) { return ( -
-

Member Settings

+
+
+
+

+ Member Settings +

+

+ Manage your profile and account settings +

+
+ +
+
+ +
+ +
+ +
+ +
+ +
+
+
); } diff --git a/components/SideNav.tsx b/components/SideNav.tsx index 6d38d41..9ede2c1 100644 --- a/components/SideNav.tsx +++ b/components/SideNav.tsx @@ -8,7 +8,6 @@ import { HomeIcon, BookOpenIcon, ChartBarIcon, - UsersIcon, MapIcon, ClockIcon, CogIcon, @@ -30,7 +29,6 @@ const menuSections = [ heading: "Management", items: [ { label: "History", href: "/history", icon: ClockIcon }, - // { label: "Organisations", href: "/organisations", icon: UsersIcon }, { label: "Settings", href: "/settings", icon: CogIcon }, ], }, diff --git a/components/dashboard/AdminDashboard.tsx b/components/dashboard/AdminDashboard.tsx new file mode 100644 index 0000000..23b9c2a --- /dev/null +++ b/components/dashboard/AdminDashboard.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; + +interface Employee { + id: number; + firstname: string; + lastname: string; + totalCourses: number; + completedCourses: number; +} + +interface Enrollment { + courseName: string; + enrolledCount: number; +} + +interface AdminDashboardData { + welcome: string; + employees: Employee[]; + enrollments: Enrollment[]; +} + +export default function AdminDashboard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/dashboard/admin-dashboard", { credentials: "include" }) + .then(async (res) => { + if (!res.ok) throw new Error("Failed to load dashboard"); + const json = await res.json(); + const enrollments = (json.enrollments || []).map((e: any) => ({ + courseName: e.coursename, + enrolledCount: Number(e.enrolledcount), + })); + const employees = (json.employees || []).map((emp: any) => ({ + id: emp.id, + firstname: emp.firstname, + lastname: emp.lastname, + totalCourses: Number(emp.totalCourses), + completedCourses: Number(emp.completedCourses), + })); + setData({ ...json, employees, enrollments }); + }) + .catch((err) => setError(err.message || "Unknown error")) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+
+
+ ); + } + if (error || !data) { + return
{error}
; + } + + // Calculate quick stats + const totalEmployees = data.employees.length; + const totalCourses = data.enrollments.length; + const totalEnrollments = data.enrollments.reduce( + (a, e) => a + e.enrolledCount, + 0 + ); + const totalCompleted = data.employees.reduce( + (a, e) => a + e.completedCourses, + 0 + ); + + return ( +
+

{data.welcome}

+ +
+ + + + +
+ +
+

Course Enrollments

+
+ + + + + + + + + +
+
+ +
+

Employee Course Progress

+
+ + + + + + + + + + {data.employees.map((emp) => ( + + + + + + ))} + +
NameEnrolledCompleted
+ {emp.firstname} {emp.lastname} + {emp.totalCourses}{emp.completedCourses}
+
+
+
+ ); +} + +function DashboardStat({ label, value }: { label: string; value: number }) { + return ( +
+
{value}
+
{label}
+
+ ); +} diff --git a/components/dashboard/UserDashboard.tsx b/components/dashboard/UserDashboard.tsx new file mode 100644 index 0000000..dbdb95c --- /dev/null +++ b/components/dashboard/UserDashboard.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; + +interface Course { + id: number; + name: string; +} +interface Module { + id: number; + title: string; +} +interface DashboardData { + welcome: string; + currentCourse: Course | null; + currentModule: Module | null; + nextToLearn: Module[]; + toRevise: Module[]; + summaryStats: { + completedModules: number; + totalModules: number; + }; + globalStats: { + completedModules: number; + totalModules: number; + }; +} + +export default function UserDashboard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/dashboard/user-dashboard", { credentials: "include" }) + .then(async (res) => { + if (!res.ok) throw new Error("Failed to load dashboard"); + const json = await res.json(); + setData(json); + }) + .catch((err) => setError(err.message || "Unknown error")) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+
+
+ ); + } + if (error || !data) { + return ( +
+ {error || "No data."} +
+ ); + } + + const { + welcome, + currentCourse, + currentModule, + nextToLearn, + toRevise, + summaryStats, + globalStats, + } = data; + + return ( +
+
+
+
+
+
+ {welcome} +
+
+ +
+
+ Continue where you left off +
+
+ {currentCourse && currentModule ? ( + <> +
+
+ {currentCourse.name} +
+
+ Module: {currentModule.title} +
+ + Resume + +
+ + ) : ( +
+ No module in progress. +
+ )} +
+
+ +
+
+ Next Steps For You +
+
+
+
Learn
+ {nextToLearn && nextToLearn.length > 0 ? ( + nextToLearn.map((mod) => ( + + {mod.title} + + )) + ) : ( +
All caught up!
+ )} +
+
+
Revise
+ {toRevise && toRevise.length > 0 ? ( + toRevise.map((mod) => ( + + {mod.title} + + )) + ) : ( +
Nothing to revise.
+ )} +
+
+
+
+ +
+
+
+ At a glance +
+ +
+
+ Modules Completed{" "} + + (Current Course) + +
+
+ + {summaryStats.completedModules} + + / + + {summaryStats.totalModules} + +
+
+ {currentCourse?.name} +
+ {/* Progress bar */} +
+
0 + ? (summaryStats.completedModules / + summaryStats.totalModules) * + 100 + : 0 + }%`, + }} + >
+
+
+ +
+
+ Modules Completed{" "} + (All Courses) +
+
+ + {globalStats.completedModules} + + / + + {globalStats.totalModules} + +
+
+ across all courses +
+
+
0 + ? (globalStats.completedModules / + globalStats.totalModules) * + 100 + : 0 + }%`, + }} + >
+
+
+
+
+
+
+
+ ); +} diff --git a/components/history/HistoryComponent.tsx b/components/history/HistoryComponent.tsx new file mode 100644 index 0000000..fae2ccd --- /dev/null +++ b/components/history/HistoryComponent.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface ActivityLog { + id: number; + action: string; + metadata: Record; + created_at: string; +} + +export default function HistoryComponent() { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/activity", { credentials: "include" }) + .then((res) => { + if (!res.ok) throw new Error(`Status ${res.status}`); + return res.json(); + }) + .then(({ logs }: { logs: ActivityLog[] }) => setLogs(logs)) + .catch((err) => { + console.error(err); + setError("Failed to load history."); + }) + .finally(() => setLoading(false)); + }, []); + + if (loading) return

Loading history…

; + if (error) return

{error}

; + if (!logs.length) return

No history yet.

; + + // sort newest first + const sorted = [...logs].sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + + const formatKey = (key: string) => + key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + + return ( +
    + {sorted.map((log, idx) => { + const title = log.action + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + const time = new Date(log.created_at).toLocaleString(); + + return ( +
  1. + + +
    + {idx + 1}. +

    {title}

    + +
    + + {Object.keys(log.metadata).length > 0 && ( +
    + + View details + +
      + {Object.entries(log.metadata).map(([key, val]) => ( +
    • + {formatKey(key)}:{" "} + {String(val)} +
    • + ))} +
    +
    + )} +
  2. + ); + })} +
+ ); +} diff --git a/components/members/settings/MemberPasswordSettings.tsx b/components/members/settings/MemberPasswordSettings.tsx new file mode 100644 index 0000000..7437379 --- /dev/null +++ b/components/members/settings/MemberPasswordSettings.tsx @@ -0,0 +1,148 @@ +"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); + + if (newPassword !== confirmPassword) { + setError("New passwords do not match"); + return; + } + + 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"); + } + + 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 +

+
+
+ + 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" + /> +
+ +
+ + 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 +

+
+ +
+ + 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} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/components/members/settings/MemberProfileSettings.tsx b/components/members/settings/MemberProfileSettings.tsx new file mode 100644 index 0000000..c33ec13 --- /dev/null +++ b/components/members/settings/MemberProfileSettings.tsx @@ -0,0 +1,200 @@ +"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 +

+
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/components/members/settings/MemberSkillsSettings.tsx b/components/members/settings/MemberSkillsSettings.tsx new file mode 100644 index 0000000..f65bf25 --- /dev/null +++ b/components/members/settings/MemberSkillsSettings.tsx @@ -0,0 +1,660 @@ +"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"; +} + +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" }, + { 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"); + + // 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 () => { + 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); + } + }; + + 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); + } + }; + + 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 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 ( +
+
+
+
+
+
+
+
+
+ ); + } + + return ( +
+

+ Learning Preferences +

+ +
+
+

+ Skills +

+ +
+

+ Add New Skill +

+
+
+ + +
+
+ + +
+ +
+
+ +
+

+ Current Skills +

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

+ No skills added yet. +

+ ) : ( +
+ {userSkills.map((userSkill) => ( +
+
+ + {userSkill.skill_name} + +
+
+ + +
+
+ ))} +
+ )} +
+
+ +
+

+ Preferred Channels +

+ +
+

+ Add Channel Preference +

+
+
+ + +
+ +
+
+ +
+

+ Current Preferences +

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

+ No channel preferences set. +

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

+ {userChannel.channel_description} +

+ )} +
+ +
+ ))} +
+ )} +
+
+ +
+

+ Preferred Levels +

+ +
+

+ Add Level Preference +

+
+
+ + +
+ +
+
+ +
+

+ Current Preferences +

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

+ No level preferences set. +

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

+ {userLevel.level_description} +

+ )} +
+ +
+ ))} +
+ )} +
+
+
+ + {error && ( +
+ {error} +
+ )} + {message && ( +
+ {message} +
+ )} +
+ ); +} 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/OrgNav.tsx b/components/organisation/OrgNav.tsx index bc14a2a..7ae3ae6 100644 --- a/components/organisation/OrgNav.tsx +++ b/components/organisation/OrgNav.tsx @@ -8,6 +8,7 @@ import { HomeIcon, BookOpenIcon, ChartBarIcon, + ClockIcon, UsersIcon, CogIcon, } from "@heroicons/react/24/outline"; @@ -28,6 +29,7 @@ const menuSections = [ items: [ { label: "My Organisation", href: "/organisation", icon: UsersIcon }, { label: "Users", href: "/users", icon: UsersIcon }, + { label: "History", href: "/history", icon: ClockIcon }, { label: "Settings", href: "/settings", icon: CogIcon }, ], }, 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..42ebfdb 100644 --- a/components/organisation/courses/CourseForm.tsx +++ b/components/organisation/courses/CourseForm.tsx @@ -1,4 +1,3 @@ -// components/organisation/courses/CourseForm.tsx "use client"; import { useState, useEffect } from "react"; @@ -9,11 +8,24 @@ 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 Channel { + id: number; + name: string; + description?: string; } -interface Tag { +interface Level { id: number; name: string; + description?: string; + sort_order?: number; } interface Option { value: number; @@ -28,17 +40,32 @@ 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" + /> +